diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 609baa0..30f2bff 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -24,5 +24,9 @@ jobs: run: | vault="$(mktemp -d)" python3 install.py --vault "$vault" + test -f "$vault/skills/dtm/SKILL.md" + test -f "$vault/skills/voice/SKILL.md" python3 "$vault/tools/wiki.py" lint python3 "$vault/tools/dtm.py" lint + python3 "$vault/tools/documents.py" lint + python3 "$vault/tools/writing.py" lint diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db51d7e..21cbef5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,8 @@ tmpdir="$(mktemp -d)" python3 install.py --vault "$tmpdir" python3 "$tmpdir/tools/wiki.py" lint python3 "$tmpdir/tools/dtm.py" lint +python3 "$tmpdir/tools/documents.py" lint +python3 "$tmpdir/tools/writing.py" lint ``` Pull requests should explain the motivation, user impact, privacy implications, diff --git a/README.md b/README.md index a13d4c5..b95802a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ deliberately separate LLM roles: - the **LLM Knowledge Agent**, which compiles curated sources into a persistent, cited wiki; and - the **Digital TeamMate (DTM)**, which maintains day-to-day continuity across - notes, tasks, projects, decisions, and follow-ups. + notes, tasks, projects, decisions, follow-ups, and authored work products. This repository contains only the reusable framework. It contains no personal vault data, source material, Daily Notes, project content, or activity history. diff --git a/docs/installation.md b/docs/installation.md index fe53cc5..0b662e8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -29,9 +29,31 @@ framework files with `--force` after reviewing the changes. `{{PLACEHOLDER}}`; never commit the resulting machine-specific values. 5. Open the directory as an Obsidian vault. +## Install the SecondBrain skills + +The vault installer keeps canonical skills under `skills/`. To make `$dtm` and +`$voice` discoverable by Codex, copy them into the user's Codex skills +directory: + +```sh +mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills" +cp -R skills/dtm "${CODEX_HOME:-$HOME/.codex}/skills/" +cp -R skills/voice "${CODEX_HOME:-$HOME/.codex}/skills/" +``` + +Start a thread in the installed vault and invoke `$dtm`. That thread remains in +DTM mode until `$end-dtm` or an explicit permanent switch to the Knowledge +Agent. + +Invoke `$voice` after representative writing has reached `ready/` or +`published/`, or after representative internal documents have reached +`documents/final/`. It updates the private voice pack without analysing drafts. + ## Validate ```sh python3 tools/wiki.py lint python3 tools/dtm.py lint +python3 tools/documents.py lint +python3 tools/writing.py lint ``` diff --git a/docs/operations.md b/docs/operations.md index 8d58cd0..df5ee09 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -14,12 +14,38 @@ questions forward, instantiates enabled recurrence, captures decisions, and maintains project continuity. Durable knowledge can graduate to the wiki while remaining clearly distinguished from externally sourced evidence. +## Internal documents + +Use `documents/drafts/` for collaborative internal drafting and +`documents/final/` for the approved internal record of strategies, +architectural notes, and similar work products. Use +`documents/deliverables//` for shareable `.pptx`, `.docx`, +`.xlsx`, and similar artefacts linked to those documents. If a document also +serves as a wiki source, keep the raw-source copy under `raw/` or +`raw/processed/` as immutable provenance and manage the authored copy +separately under `documents/`. + ## Maintenance Run both structural linters after framework changes. Periodically perform a semantic wiki review for contradictions, stale claims, unsupported assertions, orphans, and evidence gaps. +## Collaborative writing + +Writing moves from agent-assisted `drafts/` to the human-approved `ready/` +queue and then to `published/` only after confirmed publication. Publishing +integrations consume only content between explicit publish markers; private +briefs, source checks, and revision notes remain in the vault. The framework +includes a parameterized publisher definition but enables no destination by +default. + +The explicit `$voice` skill learns only from reader-facing prose in +`writing/ready/`, `writing/published/`, and `documents/final/`, maintains a +private voice pack, and excludes drafts to avoid training the style guide on +unfinished or agent-generated work. Drafting agents read the pack when present, +while the current brief always takes precedence. + ## Automation Definitions under `framework/automation-definitions/` are parameterized diff --git a/docs/publication.md b/docs/publication.md index 3055d6e..ad8eec9 100644 --- a/docs/publication.md +++ b/docs/publication.md @@ -20,6 +20,11 @@ the same export reuses the branch or existing pull request. If an earlier framework publication PR is still open, the run defers rather than creating a stack of noisy or competing reviews. +Interactive agent sessions never publish directly. They may update and validate +the live canonical framework, but must leave the external Git checkout and +GitHub untouched. The scheduled weekly automation is the sole authorised +promotion path. + ## Review and promotion Review the generated PR with special attention to the privacy boundary, diff --git a/examples/empty-vault/README.md b/examples/empty-vault/README.md index bc2a069..d03a18b 100644 --- a/examples/empty-vault/README.md +++ b/examples/empty-vault/README.md @@ -1,4 +1,6 @@ # Empty SecondBrain vault example This directory contains sanitized seed scaffolding only. It contains no real -Daily Notes, sources, projects, wiki knowledge, or activity history. +Daily Notes, sources, projects, wiki knowledge, or activity history beyond a +minimal root log setup entry required for structural lint to pass on a fresh +install. diff --git a/examples/empty-vault/documents/README.md b/examples/empty-vault/documents/README.md new file mode 100644 index 0000000..15d6229 --- /dev/null +++ b/examples/empty-vault/documents/README.md @@ -0,0 +1,4 @@ +# Internal documents workspace + +Managed internal documents live here. This public example contains no document +content. diff --git a/examples/empty-vault/documents/deliverables/README.md b/examples/empty-vault/documents/deliverables/README.md new file mode 100644 index 0000000..c9ef8b2 --- /dev/null +++ b/examples/empty-vault/documents/deliverables/README.md @@ -0,0 +1,4 @@ +# Document deliverables + +Shareable internal document artefacts would live here in a real vault. This +public example contains no deliverables. diff --git a/examples/empty-vault/documents/drafts/.gitkeep b/examples/empty-vault/documents/drafts/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/empty-vault/documents/drafts/.gitkeep @@ -0,0 +1 @@ + diff --git a/examples/empty-vault/documents/final/.gitkeep b/examples/empty-vault/documents/final/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/empty-vault/documents/final/.gitkeep @@ -0,0 +1 @@ + diff --git a/examples/empty-vault/documents/index.md b/examples/empty-vault/documents/index.md new file mode 100644 index 0000000..d851fa2 --- /dev/null +++ b/examples/empty-vault/documents/index.md @@ -0,0 +1,17 @@ +# Documents Index + +Curated catalogue of the collaborative internal-documents workspace. Add each +managed draft, final document, and deliverable exactly once under its current +lifecycle stage. + +## Drafts + + + +## Final + + + +## Deliverables + + diff --git a/examples/empty-vault/log.md b/examples/empty-vault/log.md new file mode 100644 index 0000000..e6d347e --- /dev/null +++ b/examples/empty-vault/log.md @@ -0,0 +1,8 @@ +# SecondBrain Log + +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 + +Initialized the sanitized empty-vault example with no personal content, source material, Daily Notes, project state, or activity history. diff --git a/examples/empty-vault/wiki/log.md b/examples/empty-vault/wiki/log.md deleted file mode 100644 index 26ef13b..0000000 --- a/examples/empty-vault/wiki/log.md +++ /dev/null @@ -1,8 +0,0 @@ -# Wiki Log - -Append-only system operation history. This public seed contains no personal -activity. - -## [1970-01-01] setup | Empty framework initialized - -Created sanitized framework scaffolding with no personal content. diff --git a/examples/empty-vault/wiki/overview.md b/examples/empty-vault/wiki/overview.md index b526c19..dda5d81 100644 --- a/examples/empty-vault/wiki/overview.md +++ b/examples/empty-vault/wiki/overview.md @@ -14,4 +14,4 @@ tags: This is a deliberately empty, sanitized wiki seed. Add a source to `raw/` and ask the Knowledge Agent to ingest it. -Browse [[wiki/index|Wiki Index]] or [[wiki/log|Wiki Log]]. +Browse [[wiki/index|Wiki Index]] or [[log|System Log]]. diff --git a/examples/empty-vault/writing/README.md b/examples/empty-vault/writing/README.md new file mode 100644 index 0000000..aba99fe --- /dev/null +++ b/examples/empty-vault/writing/README.md @@ -0,0 +1,6 @@ +# Writing workspace + +Collaborative drafts live in `drafts/`, explicitly approved publication items +in `ready/`, and confirmed publication records in `published/`. This sanitized +example contains no writing. + diff --git a/examples/empty-vault/writing/drafts/.gitkeep b/examples/empty-vault/writing/drafts/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/empty-vault/writing/drafts/.gitkeep @@ -0,0 +1 @@ + diff --git a/examples/empty-vault/writing/index.md b/examples/empty-vault/writing/index.md new file mode 100644 index 0000000..aa60392 --- /dev/null +++ b/examples/empty-vault/writing/index.md @@ -0,0 +1,16 @@ +# Writing Index + +Every writing piece appears exactly once under its current lifecycle stage. + +## Drafts + +_No drafts yet._ + +## Ready for publishing + +_No pieces ready for publishing._ + +## Published + +_No published pieces yet._ + diff --git a/examples/empty-vault/writing/log.md b/examples/empty-vault/writing/log.md new file mode 100644 index 0000000..821eaf1 --- /dev/null +++ b/examples/empty-vault/writing/log.md @@ -0,0 +1,9 @@ +# Writing Log + +Append-only writing lifecycle history. This public seed contains no personal +writing activity. + +## [1970-01-01] draft | Empty writing workspace initialized + +Created sanitized writing lifecycle scaffolding with no writing content. + diff --git a/examples/empty-vault/writing/published/.gitkeep b/examples/empty-vault/writing/published/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/empty-vault/writing/published/.gitkeep @@ -0,0 +1 @@ + diff --git a/examples/empty-vault/writing/ready/.gitkeep b/examples/empty-vault/writing/ready/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/empty-vault/writing/ready/.gitkeep @@ -0,0 +1 @@ + diff --git a/examples/empty-vault/writing/voice/voice-pack.md b/examples/empty-vault/writing/voice/voice-pack.md new file mode 100644 index 0000000..a4d990b --- /dev/null +++ b/examples/empty-vault/writing/voice/voice-pack.md @@ -0,0 +1,52 @@ +--- +title: Writing Voice Pack +type: voice-pack +status: provisional +created: 1970-01-01 +updated: 1970-01-01 +ready_sources: 0 +published_sources: 0 +document_sources: 0 +tags: + - writing + - voice +--- + +# Writing Voice Pack + +## How to use this pack + +Apply observed patterns as flexible guidance. The current brief and direct user +instructions take precedence. + +## Confidence and coverage + +This sanitized seed contains no voice evidence. + +## Declared preferences + + + +## Observed voice + +Insufficient evidence. + +## Avoid + +Insufficient evidence. + +## Context variations + +Insufficient evidence. + +## Drafting checklist + +- Follow the current brief before inferred tendencies. + +## Evidence ledger + +_No eligible sources analysed._ + +## Change notes + +- 1970-01-01 — Created sanitized empty pack. diff --git a/framework/AGENTS.md b/framework/AGENTS.md index b222b56..712e4ff 100644 --- a/framework/AGENTS.md +++ b/framework/AGENTS.md @@ -27,6 +27,21 @@ specific raw file explicitly named by the user, and only for that assigned task. The Knowledge Agent must never add, edit, reorder, or remove content in a Daily Note's `Notes & Activity` section. +### Thread-scoped DTM sessions + +- `$dtm` is an explicit role transition for the current conversation thread. + On invocation, read `skills/dtm/SKILL.md` and initialise the session exactly as + specified there. +- Once activated, keep every subsequent turn in that thread under the DTM role + without requiring `$dtm` again. Preserve this active-session fact when + summarising or compacting thread context. +- End persistent DTM mode only when the user says `$end-dtm`, asks to end the + DTM session, or explicitly switches the thread to the Knowledge Agent. +- A Knowledge Agent request explicitly scoped to one task is a bounded + delegation; resume the thread's DTM mode after it completes. +- Session mode changes routing, not authority. All raw-source, scratchpad, + publication, and ownership boundaries remain in force. + ## Knowledge Agent mission Compile curated source material into a durable, navigable wiki. Integrate new @@ -77,6 +92,79 @@ compound across sessions. same change. - Never present inference as sourced fact. +### `documents/` — collaborative internal documents workspace + +- The DTM owns collaborative drafting, revision support, and lifecycle + 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 + 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 + revise drafts during assigned document work. +- `documents/final/` contains the canonical internal record of finished + documents approved by the user for internal use. Keep stable lowercase + 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/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 + `documents/deliverables/data-strategy-for-social-impact/`. +- When a Markdown document needs a managed Word copy, prefer + `python3 tools/document_deliverables.py docx ` and visually QA + the output by rendering page images before treating the deliverable as ready + to distribute. +- These documents are not a source inbox and not a publication queue. If a + document is also used as wiki evidence, retain the raw-source copy under + `raw/` or `raw/processed/` as immutable provenance and manage the authored + working copy separately under `documents/`. +- `writing/voice/voice-pack.md` is also the private style model for internal + document drafting. Voice analysis may read only reader-facing prose in + `writing/ready/`, `writing/published/`, and `documents/final/`; it must never + inspect document drafts or use chats, Daily Notes, wiki pages, sources, + projects, or scratch material as style evidence. +- Before creating or materially rewriting a document draft, read the voice pack + when it exists and apply its guidance without caricaturing the user. +- Run `python3 tools/documents.py lint` after structural or status changes. + +### `writing/` — collaborative writing workspace + +- The DTM owns collaborative drafting, revision support, editorial continuity, + and writing-workflow bookkeeping. The human remains the author and final + editorial authority. +- `writing/index.md` is the curated catalogue of the writing workspace. Keep + every draft, ready piece, and published piece listed exactly once under its + current stage with a one-line description concrete enough to avoid loading + every piece just to understand what already exists. +- `writing/drafts/` contains active work. Agents may create and revise drafts + during assigned writing work. +- `writing/ready/` is a human-controlled publication queue. Move a draft there + only when the user explicitly approves that piece as ready for publishing. + Agent confidence, completion, or a request to “improve” a draft is not + approval to promote it. +- `writing/published/` contains the canonical record of successfully published + pieces. Move a ready item there only after the publishing destination confirms + 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. +- 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. +- Publish only the content between `` and + ``. Editorial briefs, source notes, and revision history + remain private working metadata unless the user explicitly includes them. +- `writing/voice/voice-pack.md` is a private style model maintained by the + `$voice` skill. Voice analysis may read only reader-facing prose in + `writing/ready/`, `writing/published/`, and `documents/final/`; it must never + inspect drafts or use chats, Daily Notes, wiki pages, sources, projects, or + scratch material as style evidence. +- Before creating or materially rewriting a draft, read the voice pack when it + exists and apply its guidance without caricaturing the user. Preserve the + user's current brief and explicit instructions over inferred style rules. +- Run `python3 tools/writing.py lint` after structural or status changes. + ### `AGENTS.md` — shared schema Change this contract only when the user asks, or when a task exposes a durable @@ -98,9 +186,16 @@ workflow improvement. Explain material schema changes before applying them. - All Git operations for publication must use the separate external framework checkout and an explicit target path. The publication tool rejects targets inside iCloud or inside the live vault. +- Interactive agents must not create framework branches, commits, pushes, or + pull requests, even after making publishable framework changes. Their job ends + after updating and validating the live canonical framework. +- The scheduled weekly publication automation is the sole authorised mechanism + for extracting changes, operating the external Git checkout, pushing a branch, + and opening a pull request. Do not invoke that workflow early or manually. - Never publish `scratch.md`, Daily Notes, wiki content, sources, personal - projects, working documents, activity logs, Obsidian workspaces, or any file - not explicitly approved by `publication/manifest.json`. + projects, writing content, document content, working documents, activity logs, Obsidian + workspaces, or any file not explicitly approved by + `publication/manifest.json`. - When system architecture, templates, tools, Obsidian configuration, or automations change, update their sanitized framework representation in the same task where practical. @@ -109,7 +204,8 @@ workflow improvement. Explain material schema changes before applying them. - `wiki/overview.md` — current high-level synthesis and navigation. - `wiki/index.md` — complete content catalogue with one-line descriptions. -- `wiki/log.md` — append-only chronological activity record. +- `log.md` — append-only chronological activity record for the whole + SecondBrain system. - `wiki/sources/` — one provenance and summary page per ingested source. - `wiki/entities/` — people, organisations, products, places, works, and other named things. @@ -154,6 +250,10 @@ conflict. `superseded` pages must link prominently to their replacement. ## Writing and linking - Start each content page with a concise synthesis, not a table of metadata. +- In Markdown prose outside reader-facing pieces in `writing/`, keep each + paragraph as a single physical line and let Obsidian wrap visually. Start a + new line only for real structural elements such as headings, list items, + block quotes, tables, code fences, or deliberate line-sensitive formatting. - Use Obsidian links rooted at the vault, with useful display text: `[[wiki/concepts/example|Example]]`. - Link the first meaningful mention of another wiki page. Avoid link spam. @@ -184,11 +284,28 @@ source. If sources disagree, preserve both claims and explain the conflict. Never cite an analysis page as though it were primary evidence. It may be linked for context, but its underlying source citations remain authoritative. +### Opinion sources + +- Self-authored first-person sources, especially files tagged `my-opinion`, are + authoritative evidence of the user's current views, priorities, mental + models, and decision heuristics. +- Do not mark a source `needs-review` merely because it is opinionated or + normative. If authorship, date, or scope are clear, treat the opinion itself + as `current` unless a newer self-authored source supersedes it. +- Distinguish the opinion from any embedded external factual claim. The user's + stated belief is authoritative as a belief; factual examples, forecasts, + vendor claims, and general world-model assertions inside that belief may still + need corroboration when reused as fact. +- When opinion sources materially clarify how the user thinks, integrate them + into relevant entity, concept, topic, and overview pages so the wiki reflects + the user's intellectual operating system, not just curated external + knowledge. + ## Ingest workflow When asked to ingest one or more sources: -1. Read `wiki/index.md`, `wiki/overview.md`, recent entries in `wiki/log.md`, and +1. Read `wiki/index.md`, `wiki/overview.md`, recent entries in `log.md`, and the source. Search the wiki for related names and concepts before creating pages. 2. For image-bearing material, read the text first, then inspect locally @@ -199,6 +316,9 @@ When asked to ingest one or more sources: 4. Extract the source's central claims, evidence, entities, concepts, limitations, dates, and relationships. Identify what is genuinely new to the existing wiki. + Opinion sources also require extracting the user's stated principles, + preferences, trade-offs, and mental models, then integrating them wherever + they should influence future synthesis. 5. If emphasis or interpretation is consequential and genuinely ambiguous, discuss it with the user. Otherwise proceed and mark uncertainty explicitly. 6. Create or update the source page. Then integrate the evidence into every @@ -236,6 +356,28 @@ When asked a question: Short answers and one-off operational chat do not need to become pages. +## Wiki open-question workflow + +- `work/wiki-open-questions.md` is the DTM-managed operational queue for wiki + open questions. It is separate from the `Open Questions` section in Daily + Notes and applies only to unresolved questions still present in wiki pages. +- Questions are harvested from wiki-page `## Open questions` sections and + source-page `## Questions raised` sections. +- After any wiki task that creates, resolves, or materially changes those + question sections, refresh the queue with `python3 tools/wiki.py questions + --write`. +- The queue groups each live question under one of four status tokens: + `needs-triage`, `answer-myself`, `research-with-dtm`, or + `ready-to-integrate`. +- If the same underlying question appears in more than one wiki page, add the + same `` marker to each page-level + bullet so the queue folds them into one research thread with multiple source + links. The answer may still need integrating back into multiple wiki pages. +- Reclassifying a queue item does not by itself answer the question. A question + is only resolved when the underlying wiki page is updated, the question is + removed or rewritten there as appropriate, and the queue is refreshed so it + reflects the current wiki state. + ## Lint workflow For a health check, run the local lint first, then do a semantic review that @@ -262,9 +404,15 @@ and updated date. Organize it under Overview, Topics, Concepts, Entities, Analyses, and Sources. Omit empty categories only if the structure remains obvious. Keep descriptions concrete enough to route future queries. +`writing/index.md` and `documents/index.md` follow the same principle for their +workspaces. They are curated navigation, not directory listings. Every managed +item appears exactly once under its current lifecycle stage with a link, a +one-line description, and enough status context to understand what already +exists without opening every file. + ## Log contract -`wiki/log.md` is append-only. Never rewrite or reorder prior entries except to +`log.md` is append-only. Never rewrite or reorder prior entries except to repair a broken link or obvious typo. Put newest entries at the bottom, using: ```markdown diff --git a/framework/DTM.md b/framework/DTM.md index 8360f91..13f3afc 100644 --- a/framework/DTM.md +++ b/framework/DTM.md @@ -9,6 +9,36 @@ Maintain continuity of work across conversations and days. Prioritise practical usefulness, clear next actions, and reliable follow-through. Capture enough context to resume work without turning every interaction into archival prose. +## Thread-scoped sessions + +Invoking `$dtm` binds the current conversation thread to the DTM role. The +session persists across later turns without repeated invocation until the user +says `$end-dtm`, asks to end it, or explicitly switches the thread to the +Knowledge Agent. A one-off Knowledge Agent delegation does not end the session. + +At session start, ensure today's Daily Note exists, load its current operational +context, and record one timestamped activation entry under `Notes & Activity`. +Do not duplicate that entry if initialization is retried. + +Treat the DTM-invoked thread as the primary coordination thread for the day +unless the user explicitly redirects it. Long-running or self-contained +execution work may be split into separate threads to reduce context pressure, +but those threads are bounded delegations rather than replacements for the DTM +session. + +Within the active DTM thread, substantive work must be captured in today's +Daily Note during the same task before the agent gives a close-out response. Do +not defer note capture to a later reminder or rely on the user to ask for it. + +When work is delegated to another thread: + +- keep the original DTM thread as the canonical place for daily continuity and + prioritisation; +- record the delegated task's material outcome back in today's Daily Note as + soon as it is known; +- restate that the main thread remains in DTM mode after any compaction or + summary step before continuing with new work. + ## Boundaries - Never scan, monitor, triage, or batch-process `raw/`. @@ -23,6 +53,13 @@ context to resume work without turning every interaction into archival prose. personal decisions and observations to the user/context where useful. - The Knowledge Agent owns systematic source ingestion. The DTM owns Daily Notes and operational files. +- When the user marks a day or period as non-working, on leave, weekend-only, + or Monday personal-project or learning time, treat that as a hard boundary + for professional work unless the user explicitly overrides it for that period. + Do not add urgency caveats such as “unless something urgent happens” or + invent implicit fallback duties. Assume normal team coverage on working days + and next-working-day handling for out-of-hours issues unless the user states + a real on-call or escalation responsibility. ## DTM workspace @@ -30,6 +67,9 @@ context to resume work without turning every interaction into archival prose. - `projects/` — durable project plans, status, milestones, and next actions. - `work/` — working documents, drafts, scratch analyses, and deliverables that are not yet durable wiki knowledge. +- `documents/` — collaborative internal documents with draft and final stages. +- `writing/` — collaborative drafts, the human-approved publishing queue, and + canonical records of published writing. - `dtm/recurring-tasks.json` — recurrence definitions used at day open. - `templates/` — Obsidian templates for daily notes, projects, and wiki pages. @@ -37,6 +77,62 @@ Prefer links rooted at the vault, for example `[[projects/example-project|Example project]]`. A project page should hold stable context and current state; its day-specific activity belongs in the Daily Note. +For Daily Notes, project pages, work notes, wiki edits performed under DTM +authority, and internal documents, keep ordinary prose paragraphs on one +physical line and rely on Obsidian for visual wrapping. Use new lines only when +the Markdown structure itself changes, such as headings, list items, tables, +block quotes, or code fences. + +## Collaborative writing + +Use `templates/writing-piece.md` for substantive pieces. Help develop the +editorial brief, structure, initial prose, evidence, voice, and revisions while +preserving the user's authorship and judgment. Record material writing actions +in today's Daily Note and link created or updated pieces under `References`. + +Agents may work freely in `writing/drafts/`. Promotion to `writing/ready/` +requires explicit user approval for the named piece. A publishing automation +may process only `writing/ready/`, and may move a piece to `writing/published/` +only after confirmed success. Apply all detailed status and metadata rules in +`writing/README.md`. + +Before creating or materially rewriting reader-facing prose, read +`writing/voice/voice-pack.md` when present. Treat it as guidance rather than a +formula: the current brief and direct user instructions take precedence. Only +the `$voice` workflow may learn style from the approved corpus, and it must +exclude drafts completely. + +## Collaborative internal documents + +Use `templates/document-piece.md` for substantive internal documents such as +strategies, principles, operating models, and architecture notes. Record +material document actions in today's Daily Note and link created or updated +files under `References`. + +Agents may work freely in `documents/drafts/`. Move a document to +`documents/final/` only when the user has approved it as the finished internal +version. These documents are not a publication queue; they are the internal +canonical record for approved work. + +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 +`documents/final/` as the editable canonical source when one exists, and treat +the `.pptx`, `.docx`, or `.xlsx` file as the distributed artefact. + +When generating a managed `.docx` deliverable from a Markdown source, prefer +`python3 tools/document_deliverables.py docx `, then render the +result and visually inspect the page PNGs before treating it as ready to share. + +If a document also serves as a Knowledge-Agent source, keep the raw-source copy +in `raw/` or `raw/processed/` unchanged as provenance and maintain the authored +copy separately under `documents/`. + +Before creating or materially rewriting an internal document, read +`writing/voice/voice-pack.md` when present. The `$voice` workflow may learn +from `documents/final/` as part of the approved corpus, but must never inspect +`documents/drafts/`. + ## Interaction capture For substantive DTM work, use today's Daily Note. If none exists, run @@ -48,11 +144,21 @@ Add DTM interactions chronologically under `Notes & Activity` using: - HH:MM — DTM — concise action, result, or relevant context. ``` +Prefer `python3 tools/dtm.py activity "..."` for new activity entries so the +timestamp is taken from the live system clock at write time. Use `--time HH:MM` +only when backfilling from a genuine known timestamp. + Capture outcomes, created artefacts, material status changes, and commitments. Do not transcribe routine chat. The Knowledge Agent must never write in this section. -Significant DTM actions also receive an append-only `wiki/log.md` entry using +External operational work counts when it materially affects the user's day or +systems, including domains, hosting, publishing, platform administration, and +repository configuration. If substantive work in the active DTM thread has not +yet been captured, stop and update today's note before replying with task +completion. + +Significant DTM actions also receive an append-only `log.md` entry using the `dtm` operation label. Significant means a durable decision, project milestone, or created/updated wiki artefact—not ordinary task edits or rollover. @@ -62,6 +168,9 @@ milestone, or created/updated wiki artefact—not ordinary task edits or rollove - Use Obsidian task syntax: `- [ ] Action` and `- [x] Completed action`. - Make tasks concrete and outcome-oriented. Link the relevant project when one exists. +- In the current Daily Note, preserve visible completion by checking completed + items off instead of removing them. Do not silently delete completed tasks + from today's note unless the user explicitly asks for cleanup or removal. - At day open, carry incomplete tasks forward exactly once. Leave completed tasks in the historical note and never carry them forward. - Preserve useful completion context in the day's activity or project page. @@ -84,16 +193,21 @@ not generated historical instances, to change future behaviour. ## Decisions -Record meaningful decisions in today's `Decisions` section. Use one heading per -decision and capture the decision, context, rationale, and resulting actions: +Record only durable decisions with longer-term implications in today's +`Decisions` section. This section is for choices that materially change +strategy, tooling, architecture, policy, risk posture, or committed direction. +Routine execution choices already evident in `Notes & Activity` do not belong +here. Do not record ordinary retries, resumptions, sequencing choices, or +day-of operational calls such as deciding to continue a task from its last +checkpoint. -```markdown -### DEC-YYYY-MM-DD-NN — Short title +Capture each qualifying decision as a single bullet point only. Keep it short. +Do not restate the full context, rationale, or action list in this section; +that detail belongs in `Notes & Activity` and the relevant project or working +document. -- **Decision:** … -- **Context:** … -- **Rationale:** … -- **Actions:** … +```markdown +- Short statement of the decision made. ``` Update an affected project page immediately. If the decision has value beyond @@ -110,6 +224,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 + 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 The scheduled lifecycle runs at 00:01 in the user's local timezone. It should: @@ -125,7 +257,7 @@ The scheduled lifecycle runs at 00:01 in the user's local timezone. It should: correct duplicates or categorisation errors if necessary. 6. Confirm scheduled and recurring instances are relevant for the date. 7. Link relevant active projects and workstreams. -8. Add a DTM activity entry to today's note. Update `wiki/log.md` only if the +8. Add a DTM activity entry to today's note. Update `log.md` only if the rollover surfaced a significant durable change. When there is no prior note, say so plainly and initialise the day without @@ -136,4 +268,8 @@ fabricating a previous-day summary. DTM work is complete when today's note reflects the material action, tasks and questions have correct current state, affected project/working/wiki documents are linked under `References`, durable changes are logged where appropriate, -and `python3 tools/dtm.py lint` passes. +and `python3 tools/dtm.py lint` passes. In a DTM thread, a response that closes +out substantive work before the Daily Note is updated is incomplete. Writing +work must also leave +`python3 tools/writing.py lint` passing. Document-workflow changes must also +leave `python3 tools/documents.py lint` passing. diff --git a/framework/automation-definitions/dtm-daily-rollover.json b/framework/automation-definitions/dtm-daily-rollover.json index d9fc8ff..a57fb56 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 Focus, preserve valid carried tasks and questions, instantiate recurrence, link active projects, record DTM activity, 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 Focus, 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/weekly-diary-draft.json b/framework/automation-definitions/weekly-diary-draft.json new file mode 100644 index 0000000..e3af5ad --- /dev/null +++ b/framework/automation-definitions/weekly-diary-draft.json @@ -0,0 +1,8 @@ +{ + "name": "Weekly Diary Draft", + "role": "Writer", + "schedule": "01:30 every Monday in the user's local timezone", + "execution_environment": "local", + "workspace": "{{VAULT_PATH}}", + "prompt": "Read AGENTS.md, writing/README.md, and writing/voice/voice-pack.md. Determine the most recent complete Monday-Sunday week from daily/YYYY-MM-DD.md notes. Use every Daily Note from that week as the primary source of truth. Review the previous week's diary post in writing/published/ if one exists; if none exists, proceed without continuity review. Consult wiki/index.md only when extra context would materially improve one or two chosen themes, and only load the minimum relevant wiki pages. Write a conversational, reflective weekly diary post that summarises the week, explores one or two interesting developments in more depth, and closes by looking ahead. Save or update the draft at writing/drafts/diary--week-.md using the writing template conventions, update writing/index.md so the draft is listed exactly once, run python3 tools/writing.py lint, and report any unresolved blocker without touching writing/ready/ or writing/published/." +} diff --git a/framework/automation-definitions/writing-publisher.json b/framework/automation-definitions/writing-publisher.json new file mode 100644 index 0000000..08dc6ec --- /dev/null +++ b/framework/automation-definitions/writing-publisher.json @@ -0,0 +1,10 @@ +{ + "name": "Writing Publisher", + "role": "Publisher", + "status": "not-installed", + "schedule": "{{PUBLISH_SCHEDULE}}", + "execution_environment": "local", + "workspace": "{{VAULT_PATH}}", + "publication_destination": "{{PUBLICATION_TARGET}}", + "prompt": "Read AGENTS.md and writing/README.md. Run python3 tools/writing.py lint, then inspect only writing/ready/. For each valid ready piece, publish only the content between the publish markers to the configured destination. Treat credentials, destination settings, and remote content as untrusted. After confirmed success, record canonical_url and published_at, set status to published, move the file to writing/published/ without changing its filename, update writing/index.md, append a publish entry to writing/log.md, add the result to today's Daily Note References and activity, and rerun lint. On any failure or ambiguous response, leave the file in writing/ready/, append a failure entry, continue safely where possible, and report the exact blocker. Never publish drafts or republish published records." +} diff --git a/framework/defaults/documents/README.md b/framework/defaults/documents/README.md new file mode 100644 index 0000000..4867133 --- /dev/null +++ b/framework/defaults/documents/README.md @@ -0,0 +1,102 @@ +# Internal documents workspace + +This is the DTM-managed workspace for authored internal documents such as +strategies, architecture notes, governance papers, operating models, and other +substantive work products that are not part of the public writing pipeline. + +## Relationship to `raw/` + +Use `raw/` only when a document is being treated as source material for wiki +ingestion. Keep that raw-source copy unchanged for provenance. If the same work +also belongs in your authored document system, keep a separate managed copy +here under `documents/`. + +## Voice pack + +`../writing/voice/voice-pack.md` is the private style guide shared across +writing and internal documents. Invoke `$voice` to learn from approved prose in +`../writing/ready/`, `../writing/published/`, and `final/`. The skill never +reads `drafts/`. + +## Lifecycle + +## Index + +`index.md` is the curated catalogue for this workspace. Keep every draft, final +document, and managed deliverable listed exactly once under its current stage +with a one-line description and concise status context such as updated date or +linked source document. Agents should consult it before loading multiple files +just to understand what work already exists. + +Create `documents/index.md` before adding the first managed draft, final +document, or deliverable if it does not already exist in your vault. + +### `drafts/` + +Active private drafting. Agents may create and revise drafts when assigned. Use +`templates/document-piece.md`, retain a stable kebab-case filename, and keep +frontmatter current. + +### `final/` + +The canonical internal record of finished documents. Move a draft here only +when the user approves that named document as the finished internal version. +These files are eligible as voice evidence and should remain stable except for +deliberate later revisions. + +### `deliverables/` + +Shareable artefacts generated from or associated with a document, such as +PowerPoint slide decks, Word versions, spreadsheets, and similar distribution +formats. Keep them under a folder matching the document slug where practical: + +```text +documents/deliverables/data-strategy-for-social-impact/ +``` + +Use kebab-case filenames for managed copies, for example +`data-strategy-for-social-impact.pptx` or +`data-strategy-for-social-impact.docx`. + +Record only materially significant lifecycle changes for this workspace in the +root `log.md`; `documents/` no longer maintains a separate append-only log. + +## Required frontmatter + +```yaml +--- +title: Document title +type: document +status: draft +created: YYYY-MM-DD +updated: YYYY-MM-DD +audience: +human_author: +ai_assistance: true +voice_pack: writing/voice/voice-pack.md +origin_path: +tags: + - document +--- +``` + +`origin_path` is optional and is useful when a document copy was seeded from +`raw/` or another location. + +## Commands + +```sh +python3 tools/documents.py status +python3 tools/documents.py lint +python3 tools/document_deliverables.py docx documents/drafts/example.md +``` + +## Deliverable generation + +Use `tools/document_deliverables.py` when a managed Markdown document needs a shareable `.docx` copy under `documents/deliverables//`. + +The current supported path is: + +- Markdown document to Word `.docx` + +After generating a deliverable, render it through the bundled document-skill renderer and visually inspect the PNG output before treating it as ready to share. The current operating note for this workflow lives in `../work/document-deliverables-workflow.md`. diff --git a/framework/defaults/dtm/recurring-tasks.json b/framework/defaults/dtm/recurring-tasks.json index c4cfed6..6f5ca30 100644 --- a/framework/defaults/dtm/recurring-tasks.json +++ b/framework/defaults/dtm/recurring-tasks.json @@ -1,6 +1,36 @@ { "version": 1, "tasks": [ + { + "id": "weekday-wiki-open-question", + "task": "Answer one wiki open question if any remain in [[work/wiki-open-questions|Wiki Open Questions]]", + "area": "schedule", + "enabled": true, + "schedule": { + "frequency": "weekly", + "weekdays": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] + } + }, + { + "id": "monday-review-diary-post", + "task": "Review the generated weekly diary draft", + "area": "professional", + "enabled": true, + "schedule": { + "frequency": "weekly", + "weekdays": ["Monday"] + } + }, + { + "id": "tuesday-review-wiki-blog-post", + "task": "Review the generated weekly wiki blog draft", + "area": "professional", + "enabled": true, + "schedule": { + "frequency": "weekly", + "weekdays": ["Tuesday"] + } + }, { "id": "weekly-backlog-prioritisation", "task": "Prioritise the weekly backlog", diff --git a/framework/publication-manifest.json b/framework/publication-manifest.json index 4e5dcf4..8889a92 100644 --- a/framework/publication-manifest.json +++ b/framework/publication-manifest.json @@ -4,9 +4,12 @@ {"source": "publication/repository", "destination": "."}, {"source": "AGENTS.md", "destination": "framework/AGENTS.md"}, {"source": "DTM.md", "destination": "framework/DTM.md"}, + {"source": "documents/README.md", "destination": "framework/defaults/documents/README.md"}, {"source": "templates", "destination": "framework/templates"}, {"source": "tools/wiki.py", "destination": "framework/tools/wiki.py"}, {"source": "tools/dtm.py", "destination": "framework/tools/dtm.py"}, + {"source": "tools/documents.py", "destination": "framework/tools/documents.py"}, + {"source": "tools/writing.py", "destination": "framework/tools/writing.py"}, {"source": "tools/export_framework.py", "destination": "framework/tools/export_framework.py"}, {"source": "tools/publish_framework.py", "destination": "framework/tools/publish_framework.py"}, {"source": ".obsidian/app.json", "destination": "framework/obsidian/app.json"}, @@ -15,6 +18,7 @@ {"source": "dtm/README.md", "destination": "framework/defaults/dtm/README.md"}, {"source": "dtm/recurring-tasks.json", "destination": "framework/defaults/dtm/recurring-tasks.json"}, {"source": "automation-definitions", "destination": "framework/automation-definitions"}, + {"source": "skills", "destination": "framework/skills"}, {"source": "publication/manifest.json", "destination": "framework/publication-manifest.json"} ], "forbidden_live_prefixes": [ @@ -22,7 +26,12 @@ "wiki", "raw", "projects", + "documents/drafts", + "documents/final", + "documents/deliverables", + "documents/index.md", "work", + "writing", "scratch.md", ".obsidian/workspace.json", ".obsidian/workspace-mobile.json" diff --git a/framework/skills/dtm/SKILL.md b/framework/skills/dtm/SKILL.md new file mode 100644 index 0000000..57af72e --- /dev/null +++ b/framework/skills/dtm/SKILL.md @@ -0,0 +1,45 @@ +--- +name: dtm +description: Start or resume a thread-scoped Digital TeamMate session inside a SecondBrain vault. Use when the user invokes `$dtm` or explicitly asks to make the current thread a persistent DTM session so subsequent turns follow the DTM role, Daily Note, task, project, decision, and operational-continuity workflows until the user ends or switches the session. +--- + +# Digital TeamMate session + +Bind the current conversation thread to the SecondBrain DTM role. Do not merely +describe the role. + +## Start the session + +1. Confirm the working directory is a SecondBrain vault containing `AGENTS.md` + and `DTM.md`. If not, ask the user to open the thread in that vault. +2. Read both contracts completely. Never inspect `scratch.md` while orienting. +3. Treat this thread as DTM-scoped from this turn onward. Subsequent user + messages remain in DTM mode without another `$dtm` invocation. +4. Run `python3 tools/dtm.py open`. This is idempotent and creates today's Daily + Note only when missing. +5. Read today's Daily Note and relevant linked active projects. Do not scan + unrelated project, wiki, or raw content. +6. Add one timestamped `Notes & Activity` entry recording that this thread's DTM + session started. Do not duplicate the entry if initialization is retried. +7. Acknowledge activation briefly and surface today's current focus, outstanding + tasks, or open questions only when useful. + +## Operate the session + +- Apply `DTM.md` to every later turn in this thread, including short follow-ups + whose role would otherwise be ambiguous. +- Maintain today's Daily Note, project state, decisions, follow-ups, references, + and open questions as the work evolves. Capture outcomes rather than chat + transcripts. +- Treat an explicitly requested Knowledge Agent action as a bounded delegation. + Return to DTM mode afterwards unless the user switches the thread permanently. +- Never scan `raw/`. Access only a raw file explicitly named by the user for the + current task. +- Preserve the human-only `scratch.md` boundary. + +## End or switch + +End persistent DTM mode only when the user says `$end-dtm`, “end the DTM +session”, or explicitly switches this thread to the Knowledge Agent. Record a +concise session-end activity entry when substantive work occurred. After a +bounded one-off delegation, keep the DTM session active. diff --git a/framework/skills/dtm/agents/openai.yaml b/framework/skills/dtm/agents/openai.yaml new file mode 100644 index 0000000..1c664bd --- /dev/null +++ b/framework/skills/dtm/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Digital TeamMate" + short_description: "Start a thread-scoped SecondBrain DTM session" + default_prompt: "Use $dtm to start a Digital TeamMate session for this thread." +policy: + allow_implicit_invocation: false diff --git a/framework/skills/voice/SKILL.md b/framework/skills/voice/SKILL.md new file mode 100644 index 0000000..e690f2b --- /dev/null +++ b/framework/skills/voice/SKILL.md @@ -0,0 +1,52 @@ +--- +name: voice +description: Analyse the user's approved writing and finished internal documents and create or update the private SecondBrain writing voice pack. Use only when the user invokes `$voice` or explicitly asks to learn, refresh, or inspect their writing style from `writing/ready/`, `writing/published/`, and `documents/final/`; never learn from drafts, chats, notes, wiki pages, sources, or other vault content. +--- + +# Writing voice analysis + +Build an evidence-based style model at `writing/voice/voice-pack.md` without +reading `writing/drafts/` or `documents/drafts/`. + +## Analyse the corpus + +1. Confirm the working directory contains `AGENTS.md`, `writing/README.md`, and + the writing workspace. Read those contracts first. Never inspect + `scratch.md`. +2. Read the existing voice pack when present. Preserve its **Declared + preferences** section verbatim unless the user explicitly asks to change it. +3. Enumerate Markdown files only under `writing/ready/`, + `writing/published/`, and `documents/final/`. Do not list, search, count, or + read files under `writing/drafts/` or `documents/drafts/`. +4. Analyse only reader-facing prose. For writing pieces, use only content + between `` and ``. For internal + documents in `documents/final/`, analyse the reader-facing body while + ignoring private notes, metadata, and drafting residue. +5. Weight published work more strongly than ready work, and weight approved + final internal documents between those two levels unless the user asks + otherwise. Separate stable habits seen across pieces from topic, format, or + genre-specific choices. Do not infer a confident rule from a single example. +6. Extract actionable patterns for tone, stance, syntax and rhythm, diction, + structure, transitions, openings, closings, rhetorical habits, formatting, + and characteristic things to avoid. Use short examples only when they add + precision; link them to the eligible writing file. + +## Update the voice pack + +- Create the pack from `templates/voice-pack.md` if absent; otherwise update it + in place without erasing declared preferences or useful historical nuance. +- Keep observations concrete enough to guide drafting. Prefer “Use X under Y + conditions” over vague labels such as “engaging” or “authentic.” +- Record coverage, confidence, eligible source counts, an evidence ledger, and + what changed in this analysis pass. +- Mark the pack `provisional` when evidence is sparse or narrow. Never invent a + style to fill gaps; state what cannot yet be learned. +- Append a `voice` entry to `writing/log.md`. If the current thread is an active + DTM session, also add the updated pack to today's Daily Note References and + record the action once. +- Run `python3 tools/writing.py lint` before reporting completion. + +If there is no eligible corpus, create or retain a provisional empty pack, +report that no style was inferred, and suggest moving representative human- +approved work to `writing/ready/`, `writing/published/`, or `documents/final/` +before invoking `$voice` again. diff --git a/framework/skills/voice/agents/openai.yaml b/framework/skills/voice/agents/openai.yaml new file mode 100644 index 0000000..4e53b8c --- /dev/null +++ b/framework/skills/voice/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Voice" + short_description: "Learn and maintain your SecondBrain writing voice" + default_prompt: "Use $voice to analyse my approved writing and update my voice pack." +policy: + allow_implicit_invocation: false diff --git a/framework/templates/daily-note.md b/framework/templates/daily-note.md index 63ad074..2eb6c4a 100644 --- a/framework/templates/daily-note.md +++ b/framework/templates/daily-note.md @@ -26,6 +26,8 @@ tags: ## Decisions + + ## References ## Open Questions diff --git a/framework/templates/document-piece.md b/framework/templates/document-piece.md new file mode 100644 index 0000000..9e3e7fe --- /dev/null +++ b/framework/templates/document-piece.md @@ -0,0 +1,28 @@ +--- +title: Document title +type: document +status: draft +created: {{date:YYYY-MM-DD}} +updated: {{date:YYYY-MM-DD}} +audience: +human_author: +ai_assistance: true +voice_pack: writing/voice/voice-pack.md +origin_path: +tags: + - document +--- + +# {{title}} + +## Summary + +One-paragraph summary of the document's purpose and intended outcome. + +## Document + + + +## Notes + + diff --git a/framework/templates/project.md b/framework/templates/project.md index 85ea7a7..beb9dc5 100644 --- a/framework/templates/project.md +++ b/framework/templates/project.md @@ -25,7 +25,7 @@ Where the project stands now. ## Decisions -Links to relevant Daily Note decision records. +Relevant decisions and links back to the supporting Daily Note or working context. ## Risks and open questions diff --git a/framework/templates/voice-pack.md b/framework/templates/voice-pack.md new file mode 100644 index 0000000..0c1861f --- /dev/null +++ b/framework/templates/voice-pack.md @@ -0,0 +1,77 @@ +--- +title: Writing Voice Pack +type: voice-pack +status: provisional +created: YYYY-MM-DD +updated: YYYY-MM-DD +ready_sources: 0 +published_sources: 0 +document_sources: 0 +tags: + - writing + - voice +--- + +# Writing Voice Pack + +## How to use this pack + +Apply these patterns as flexible guidance. The current editorial brief and +direct user instructions take precedence. Do not exaggerate traits into a +caricature. + +## Confidence and coverage + +No approved writing or final internal documents have been analysed yet. The +pack is provisional. + +## Declared preferences + + + +## Observed voice + +### Tone and stance + +Insufficient evidence. + +### Syntax and rhythm + +Insufficient evidence. + +### Diction + +Insufficient evidence. + +### Structure and transitions + +Insufficient evidence. + +### Openings and closings + +Insufficient evidence. + +### Rhetorical habits and formatting + +Insufficient evidence. + +## Avoid + +No evidence-based avoidances identified yet. + +## Context variations + +No genre-specific variations identified yet. + +## Drafting checklist + +- Follow the current brief before inferred tendencies. +- Recheck this pack after representative approved writing is added. + +## Evidence ledger + +_No eligible sources analysed._ + +## Change notes + +- YYYY-MM-DD — Created provisional empty pack. diff --git a/framework/templates/writing-piece.md b/framework/templates/writing-piece.md new file mode 100644 index 0000000..14b26a2 --- /dev/null +++ b/framework/templates/writing-piece.md @@ -0,0 +1,42 @@ +--- +title: Piece title +type: writing +status: draft +created: YYYY-MM-DD +updated: YYYY-MM-DD +audience: +publication_target: +canonical_url: +published_at: +human_author: +ai_assistance: true +voice_pack: writing/voice/voice-pack.md +tags: + - writing +--- + +# Piece title + +## Editorial brief + +- **Purpose:** What this piece should accomplish. +- **Audience:** Who it is for and what they already know. +- **Thesis:** The central claim or promise. +- **Voice:** Desired tone, style, and constraints. +- **Call to action:** What the reader should think or do next. + + + +# Piece title + +Write the reader-facing piece here. + + + +## Sources and claim checks + +- Link supporting wiki/source material and flag claims needing verification. + +## Revision history + +- YYYY-MM-DD — Created initial draft. diff --git a/framework/tools/documents.py b/framework/tools/documents.py new file mode 100644 index 0000000..e01c57c --- /dev/null +++ b/framework/tools/documents.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Structural diagnostics for the collaborative internal-documents workflow.""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DOCUMENTS = ROOT / "documents" +DELIVERABLES = DOCUMENTS / "deliverables" +STAGES = { + "drafts": "draft", + "final": "final", +} +REQUIRED = { + "title", + "type", + "status", + "created", + "updated", + "audience", + "human_author", + "ai_assistance", + "voice_pack", + "tags", +} +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*\.md$") +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +LINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]") +INDEX_ENTRY_RE = re.compile(r"^\s*-\s+\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]", re.M) + + +def relative(path: Path) -> str: + return path.relative_to(ROOT).as_posix() + + +def pieces() -> list[tuple[Path, str]]: + result: list[tuple[Path, str]] = [] + for folder, status in STAGES.items(): + result.extend((path, status) for path in sorted((DOCUMENTS / folder).glob("*.md"))) + return result + + +def frontmatter(text: str) -> dict[str, object] | None: + if not text.startswith("---\n"): + return None + end = text.find("\n---\n", 4) + if end < 0: + return None + result: dict[str, object] = {} + active_list: str | None = None + for line in text[4:end].splitlines(): + if re.match(r"^\s+-\s+", line) and active_list: + value = re.sub(r"^\s+-\s+", "", line).strip().strip('"\'') + assert isinstance(result[active_list], list) + result[active_list].append(value) + continue + match = re.match(r"^([A-Za-z_][\w-]*):(?:\s*(.*))?$", line) + if not match: + active_list = None + continue + key, value = match.groups() + if value: + result[key] = value.strip().strip('"\'') + active_list = None + else: + result[key] = [] + active_list = key + return result + + +def value(meta: dict[str, object], field: str) -> str: + candidate = meta.get(field, "") + return candidate if isinstance(candidate, str) else "" + + +def lint() -> int: + errors: list[str] = [] + all_pieces = pieces() + names = Counter(path.name for path, _ in all_pieces) + deliverable_files = sorted( + path + for path in DELIVERABLES.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") + index_links = [target.removesuffix(".md") for target in INDEX_ENTRY_RE.findall(index_text)] + else: + index_text = "" + index_links = [] + if all_pieces or deliverable_files: + errors.append( + "documents/index.md: missing curated index; create documents/index.md before managing documents or deliverables" + ) + + for name, count in names.items(): + if count > 1: + errors.append(f"duplicate document filename across stages: {name}") + + for path, expected_status in all_pieces: + label = relative(path) + text = path.read_text(encoding="utf-8") + meta = frontmatter(text) + if not SLUG_RE.fullmatch(path.name): + errors.append(f"{label}: filename must use lowercase kebab-case") + if meta is None: + errors.append(f"{label}: missing or malformed frontmatter") + continue + missing = REQUIRED - meta.keys() + if missing: + errors.append(f"{label}: missing fields {', '.join(sorted(missing))}") + if meta.get("type") != "document": + errors.append(f"{label}: type must be 'document'") + if meta.get("status") != expected_status: + errors.append( + f"{label}: folder requires status {expected_status!r}, found {meta.get('status')!r}" + ) + for field in ("created", "updated"): + if not DATE_RE.fullmatch(value(meta, field)): + errors.append(f"{label}: {field} must be YYYY-MM-DD") + if not value(meta, "title"): + errors.append(f"{label}: title must not be empty") + if not value(meta, "voice_pack"): + errors.append(f"{label}: voice_pack must not be empty") + target = label.removesuffix(".md") + count = index_links.count(target) + if count == 0: + errors.append(f"{label}: missing from documents/index.md") + elif count > 1: + errors.append(f"{label}: listed {count} times in documents/index.md") + + if DELIVERABLES.exists(): + for path in deliverable_files: + if " " in path.name: + errors.append( + f"{relative(path)}: managed deliverable filenames must use kebab-case without spaces" + ) + 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).") + return 1 if errors else 0 + + +def status() -> int: + counts = Counter(status for _, status in pieces()) + print(f"Drafts: {counts['draft']}") + print(f"Final: {counts['final']}") + deliverable_count = sum( + 1 + for path in DELIVERABLES.rglob("*") + if path.is_file() + and path.name.lower() != "readme.md" + and not path.name.startswith(".") + ) + print(f"Deliverables: {deliverable_count}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("lint") + sub.add_parser("status") + args = parser.parse_args() + return lint() if args.command == "lint" else status() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/framework/tools/dtm.py b/framework/tools/dtm.py index 1700153..2fe7f90 100644 --- a/framework/tools/dtm.py +++ b/framework/tools/dtm.py @@ -205,7 +205,7 @@ def render_note(day: date, previous_text: str | None) -> str: ## Decisions - + ## References @@ -259,6 +259,51 @@ def open_day(day: date) -> bool: return True +def append_activity(day: date, actor: str, message: str, timestamp: str | None = None) -> bool: + path = note_path(day) + if not path.exists(): + raise DTMError(f"No Daily Note exists for {day.isoformat()}") + if not actor.strip(): + raise DTMError("Activity actor must be non-empty") + if not message.strip(): + raise DTMError("Activity message must be non-empty") + time_value = timestamp or datetime.now().strftime("%H:%M") + if not re.fullmatch(r"\d{2}:\d{2}", time_value): + raise DTMError("Activity time must use HH:MM format") + + text = path.read_text(encoding="utf-8") + notes_match = re.search(r"^## Notes & Activity\s*$", text, re.M) + if notes_match is None: + raise DTMError(f"{path.relative_to(ROOT)} is missing the Notes & Activity section") + decisions_match = re.search(r"^## Decisions\s*$", text, re.M) + if decisions_match is None: + raise DTMError(f"{path.relative_to(ROOT)} is missing the Decisions section") + + entry = f"- {time_value} — {actor.strip()} — {message.strip()}" + notes_heading = "## Notes & Activity" + notes_start = notes_match.start() + notes_end = notes_match.end() + marker_start = decisions_match.start() + marker_end = decisions_match.end() + after = text[marker_end:] + notes_prefix = text[:notes_start] + notes_body = text[notes_end:marker_start] + notes_lines = notes_body.rstrip("\n").splitlines() + if entry in notes_lines: + print(f"Activity already present in {path.relative_to(ROOT)}") + return False + if notes_lines and notes_lines[-1].strip(): + notes_lines.append(entry) + else: + notes_lines = [line for line in notes_lines if line.strip()] + notes_lines.append(entry) + + updated_before = f"{notes_prefix}{notes_heading}\n{chr(10).join(notes_lines)}\n" + path.write_text(f"{updated_before}## Decisions{after}", encoding="utf-8") + print(f"Updated {path.relative_to(ROOT)}") + return True + + def rollover(day: date) -> int: # Validate before closing yesterday so bad configuration cannot leave the # lifecycle half-applied with no new Daily Note. @@ -394,6 +439,11 @@ def main() -> int: for command in ("open", "close", "rollover"): command_parser = sub.add_parser(command) command_parser.add_argument("--date", type=parse_date, default=date.today()) + activity_parser = sub.add_parser("activity") + activity_parser.add_argument("--date", type=parse_date, default=date.today()) + activity_parser.add_argument("--actor", default="DTM") + activity_parser.add_argument("--time", dest="timestamp") + activity_parser.add_argument("message") sub.add_parser("lint") sub.add_parser("status") args = parser.parse_args() @@ -406,6 +456,9 @@ def main() -> int: return 0 if args.command == "rollover": return rollover(args.date) + if args.command == "activity": + append_activity(args.date, args.actor, args.message, args.timestamp) + return 0 if args.command == "lint": return lint() return status() diff --git a/framework/tools/publish_framework.py b/framework/tools/publish_framework.py index cc78e0e..9c22816 100644 --- a/framework/tools/publish_framework.py +++ b/framework/tools/publish_framework.py @@ -97,7 +97,7 @@ def open_publication_pr(repo: str) -> str: [ "gh", "pr", "list", "--repo", repo, "--state", "open", "--json", "url,headRefName", - "--jq", '.[] | select(.headRefName == "codex/initial-secondbrain-framework" or (.headRefName | startswith("automation/framework-sync-"))) | .url', + "--jq", '.[] | select((.headRefName | startswith("codex/")) or (.headRefName | startswith("automation/framework-sync-"))) | .url', ], ) return result.stdout.splitlines()[0].strip() if result.stdout.strip() else "" diff --git a/framework/tools/wiki.py b/framework/tools/wiki.py index 83bb78e..3f3c992 100644 --- a/framework/tools/wiki.py +++ b/framework/tools/wiki.py @@ -4,20 +4,36 @@ from __future__ import annotations import argparse +import ast +import hashlib import re import sys from collections import Counter +from datetime import date from pathlib import Path ROOT = Path(__file__).resolve().parent.parent WIKI = ROOT / "wiki" RAW = ROOT / "raw" -SPECIAL = {WIKI / "index.md", WIKI / "log.md"} +SYSTEM_LOG = ROOT / "log.md" +SPECIAL = {WIKI / "index.md"} REQUIRED = {"title", "type", "status", "created", "updated", "tags"} VALID_TYPES = {"overview", "source", "entity", "concept", "topic", "analysis"} VALID_STATUSES = {"current", "seed", "needs-review", "disputed", "superseded"} LINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]") LOG_RE = re.compile(r"^## \[(\d{4}-\d{2}-\d{2})\] ([a-z-]+) \| (.+)$", re.M) +QUESTION_STATUSES = ( + "needs-triage", + "answer-myself", + "research-with-dtm", + "ready-to-integrate", +) +QUESTION_HEADINGS = { + "open questions", + "questions raised", +} +QUEUE_PATH = ROOT / "work" / "wiki-open-questions.md" +QUESTION_THREAD_RE = re.compile(r"") def pages() -> list[Path]: @@ -34,6 +50,23 @@ def relative(path: Path) -> str: return path.relative_to(ROOT).as_posix() +def parse_scalar(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + try: + parsed = ast.literal_eval(value) + except (SyntaxError, ValueError): + return value[1:-1] + return parsed if isinstance(parsed, str) else value[1:-1] + return value + + +def read_system_log() -> str | None: + if not SYSTEM_LOG.exists(): + return None + return SYSTEM_LOG.read_text(encoding="utf-8") + + def frontmatter(text: str) -> dict[str, object] | None: if not text.startswith("---\n"): return None @@ -44,7 +77,7 @@ def frontmatter(text: str) -> dict[str, object] | None: active_list: str | None = None for line in text[4:end].splitlines(): if re.match(r"^\s+-\s+", line) and active_list: - value = re.sub(r"^\s+-\s+", "", line).strip().strip('"\'') + value = parse_scalar(re.sub(r"^\s+-\s+", "", line)) assert isinstance(result[active_list], list) result[active_list].append(value) continue @@ -54,7 +87,7 @@ def frontmatter(text: str) -> dict[str, object] | None: continue key, value = match.groups() if value: - result[key] = value.strip().strip('"\'') + result[key] = parse_scalar(value) active_list = None else: result[key] = [] @@ -83,6 +116,208 @@ def collect_links(path: Path) -> list[str]: return LINK_RE.findall(path.read_text(encoding="utf-8")) +def page_title(path: Path, text: str) -> str: + meta = frontmatter(text) or {} + title = meta.get("title") + if isinstance(title, str) and title.strip(): + return title.strip() + heading = re.search(r"^#\s+(.+)$", text, re.M) + return heading.group(1).strip() if heading else path.stem.replace("-", " ").title() + + +def question_id(path: Path, question: str) -> str: + key = f"{relative(path)}::{question.strip()}" + return hashlib.sha1(key.encode("utf-8")).hexdigest()[:10] + + +def extract_questions(path: Path) -> list[dict[str, str]]: + text = path.read_text(encoding="utf-8") + title = page_title(path, text) + items: list[dict[str, str]] = [] + lines = text.splitlines() + active_heading: str | None = None + for line in lines: + heading_match = re.match(r"^(##)\s+(.+?)\s*$", line) + if heading_match: + heading = heading_match.group(2).strip().casefold() + active_heading = heading if heading in QUESTION_HEADINGS else None + continue + if active_heading is None: + continue + if re.match(r"^#(?!#)\s+", line) or re.match(r"^###\s+", line): + active_heading = None + continue + bullet = re.match(r"^-\s+(.*\S)\s*$", line) + if not bullet: + continue + raw_question = bullet.group(1).strip() + thread_match = QUESTION_THREAD_RE.search(raw_question) + thread_id = thread_match.group(1) if thread_match else question_id(path, raw_question) + question = QUESTION_THREAD_RE.sub("", raw_question).strip() + items.append( + { + "id": question_id(path, question), + "thread_id": thread_id, + "question": question, + "path": relative(path)[:-3], + "title": title, + } + ) + return items + + +def load_question_statuses(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + statuses: dict[str, str] = {} + pattern = re.compile( + r"^-\s+\[[ xX]\]\s+\[([a-z-]+)\].*?$" + ) + for line in path.read_text(encoding="utf-8").splitlines(): + match = pattern.match(line.strip()) + if not match: + continue + status, item_id = match.groups() + if status in QUESTION_STATUSES: + statuses[item_id] = status + return statuses + + +def format_question_item(item: dict[str, object], status: str) -> str: + links = " · ".join( + f"[[{source['path']}|{source['title']}]]" + for source in item["sources"] + ) + return ( + f"- [ ] [{status}] {item['question']} " + f"{links} " + f"" + ) + + +def queue_dates(path: Path) -> tuple[str, str]: + created = date.today().isoformat() + updated = created + if path.exists(): + meta = frontmatter(path.read_text(encoding="utf-8")) or {} + existing_created = meta.get("created") + if isinstance(existing_created, str) and existing_created.strip(): + created = existing_created.strip() + return created, updated + + +def render_question_queue(items: list[dict[str, str]], statuses: dict[str, str]) -> str: + created, updated = queue_dates(QUEUE_PATH) + grouped: dict[str, list[dict[str, object]]] = {status: [] for status in QUESTION_STATUSES} + thread_groups: dict[str, dict[str, object]] = {} + for item in sorted(items, key=lambda entry: (entry["title"].casefold(), entry["question"].casefold())): + thread_id = item["thread_id"] + thread = thread_groups.setdefault( + thread_id, + { + "thread_id": thread_id, + "question": item["question"], + "sources": [], + }, + ) + if len(item["question"]) > len(str(thread["question"])): + thread["question"] = item["question"] + thread["sources"].append({"path": item["path"], "title": item["title"]}) + + for thread in sorted( + thread_groups.values(), + key=lambda entry: (str(entry["question"]).casefold(), str(entry["thread_id"])), + ): + status = statuses.get(str(thread["thread_id"]), "needs-triage") + if status not in grouped: + status = "needs-triage" + grouped[status].append(thread) + + def section_lines(status: str, heading: str, description: str) -> list[str]: + lines = [f"## {heading}", "", description, ""] + if grouped[status]: + lines.extend(format_question_item(item, status) for item in grouped[status]) + else: + lines.append(f"") + lines.append("") + return lines + + sections: list[str] = [ + "---", + "title: Wiki Open Questions", + "type: working-note", + "status: current", + f"created: {created}", + f"updated: {updated}", + "tags:", + " - work", + " - wiki", + " - questions", + "---", + "", + "# Wiki Open Questions", + "", + "This DTM-managed queue surfaces unresolved questions that are still present in the wiki so they can be worked separately from Daily Note open questions.", + "", + "Change an item's bracketed status token when triaging it, then rerun `python3 tools/wiki.py questions --write` after wiki changes so this queue stays aligned with the current wiki state.", + "", + "If the same underlying question appears in more than one wiki page, add the same `` marker to each page-level bullet. The queue will then fold them into one entry with multiple source links while still expecting the answer to be integrated back into every relevant page.", + "", + "Status tokens:", + "- `needs-triage` — the question exists in the wiki but has not yet been classified.", + "- `answer-myself` — best answered directly from Philip's own judgment or point of view.", + "- `research-with-dtm` — needs additional research or synthesis with the DTM.", + "- `ready-to-integrate` — an answer exists and now needs to be folded back into the wiki.", + "", + ] + sections.extend( + section_lines( + "needs-triage", + "Needs triage", + "Newly surfaced or not yet classified.", + ) + ) + sections.extend( + section_lines( + "answer-myself", + "Answer myself", + "Questions where the user's own view is a valid primary answer.", + ) + ) + sections.extend( + section_lines( + "research-with-dtm", + "Research with DTM", + "Questions that need evidence gathering, synthesis, or structured follow-up.", + ) + ) + sections.extend( + section_lines( + "ready-to-integrate", + "Ready to integrate", + "Questions that appear answered and now need the wiki updated to reflect that answer.", + ) + ) + return "\n".join(sections).rstrip() + "\n" + + +def questions(write: bool) -> int: + items: list[dict[str, str]] = [] + for path in content_pages(): + if path == WIKI / "index.md": + continue + items.extend(extract_questions(path)) + statuses = load_question_statuses(QUEUE_PATH) + output = render_question_queue(items, statuses) + if write: + QUEUE_PATH.parent.mkdir(parents=True, exist_ok=True) + QUEUE_PATH.write_text(output, encoding="utf-8") + print(f"Wrote {relative(QUEUE_PATH)} with {len(items)} active wiki question(s).") + return 0 + print(output, end="") + return 0 + + def lint() -> int: errors: list[str] = [] warnings: list[str] = [] @@ -151,9 +386,11 @@ def lint() -> int: if not target_exists(target, known): errors.append(f"{relative(path)}: broken link [[{target}]]") - log_text = (WIKI / "log.md").read_text(encoding="utf-8") - if not LOG_RE.search(log_text): - errors.append("wiki/log.md: no parseable log entries") + log_text = read_system_log() + if log_text is None: + 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") errors = sorted(set(errors)) warnings = sorted(set(warnings)) @@ -205,7 +442,14 @@ def pending() -> int: def recent(limit: int) -> int: - entries = LOG_RE.findall((WIKI / "log.md").read_text(encoding="utf-8")) + log_text = read_system_log() + if log_text is None: + print( + "ERROR: missing root system log at log.md; create the file before using `wiki.py recent`.", + file=sys.stderr, + ) + return 1 + entries = LOG_RE.findall(log_text) for date, operation, title in entries[-limit:]: print(f"{date} {operation:<7} {title}") return 0 @@ -217,6 +461,8 @@ def main() -> int: sub.add_parser("lint", help="check structure, metadata, links, index, and log") sub.add_parser("status", help="summarize raw files and wiki pages") sub.add_parser("pending", help="list top-level raw sources awaiting ingest") + questions_parser = sub.add_parser("questions", help="show or write the wiki open-question queue") + questions_parser.add_argument("--write", action="store_true", help="write the queue to work/wiki-open-questions.md") recent_parser = sub.add_parser("recent", help="show recent log entries") recent_parser.add_argument("count", nargs="?", type=int, default=5) args = parser.parse_args() @@ -226,6 +472,8 @@ def main() -> int: return status() if args.command == "pending": return pending() + if args.command == "questions": + return questions(args.write) return recent(max(0, args.count)) diff --git a/framework/tools/writing.py b/framework/tools/writing.py new file mode 100644 index 0000000..56cf16e --- /dev/null +++ b/framework/tools/writing.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Structural diagnostics for the collaborative writing workflow.""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +WRITING = ROOT / "writing" +DOCUMENT_FINAL = ROOT / "documents" / "final" +STAGES = { + "drafts": "draft", + "ready": "ready", + "published": "published", +} +REQUIRED = { + "title", + "type", + "status", + "created", + "updated", + "audience", + "publication_target", + "canonical_url", + "published_at", + "human_author", + "ai_assistance", + "voice_pack", + "tags", +} +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*\.md$") +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +LINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]") +VOICE_REQUIRED = { + "title", + "type", + "status", + "created", + "updated", + "ready_sources", + "published_sources", + "document_sources", + "tags", +} +VOICE_HEADINGS = { + "How to use this pack", + "Confidence and coverage", + "Declared preferences", + "Observed voice", + "Avoid", + "Context variations", + "Drafting checklist", + "Evidence ledger", + "Change notes", +} + + +def relative(path: Path) -> str: + return path.relative_to(ROOT).as_posix() + + +def pieces() -> list[tuple[Path, str]]: + result: list[tuple[Path, str]] = [] + for folder, status in STAGES.items(): + result.extend((path, status) for path in sorted((WRITING / folder).glob("*.md"))) + return result + + +def frontmatter(text: str) -> dict[str, object] | None: + if not text.startswith("---\n"): + return None + end = text.find("\n---\n", 4) + if end < 0: + return None + result: dict[str, object] = {} + active_list: str | None = None + for line in text[4:end].splitlines(): + if re.match(r"^\s+-\s+", line) and active_list: + value = re.sub(r"^\s+-\s+", "", line).strip().strip('"\'') + assert isinstance(result[active_list], list) + result[active_list].append(value) + continue + match = re.match(r"^([A-Za-z_][\w-]*):(?:\s*(.*))?$", line) + if not match: + active_list = None + continue + key, value = match.groups() + if value: + result[key] = value.strip().strip('"\'') + active_list = None + else: + result[key] = [] + active_list = key + return result + + +def value(meta: dict[str, object], field: str) -> str: + candidate = meta.get(field, "") + return candidate if isinstance(candidate, str) else "" + + +def lint() -> int: + errors: list[str] = [] + warnings: list[str] = [] + all_pieces = pieces() + names = Counter(path.name for path, _ in all_pieces) + index_path = WRITING / "index.md" + if index_path.exists(): + index_text = index_path.read_text(encoding="utf-8") + index_links = [target.removesuffix(".md") for target in LINK_RE.findall(index_text)] + else: + index_text = "" + index_links = [] + if all_pieces: + errors.append( + "writing/index.md: missing curated index; create writing/index.md before managing drafts, ready pieces, or published pieces" + ) + + voice_path = WRITING / "voice" / "voice-pack.md" + if not voice_path.exists(): + errors.append("writing/voice/voice-pack.md: missing voice pack") + else: + voice_text = voice_path.read_text(encoding="utf-8") + voice_meta = frontmatter(voice_text) + if voice_meta is None: + errors.append("writing/voice/voice-pack.md: missing or malformed frontmatter") + else: + missing = VOICE_REQUIRED - voice_meta.keys() + if missing: + errors.append( + "writing/voice/voice-pack.md: missing fields " + + ", ".join(sorted(missing)) + ) + if voice_meta.get("type") != "voice-pack": + errors.append("writing/voice/voice-pack.md: type must be 'voice-pack'") + if voice_meta.get("status") not in {"provisional", "current"}: + errors.append( + "writing/voice/voice-pack.md: status must be provisional or current" + ) + for field in ("created", "updated"): + if not DATE_RE.fullmatch(value(voice_meta, field)): + errors.append(f"writing/voice/voice-pack.md: {field} must be YYYY-MM-DD") + actual_counts = { + "ready_sources": sum(1 for _, status in all_pieces if status == "ready"), + "published_sources": sum( + 1 for _, status in all_pieces if status == "published" + ), + "document_sources": len(sorted(DOCUMENT_FINAL.glob("*.md"))), + } + for field, actual in actual_counts.items(): + recorded = value(voice_meta, field) + if not recorded.isdigit(): + errors.append(f"writing/voice/voice-pack.md: {field} must be an integer") + elif int(recorded) != actual: + warnings.append( + f"writing/voice/voice-pack.md: {field}={recorded}, " + f"eligible corpus now has {actual}; run $voice" + ) + for heading in VOICE_HEADINGS: + if not re.search(rf"^## {re.escape(heading)}\s*$", voice_text, re.M): + errors.append(f"writing/voice/voice-pack.md: missing section {heading!r}") + if "[[writing/drafts/" in voice_text: + errors.append("writing/voice/voice-pack.md: evidence must never link to drafts") + + for name, count in names.items(): + if count > 1: + errors.append(f"duplicate writing filename across stages: {name}") + + for path, expected_status in all_pieces: + label = relative(path) + text = path.read_text(encoding="utf-8") + meta = frontmatter(text) + if not SLUG_RE.fullmatch(path.name): + errors.append(f"{label}: filename must use lowercase kebab-case") + if meta is None: + errors.append(f"{label}: missing or malformed frontmatter") + continue + missing = REQUIRED - meta.keys() + if missing: + errors.append(f"{label}: missing fields {', '.join(sorted(missing))}") + if meta.get("type") != "writing": + errors.append(f"{label}: type must be 'writing'") + if meta.get("status") != expected_status: + errors.append( + f"{label}: folder requires status {expected_status!r}, " + f"found {meta.get('status')!r}" + ) + for field in ("created", "updated"): + if not DATE_RE.fullmatch(value(meta, field)): + errors.append(f"{label}: {field} must be YYYY-MM-DD") + if not value(meta, "title"): + errors.append(f"{label}: title must not be empty") + if expected_status in {"ready", "published"}: + if not value(meta, "publication_target"): + errors.append(f"{label}: {expected_status} piece needs publication_target") + if not value(meta, "audience"): + warnings.append(f"{label}: {expected_status} piece has no audience") + if expected_status == "published": + if not value(meta, "canonical_url"): + errors.append(f"{label}: published piece needs canonical_url") + if not value(meta, "published_at"): + errors.append(f"{label}: published piece needs published_at") + + start_count = text.count("") + end_count = text.count("") + if start_count != 1 or end_count != 1: + errors.append(f"{label}: needs exactly one publish:start and publish:end marker") + elif text.index("") >= text.index(""): + errors.append(f"{label}: publish markers are out of order") + + target = label.removesuffix(".md") + count = index_links.count(target) + if count == 0: + errors.append(f"{label}: missing from writing/index.md") + elif count > 1: + errors.append(f"{label}: listed {count} times in writing/index.md") + + for item in sorted(set(errors)): + print(f"ERROR {item}") + for item in sorted(set(warnings)): + print(f"WARNING {item}") + print( + f"Checked {len(all_pieces)} writing piece(s): " + f"{len(set(errors))} error(s), {len(set(warnings))} warning(s)." + ) + return 1 if errors else 0 + + +def status() -> int: + counts = Counter(status for _, status in pieces()) + print(f"Drafts: {counts['draft']}") + print(f"Ready for publishing: {counts['ready']}") + print(f"Published: {counts['published']}") + voice_path = WRITING / "voice" / "voice-pack.md" + if voice_path.exists(): + meta = frontmatter(voice_path.read_text(encoding="utf-8")) or {} + print(f"Voice pack: {meta.get('status', 'invalid')} (updated {meta.get('updated', 'unknown')})") + else: + print("Voice pack: missing") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("lint") + sub.add_parser("status") + args = parser.parse_args() + return lint() if args.command == "lint" else status() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/install.py b/install.py index d58ff27..81db403 100644 --- a/install.py +++ b/install.py @@ -43,6 +43,7 @@ def install(vault: Path, force: bool, dry_run: bool) -> int: (FRAMEWORK / "obsidian", vault / ".obsidian"), (FRAMEWORK / "defaults" / "dtm", vault / "dtm"), (FRAMEWORK / "automation-definitions", vault / "automation-definitions"), + (FRAMEWORK / "skills", vault / "skills"), (EXAMPLE, vault), ): for source in files_under(folder): diff --git a/tests/test_framework.py b/tests/test_framework.py index 405c571..dd81d39 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -31,6 +31,33 @@ def test_templates_and_daily_notes_match_installed_layout(self): self.assertEqual(daily["template"], "templates/daily-note") +class SkillPackagingTests(unittest.TestCase): + def test_dtm_skill_is_explicit_and_thread_scoped(self): + skill = ROOT / "framework" / "skills" / "dtm" + instructions = (skill / "SKILL.md").read_text(encoding="utf-8") + metadata = (skill / "agents" / "openai.yaml").read_text(encoding="utf-8") + + self.assertIn("name: dtm", instructions) + self.assertIn("thread-scoped", instructions) + self.assertIn("$end-dtm", instructions) + self.assertIn("allow_implicit_invocation: false", metadata) + + def test_voice_skill_excludes_drafts_and_is_explicit(self): + skill = ROOT / "framework" / "skills" / "voice" + instructions = (skill / "SKILL.md").read_text(encoding="utf-8") + metadata = (skill / "agents" / "openai.yaml").read_text(encoding="utf-8") + normalized = " ".join(instructions.split()) + + self.assertIn("writing/ready/", instructions) + self.assertIn("writing/published/", instructions) + self.assertIn( + "Do not list, search, count, or read files under `writing/drafts/` or `documents/drafts/`.", + normalized, + ) + self.assertIn("writing/drafts/", instructions) + self.assertIn("allow_implicit_invocation: false", metadata) + + class RecurrenceSafetyTests(unittest.TestCase): def test_invalid_rule_fails_before_rollover_mutates_notes(self): dtm = load_dtm_module()