diff --git a/examples/empty-vault/log.md b/examples/empty-vault/log.md index e6d347e..6df2e32 100644 --- a/examples/empty-vault/log.md +++ b/examples/empty-vault/log.md @@ -3,6 +3,6 @@ Append-only history of SecondBrain operations. This sanitized seed starts with a single setup entry so lint can validate a fresh install cleanly. -## [2026-01-01] setup | Empty vault scaffold +## [1970-01-01] setup | Empty vault scaffold Initialized the sanitized empty-vault example with no personal content, source material, Daily Notes, project state, or activity history. diff --git a/framework/AGENTS.md b/framework/AGENTS.md index 0840d8f..f51760a 100644 --- a/framework/AGENTS.md +++ b/framework/AGENTS.md @@ -120,11 +120,17 @@ compound across sessions. - Valid working-note statuses are `current`, `parked`, and `reference`. - `reference` means the note is no longer an active work item but is retained because its reasoning, examples, or context still have value. +- Reusable operational assets such as prompt templates, checklists, method + notes, and actively used research packs remain `current` while they are still + being used or iterated. Do not treat `reference` as meaning merely + "reusable". - Keep stable lowercase kebab-case filenames for managed working notes and update `work/index.md` plus relevant Daily Note references when lifecycle state or scope changes materially. -- `work/wiki-open-questions.md` remains the DTM-managed operational queue for - unresolved wiki questions and follows the same workspace conventions. +- `work/wiki-open-questions.md` is the DTM-managed operational queue for open + questions that are worth carrying beyond one day. It centralises both + unresolved wiki questions and non-wiki DTM questions so they do not remain + fragmented across Daily Notes and individual working documents. ### `documents/` — collaborative internal documents workspace @@ -132,7 +138,7 @@ compound across sessions. bookkeeping for authored internal documents such as strategies, architectural principles, operating models, and similar work products. - `documents/index.md` is the curated catalogue of the document workspace. - Keep every draft, final document, and managed deliverable listed exactly once + Keep every draft, final document, reference document, and managed deliverable listed exactly once under its current stage with a one-line description concrete enough to route future work without opening each file. - `documents/drafts/` contains active private drafting. Agents may create and @@ -142,6 +148,12 @@ compound across sessions. kebab-case filenames across stages and update `documents/index.md`, the root `log.md` when the change is materially significant, and relevant Daily Note references in the same change. +- `documents/reference/` contains non-authored reference documents kept for + context, citation, or future drafting support. These may remain in their + original file formats and filenames. They are part of the document workspace, + not the wiki source-ingestion queue, and must never be treated as authored + canonical documents unless the user explicitly promotes or rewrites them into + `documents/drafts/` or `documents/final/`. - `documents/deliverables/` contains shareable file-format variants of those documents, such as `.pptx`, `.docx`, and `.xlsx`. Keep deliverables grouped under a folder named for the document slug where practical, for example @@ -183,6 +195,10 @@ compound across sessions. success and the canonical URL and publication time are recorded. - A failed or ambiguous publication remains in `writing/ready/`; never mark or move it as published optimistically. +- `writing/archive/` contains retained records of formerly published pieces + that should no longer be treated as part of the active public website corpus. + Use `status: archived`, preserve the former `canonical_url` and + `published_at`, and add `archived_at` plus a concise `archive_reason`. - Keep a stable lowercase kebab-case filename across stages. Update frontmatter, `writing/index.md`, the root `log.md` when the change is materially significant, and relevant Daily Note references in the same change. diff --git a/framework/DTM.md b/framework/DTM.md index 84fa202..b202cd9 100644 --- a/framework/DTM.md +++ b/framework/DTM.md @@ -98,6 +98,8 @@ Working-note statuses are: - `parked` — inactive for now but likely to resume. - `reference` — no longer active work, but retained because the note still has useful context or reasoning. +Reusable operational assets in `work/`, such as prompt templates, checklists, research packs, or working methods that are still being actively used or revised, should remain `current`. Do not mark a note `reference` merely because it is reusable. + Keep project filenames and managed working-note filenames in stable lowercase kebab-case. Use `templates/project.md` for projects and `templates/working-note.md` for new managed working notes. @@ -143,6 +145,12 @@ Agents may work freely in `documents/drafts/`. Move a document to version. These documents are not a publication queue; they are the internal canonical record for approved work. +Use `documents/reference/` for non-authored reference documents that should +live inside the document workspace without becoming authored canonicals or wiki +sources. Keep them in their original formats where that is the point of the +reference copy, catalogue them in `documents/index.md`, and do not treat them +as drafts, finals, or deliverables. + Place shareable file-format variants such as PowerPoint, Word, and spreadsheet files under `documents/deliverables//` when they correspond to a document workspace item. Treat the Markdown file in `documents/drafts/` or @@ -162,6 +170,12 @@ Before creating or materially rewriting an internal document, read from `documents/final/` as part of the approved corpus, but must never inspect `documents/drafts/`. +If the user says they have edited a managed document directly in Obsidian or +otherwise outside the current chat, treat the on-disk file as authoritative. +Re-read that file immediately before promotion, deliverable generation, or any +final review step that depends on the latest wording. Do not rely solely on the +version already held in conversational context. + ## Interaction capture For substantive DTM work, use today's Daily Note. If none exists, run @@ -262,23 +276,24 @@ entry. note immediately; preserve the resolution in activity, a decision, project, or wiki page as appropriate. Historical notes remain historical. -## Wiki open questions - -Wiki open questions are managed separately from Daily Note open questions. - -- Use `work/wiki-open-questions.md` as the operational queue for unresolved - questions still present in wiki pages. -- Refresh it with `python3 tools/wiki.py questions --write` after wiki work - changes any `## Open questions` or source `## Questions raised` section. -- Triage each item by changing its status token to `answer-myself`, - `research-with-dtm`, or `ready-to-integrate` when `needs-triage` is no longer - accurate. -- If two page-level questions are really the same underlying question, add the - same `` marker to both bullets so the - queue folds them into one research thread with multiple source links. The +## Open questions + +Use `work/wiki-open-questions.md` as the single operational queue for open +questions that are worth carrying beyond one day. + +- Promote a Daily Note question into this queue when it is durable enough to + matter beyond one day and should be tracked without repeated carry-forward. +- Keep each queued question linked to the note or project where the answer will + eventually need integrating. +- When a question is answered, update the relevant note or project first, then + remove the question from the queue so the queue reflects the live state. +- Wiki-originated questions still require `python3 tools/wiki.py questions --write` + after wiki work changes any `## Open questions` or source `## Questions raised` + section. +- If two wiki page-level questions are really the same underlying question, add + the same `` marker to both bullets so + the queue folds them into one research thread with multiple source links. The final answer may still need integrating back into multiple wiki pages. -- When a question is answered, update the underlying wiki page first, then - refresh the queue so the question stops surfacing there. ## Day close and day open @@ -295,7 +310,14 @@ The scheduled lifecycle runs at 00:01 in the user's local timezone. It should: when no material progress is possible under the user's control. Track those items under a separate `Blockers` section instead. A blocked item may appear in `Focus` only if there is a genuine actionable step the user can take - today beyond merely waiting or monitoring. + today beyond merely waiting or monitoring. Do not mention blocked paths in a + focus item as contrast, caveat, or "do not work on this" framing; the focus + line should name only the positive action to take, and the blocked path + belongs only in `Blockers`. Each numbered focus item must cover exactly one + distinct project or task. Do not bundle unrelated work into one focus line, + even when the second item is smaller, adjacent, or non-urgent. Put that + follow-through in the appropriate to-do section unless it genuinely deserves + its own focus slot. 5. Keep the mechanically carried personal/professional tasks and open questions; correct duplicates or categorisation errors if necessary. 6. Confirm scheduled and recurring instances are relevant for the date. diff --git a/framework/automation-definitions/dtm-daily-rollover.json b/framework/automation-definitions/dtm-daily-rollover.json index e1c6ce9..6ecbe97 100644 --- a/framework/automation-definitions/dtm-daily-rollover.json +++ b/framework/automation-definitions/dtm-daily-rollover.json @@ -4,5 +4,5 @@ "schedule": "00:01 daily in the user's local timezone", "execution_environment": "local", "workspace": "{{VAULT_PATH}}", - "prompt": "Read AGENTS.md and DTM.md, run python3 tools/dtm.py rollover, finalise the previous note from recorded evidence, populate today's Previous Day and actionable-only Focus, keep genuine blockers in a separate Blockers section, preserve valid carried tasks and questions, instantiate recurrence, link active projects, record DTM activity, capture only concise bullet-point durable decisions in the Decisions section, log only significant durable changes, and run DTM and wiki lint checks." + "prompt": "Read AGENTS.md and DTM.md, run python3 tools/dtm.py rollover, finalise the previous note from recorded evidence, populate today's Previous Day and actionable-only Focus, keep each Focus item limited to one distinct project or task, keep Focus limited to positive actionable priorities without mentioning blocked paths as caveats or contrast clauses, keep genuine blockers in a separate Blockers section, preserve valid carried tasks and questions, instantiate recurrence, link active projects, record DTM activity, capture only concise bullet-point durable decisions in the Decisions section, log only significant durable changes, and run DTM and wiki lint checks." } diff --git a/framework/automation-definitions/knowledge-agent-raw-inbox.json b/framework/automation-definitions/knowledge-agent-raw-inbox.json index b69ad98..efa667c 100644 --- a/framework/automation-definitions/knowledge-agent-raw-inbox.json +++ b/framework/automation-definitions/knowledge-agent-raw-inbox.json @@ -4,5 +4,5 @@ "schedule": "02:00 daily in the user's local timezone", "execution_environment": "local", "workspace": "{{VAULT_PATH}}", - "prompt": "Read AGENTS.md and inspect only top-level pending files in raw/. Process each independently through the full ingestion workflow. Archive a source unchanged under raw/processed/ only after integration, index, log and lint all succeed. Leave blocked or failed sources visible and report them." + "prompt": "Read AGENTS.md and inspect only top-level pending files in raw/, ignoring metadata files such as .DS_Store. Process each independently through the full ingestion workflow. Archive a source unchanged under raw/processed/ only after integration, index, log and lint all succeed. Leave blocked or failed sources visible and report them." } diff --git a/framework/obsidian/app.json b/framework/obsidian/app.json index 1b330c3..1d5db9d 100644 --- a/framework/obsidian/app.json +++ b/framework/obsidian/app.json @@ -4,5 +4,6 @@ "newFileLocation": "folder", "newFileFolderPath": "raw", "attachmentFolderPath": "raw/assets", - "vimMode": true + "vimMode": true, + "openBehavior": "file:start-here" } \ No newline at end of file diff --git a/framework/skills/thinking-interview/SKILL.md b/framework/skills/thinking-interview/SKILL.md new file mode 100644 index 0000000..eeabdab --- /dev/null +++ b/framework/skills/thinking-interview/SKILL.md @@ -0,0 +1,90 @@ +--- +name: thinking-interview +description: Conduct a structured Socratic interview that helps the user develop original thinking on a complex topic. Use only when the user invokes `$thinking-interview` or explicitly asks for a one-question-at-a-time interview to elicit, refine, and strengthen their own reasoning rather than generate ideas for them. +--- + +# Thinking interview + +Use this skill to help the user think their way to a stronger position in their +own voice. The goal is not to supply the opinion, but to draw it out, test it, +and structure it. + +## Core behavior + +- Ask exactly one question at a time. +- Start broad before narrowing. +- Prefer open questions that reveal the user's current model, priorities, + assumptions, and framing. +- Use follow-up questions when reasoning is unclear, unsupported, incomplete, + internally inconsistent, or points to an important implication. +- Challenge selectively. Improve the quality of the thinking, but do not turn + the exchange into endless adversarial debate. +- Once a line of reasoning is sufficiently developed, either accept it and move + on or shift to another perspective. +- Track the user's emerging viewpoint across the interview so later questions + build on what has already been established. +- Do not answer your own questions unless the user explicitly asks you to step + out of interview mode. + +## Interview flow + +1. Establish the topic and any extra constraints or areas of emphasis if the + user has not already provided them. +2. Begin with a broad question that surfaces the user's overall perspective or + instinctive position. +3. Move into targeted follow-ups that clarify: + - definitions and scope; + - assumptions and implied tradeoffs; + - evidence, examples, and experience; + - consequences, risks, and second-order effects; + - points of tension, contradiction, or uncertainty; + - links between separate ideas that have emerged during the discussion. +4. Periodically maintain a working mental model of the user's position. Do this + mostly internally; only give a short recap when it helps the discussion or + the user asks for one. +5. Continue until there is enough material to reconstruct the user's opinion + confidently in their own voice, or until the user asks to stop and + synthesize. + +## Question design + +- Early questions should be broad, exploratory, and non-leading. +- Mid-stage questions should probe reasoning quality and reveal structure. +- Later questions should test coherence, prioritization, and implications. +- If the user gives a strong but compressed answer, unpack it rather than + moving on too quickly. +- If the user has already established a point well, avoid reopening it without + a clear reason. +- If several follow-ups are possible, choose the one most likely to improve the + user's reasoning rather than the one most likely to create argument. + +## Tone + +- Be calm, rigorous, and collaborative. +- Treat the user as the thinker and author. +- Avoid performative skepticism and avoid filling space with your own theories. +- If the user seems stuck, ask narrower or more concrete questions rather than + switching into idea generation. + +## Completion + +When the interview has reached a good stopping point, provide: + +1. A concise summary of the user's position. +2. The key arguments and supporting reasoning that emerged. +3. Unresolved questions, uncertainties, or areas that would benefit from + further thought. +4. A proposed outline for an opinion piece, work note, whitepaper, or blog post + based on the discussion. +5. Any novel insights or recurring themes that emerged during the interview but + were not initially stated so explicitly. + +Do not produce this synthesis prematurely. Until the interview is complete, +keep asking one question at a time. + +## DTM compatibility + +If this skill is invoked inside an active `$dtm` thread, remain within the DTM +workflow. Treat the interview as DTM work, capture substantive outcomes in the +Daily Note before closing out, and create or update durable notes only if the +user asks for that next step. diff --git a/framework/skills/thinking-interview/agents/openai.yaml b/framework/skills/thinking-interview/agents/openai.yaml new file mode 100644 index 0000000..b7db596 --- /dev/null +++ b/framework/skills/thinking-interview/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Thinking Interview" + short_description: "One-question Socratic interview for original thinking" + default_prompt: "Use $thinking-interview to help me develop my own position on a complex topic through a one-question-at-a-time Socratic interview." +policy: + allow_implicit_invocation: false diff --git a/framework/templates/daily-note.md b/framework/templates/daily-note.md index 5d1e63b..62ce0ad 100644 --- a/framework/templates/daily-note.md +++ b/framework/templates/daily-note.md @@ -16,6 +16,8 @@ tags: ## Focus + + ## Blockers ## Personal To-Do diff --git a/framework/templates/working-note.md b/framework/templates/working-note.md index 121b6ce..45b9c19 100644 --- a/framework/templates/working-note.md +++ b/framework/templates/working-note.md @@ -22,4 +22,4 @@ Working material, drafts, analysis, or interim outputs. ## Next use -How this note is expected to be used next, integrated, or retired to reference status. +How this note is expected to be used next, integrated, or retired to reference status once it is no longer an active working asset. diff --git a/framework/tools/documents.py b/framework/tools/documents.py index e01c57c..481bcef 100644 --- a/framework/tools/documents.py +++ b/framework/tools/documents.py @@ -12,6 +12,7 @@ ROOT = Path(__file__).resolve().parent.parent DOCUMENTS = ROOT / "documents" DELIVERABLES = DOCUMENTS / "deliverables" +REFERENCE = DOCUMENTS / "reference" STAGES = { "drafts": "draft", "final": "final", @@ -87,6 +88,11 @@ def lint() -> int: for path in DELIVERABLES.rglob("*") if path.is_file() and path.name.lower() != "readme.md" and not path.name.startswith(".") ) + reference_files = sorted( + path + for path in REFERENCE.rglob("*") + if path.is_file() and path.name.lower() != "readme.md" and not path.name.startswith(".") + ) index_path = DOCUMENTS / "index.md" if index_path.exists(): index_text = index_path.read_text(encoding="utf-8") @@ -94,9 +100,9 @@ def lint() -> int: else: index_text = "" index_links = [] - if all_pieces or deliverable_files: + if all_pieces or deliverable_files or reference_files: errors.append( - "documents/index.md: missing curated index; create documents/index.md before managing documents or deliverables" + "documents/index.md: missing curated index; create documents/index.md before managing documents, references, or deliverables" ) for name, count in names.items(): @@ -147,6 +153,14 @@ def lint() -> int: elif count > 1: errors.append(f"{relative(path)}: listed {count} times in documents/index.md") + if REFERENCE.exists(): + for path in reference_files: + count = index_links.count(relative(path)) + if count == 0: + errors.append(f"{relative(path)}: missing from documents/index.md") + elif count > 1: + errors.append(f"{relative(path)}: listed {count} times in documents/index.md") + for item in sorted(set(errors)): print(f"ERROR {item}") print(f"Checked {len(all_pieces)} document(s): {len(set(errors))} error(s), 0 warning(s).") @@ -157,6 +171,14 @@ def status() -> int: counts = Counter(status for _, status in pieces()) print(f"Drafts: {counts['draft']}") print(f"Final: {counts['final']}") + reference_count = sum( + 1 + for path in REFERENCE.rglob("*") + if path.is_file() + and path.name.lower() != "readme.md" + and not path.name.startswith(".") + ) + print(f"Reference: {reference_count}") deliverable_count = sum( 1 for path in DELIVERABLES.rglob("*") diff --git a/framework/tools/dtm.py b/framework/tools/dtm.py index 6729efe..b66cff1 100644 --- a/framework/tools/dtm.py +++ b/framework/tools/dtm.py @@ -36,6 +36,12 @@ QUESTION_RE = re.compile(r"^- (?!\[[ xX]\] )(?!" + "" if previous_text - else "1. Establish today's priorities and active workstreams." + else "1. Establish today's highest-priority distinct task or project." ) previous_summary = ( f"Previous note: {previous_link}\n\n" @@ -283,7 +298,7 @@ def render_note(day: date, previous_text: str | None) -> str: ## Blockers - + ## Personal To-Do @@ -533,6 +548,11 @@ def lint() -> int: for field in ("type: daily-note", f"date: {path.stem}", "status:"): if field not in text[: text.find("\n---\n", 4) + 5]: errors.append(f"{path.relative_to(ROOT)}: missing frontmatter value {field}") + for item in focus_items(text): + if looks_like_bundled_focus_item(item): + errors.append( + f"{path.relative_to(ROOT)}: bundled Focus item should be split into distinct tasks: {item!r}" + ) for item in sorted(set(errors)): print(f"ERROR {item}") diff --git a/framework/tools/publish_framework.py b/framework/tools/publish_framework.py index 9c22816..4c8f54f 100644 --- a/framework/tools/publish_framework.py +++ b/framework/tools/publish_framework.py @@ -143,6 +143,8 @@ def publish(target: Path, repo: str, assignee: str, base: str) -> int: print("No meaningful framework changes; no branch, commit, or PR created.") return 0 + validate_export(staging) + pending = open_publication_pr(repo) if pending: print(f"Publication review already open; deferring a new branch: {pending}") @@ -165,7 +167,6 @@ def publish(target: Path, repo: str, assignee: str, base: str) -> int: git(target, "switch", "-c", branch) replace_worktree(target, staging) - validate_export(target) git(target, "add", "-A") if git(target, "diff", "--cached", "--quiet", check=False).returncode == 0: print("No meaningful staged changes; no commit or PR created.") diff --git a/framework/tools/wiki.py b/framework/tools/wiki.py index 3f3c992..ca0bf63 100644 --- a/framework/tools/wiki.py +++ b/framework/tools/wiki.py @@ -34,6 +34,7 @@ } QUEUE_PATH = ROOT / "work" / "wiki-open-questions.md" QUESTION_THREAD_RE = re.compile(r"") +RAW_PENDING_IGNORED_NAMES = {".DS_Store", ".gitkeep", "README.md"} def pages() -> list[Path]: @@ -67,6 +68,22 @@ def read_system_log() -> str | None: return SYSTEM_LOG.read_text(encoding="utf-8") +def parse_iso_date(value: object) -> date | None: + if not isinstance(value, str): + return None + try: + return date.fromisoformat(value.strip()) + except ValueError: + return None + + +def pending_raw_sources() -> list[Path]: + return sorted( + path for path in RAW.iterdir() + if path.is_file() and path.name not in RAW_PENDING_IGNORED_NAMES + ) + + def frontmatter(text: str) -> dict[str, object] | None: if not text.startswith("---\n"): return None @@ -132,6 +149,9 @@ def question_id(path: Path, question: str) -> str: def extract_questions(path: Path) -> list[dict[str, str]]: text = path.read_text(encoding="utf-8") + meta = frontmatter(text) or {} + if meta.get("type") == "project" and meta.get("status") == "completed": + return [] title = page_title(path, text) items: list[dict[str, str]] = [] lines = text.splitlines() @@ -330,6 +350,8 @@ def lint() -> int: ) titles: list[str] = [] inbound: Counter[str] = Counter() + wiki_updated_dates: set[date] = set() + source_ingests: list[tuple[str, str, date]] = [] for path in contents: text = path.read_text(encoding="utf-8") @@ -348,6 +370,11 @@ def lint() -> int: errors.append(f"{relative(path)}: invalid status {meta.get('status')!r}") if meta.get("title"): titles.append(str(meta["title"]).casefold()) + updated_at = parse_iso_date(meta.get("updated")) + if updated_at is None: + errors.append(f"{relative(path)}: invalid updated date {meta.get('updated')!r}") + else: + wiki_updated_dates.add(updated_at) if meta.get("type") == "source": for field in ("source_path", "source_kind", "ingested"): if not meta.get(field): @@ -357,6 +384,11 @@ def lint() -> int: errors.append( f"{relative(path)}: source_path does not exist: {source_path}" ) + ingested_at = parse_iso_date(meta.get("ingested")) + if ingested_at is None: + errors.append(f"{relative(path)}: invalid ingested date {meta.get('ingested')!r}") + elif isinstance(meta.get("title"), str) and meta["title"].strip(): + source_ingests.append((relative(path), meta["title"].strip(), ingested_at)) for target in LINK_RE.findall(text): normalized = normalize_target(target) @@ -391,6 +423,52 @@ def lint() -> int: errors.append("log.md: missing root system log; create log.md with at least one parseable entry") elif not LOG_RE.search(log_text): errors.append("log.md: no parseable log entries") + else: + log_entries = [ + (date.fromisoformat(entry_date), operation, title.strip()) + for entry_date, operation, title in LOG_RE.findall(log_text) + ] + log_entry_blocks: list[tuple[date, str, str, str]] = [] + matches = list(LOG_RE.finditer(log_text)) + for index, match in enumerate(matches): + body_start = match.end() + body_end = matches[index + 1].start() if index + 1 < len(matches) else len(log_text) + entry_date, operation, title = match.groups() + log_entry_blocks.append( + ( + date.fromisoformat(entry_date), + operation, + title.strip(), + log_text[body_start:body_end], + ) + ) + log_dates = {entry_date for entry_date, _, _ in log_entries} + logged_source_entries = { + (entry_date, title.casefold()) + for entry_date, operation, title in log_entries + if operation in {"ingest", "dtm", "query", "lint"} + } + for updated_at in sorted(wiki_updated_dates): + if updated_at not in log_dates: + errors.append( + "log.md: missing log entry for wiki content updated on " + f"{updated_at.isoformat()}" + ) + for source_path, source_title, ingested_at in source_ingests: + source_key = source_path[:-3] + has_matching_entry = (ingested_at, source_title.casefold()) in logged_source_entries + if not has_matching_entry: + has_matching_entry = any( + entry_date == ingested_at + and operation in {"ingest", "dtm", "query", "lint"} + and f"[[{source_key}" in body + for entry_date, operation, _, body in log_entry_blocks + ) + if not has_matching_entry: + errors.append( + f"log.md: missing source-specific entry for {source_path} " + f"on {ingested_at.isoformat()} titled {source_title!r}" + ) errors = sorted(set(errors)) warnings = sorted(set(warnings)) @@ -412,10 +490,7 @@ def status() -> int: meta = frontmatter(path.read_text(encoding="utf-8")) or {} counts[str(meta.get("type", "unknown"))] += 1 states[str(meta.get("status", "unknown"))] += 1 - pending = sorted( - path for path in RAW.iterdir() - if path.is_file() and path.name not in {".gitkeep", "README.md"} - ) + pending = pending_raw_sources() processed = sum( 1 for path in (RAW / "processed").rglob("*") if path.is_file() and path.name not in {".gitkeep", "README.md"} @@ -429,10 +504,7 @@ def status() -> int: def pending() -> int: - sources = sorted( - path for path in RAW.iterdir() - if path.is_file() and path.name not in {".gitkeep", "README.md"} - ) + sources = pending_raw_sources() if not sources: print("No pending raw sources.") return 0 diff --git a/framework/tools/writing.py b/framework/tools/writing.py index 56cf16e..04958c4 100644 --- a/framework/tools/writing.py +++ b/framework/tools/writing.py @@ -16,6 +16,7 @@ "drafts": "draft", "ready": "ready", "published": "published", + "archive": "archived", } REQUIRED = { "title", @@ -204,6 +205,15 @@ def lint() -> int: errors.append(f"{label}: published piece needs canonical_url") if not value(meta, "published_at"): errors.append(f"{label}: published piece needs published_at") + if expected_status == "archived": + if not value(meta, "canonical_url"): + errors.append(f"{label}: archived piece needs former canonical_url") + if not value(meta, "published_at"): + errors.append(f"{label}: archived piece needs former published_at") + if not DATE_RE.fullmatch(value(meta, "archived_at")): + errors.append(f"{label}: archived_at must be YYYY-MM-DD") + if not value(meta, "archive_reason"): + errors.append(f"{label}: archived piece needs archive_reason") start_count = text.count("") end_count = text.count("")