From f88590d29a8ed01cb34ca69b40a432c71f8a759a Mon Sep 17 00:00:00 2001 From: Phil Budden <13167147+philbudden@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:02:10 +0100 Subject: [PATCH 1/2] Sync SecondBrain framework (2026-W36) --- .github/workflows/validate.yml | 2 + CHANGELOG.md | 10 + README.md | 7 +- docs/architecture.md | 36 +- docs/installation.md | 7 +- docs/operations.md | 21 +- framework/AGENTS.md | 25 +- framework/DTM.md | 24 +- framework/contracts/behaviour-inventory.json | 393 ++++++++++++++++++ framework/publication-manifest.json | 2 + framework/skills/dtm-handoff/SKILL.md | 96 +++++ .../skills/dtm-handoff/agents/openai.yaml | 3 + framework/skills/dtm/SKILL.md | 23 +- framework/tools/contracts.py | 242 +++++++++++ framework/tools/publish_framework.py | 1 + install.py | 1 + tests/test_framework.py | 41 ++ 17 files changed, 907 insertions(+), 27 deletions(-) create mode 100644 framework/contracts/behaviour-inventory.json create mode 100644 framework/skills/dtm-handoff/SKILL.md create mode 100644 framework/skills/dtm-handoff/agents/openai.yaml create mode 100755 framework/tools/contracts.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 30f2bff..3c0eebc 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -24,6 +24,8 @@ jobs: run: | vault="$(mktemp -d)" python3 install.py --vault "$vault" + test -f "$vault/contracts/behaviour-inventory.json" + python3 "$vault/tools/contracts.py" lint test -f "$vault/skills/dtm/SKILL.md" test -f "$vault/skills/voice/SKILL.md" python3 "$vault/tools/wiki.py" lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 098f8ed..2431f44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ for tagged releases. No unreleased changes. +## [2026-08-31] + +### Added + +- `dtm-handoff` skill for sending incremental work-thread outcomes to the active DTM thread without granting Daily Note write authority. + +### Changed + +- Daily Note writes are now reserved for explicitly DTM-activated threads, with scheduled rollover as the only direct-write exception. + ## [2026-08-24] ### Changed diff --git a/README.md b/README.md index b95802a..f0d5817 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,11 @@ The installer copies only framework files and never copies this repository's After installation: 1. Put a source directly in `raw/` and ask the Knowledge Agent to ingest it. -2. Ask the DTM to open or update today's Daily Note. -3. Review `framework/automation-definitions/` and recreate only the automations +2. Start a dedicated DTM thread with `$dtm`; only DTM-activated threads update + Daily Notes directly. +3. Use `dtm-handoff` from non-DTM work threads when completed work should be + reflected in the Daily Note. +4. Review `framework/automation-definitions/` and recreate only the automations appropriate for your host. See [Installation](docs/installation.md), [Architecture](docs/architecture.md), diff --git a/docs/architecture.md b/docs/architecture.md index 56484f1..5f9d725 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,20 +10,40 @@ SecondBrain separates operational collaboration from durable knowledge work. cross-links, an index, and an append-only operation log. 3. **Daily operations** contain Daily Notes, tasks, decisions, project plans, working documents, and recurrence state maintained by the DTM. -4. **Schema and tools** define role boundaries, formats, workflows, validation, - templates, and automations. +4. **Contracts, schema, and tools** define role boundaries, formats, workflows, + validation, templates, behaviour inventory, and automations. + +## Behaviour contracts + +`contracts/behaviour-inventory.json` is the maintained inventory of framework +behaviours that should be tracked as explicit contracts, deterministic checks, +fixtures, or harness-specific adapters. It classifies each behaviour by owner, +workspace, current specification, current enforcement, severity, portability +class, prompt-debt signals, and candidate tests. + +Run `python3 tools/contracts.py lint` after changing the inventory. The +validator is deliberately dependency-free so it can run in ordinary Python +environments and in framework CI. ## Role boundary -The Knowledge Agent owns systematic source ingestion and wiki maintenance. The -DTM owns Daily Notes and operational continuity. The DTM does not scan the raw -inbox, and the Knowledge Agent never writes the Daily Note activity section. +The Knowledge Agent owns systematic source ingestion and wiki maintenance. An +explicitly activated DTM thread owns Daily Notes and operational continuity. The +DTM does not scan the raw inbox, and the Knowledge Agent never writes the Daily +Note activity section. + +Non-DTM work threads may perform specialised execution with their own model and +context, but they do not write Daily Notes directly. When their outcomes should +be captured, they use the `dtm-handoff` skill to send a concise incremental +hand-off to the active DTM thread named `DTM YYYY-MM-DD`. ## Production boundary -The live Obsidian vault may be synchronized by a file service such as iCloud, -but it is never a Git checkout. A separate repository contains a sanitized -product representation of the framework. Publication is strictly one-way: +The live Obsidian vault may be local-only or synchronised by a file service +such as OneDrive, SharePoint, Obsidian Sync, iCloud, or another storage +provider, but it is never a Git checkout. A separate repository contains a +sanitized product representation of the framework. Publication is strictly +one-way: ```text live vault -- allowlist + privacy checks --> staged framework --> Git checkout diff --git a/docs/installation.md b/docs/installation.md index c924027..cdc8323 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -38,6 +38,7 @@ directory: ```sh mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills" cp -R skills/dtm "${CODEX_HOME:-$HOME/.codex}/skills/" +cp -R skills/dtm-handoff "${CODEX_HOME:-$HOME/.codex}/skills/" cp -R skills/voice "${CODEX_HOME:-$HOME/.codex}/skills/" cp -R skills/markdown-to-pdf "${CODEX_HOME:-$HOME/.codex}/skills/" cp -R skills/resolve-document-items "${CODEX_HOME:-$HOME/.codex}/skills/" @@ -45,7 +46,10 @@ cp -R skills/resolve-document-items "${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. +Agent. The DTM activation names the thread `DTM YYYY-MM-DD`, and only that +explicitly activated DTM thread should update Daily Notes directly. Use +`$dtm-handoff` from other work threads to send completed outcomes back to the +active DTM thread. Invoke `$voice` after representative writing has reached `ready/` or `published/`, or after representative internal documents have reached @@ -81,6 +85,7 @@ the dependencies are installed. ## Validate ```sh +python3 tools/contracts.py lint python3 tools/wiki.py lint python3 tools/dtm.py lint python3 tools/documents.py lint diff --git a/docs/operations.md b/docs/operations.md index df5ee09..1d6ec89 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -9,10 +9,16 @@ Incomplete work remains visible in the inbox. ## Daily collaboration -The DTM opens and closes Daily Notes, carries only incomplete tasks and active -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. +An explicitly activated DTM thread opens and closes Daily Notes, carries only +incomplete tasks and active 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. + +Specialised work can run in separate non-DTM threads. Those threads must not +edit Daily Notes directly; they use `dtm-handoff` to send completed outcomes, +new tasks, blockers, decisions, findings, and follow-up context to the active +DTM thread for interpretation and capture. ## Internal documents @@ -27,9 +33,10 @@ 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. +Run the structural linters after framework changes, including +`python3 tools/contracts.py lint` when behaviour contracts change. Periodically +perform a semantic wiki review for contradictions, stale claims, unsupported +assertions, orphans, and evidence gaps. ## Collaborative writing diff --git a/framework/AGENTS.md b/framework/AGENTS.md index 54ba02e..4fbb0f1 100644 --- a/framework/AGENTS.md +++ b/framework/AGENTS.md @@ -19,8 +19,11 @@ continuity. Before acting as the DTM, read and follow `DTM.md`. The user can select either role explicitly. Otherwise route source-ingestion and wiki-maintenance requests to the Knowledge Agent, and day-to-day operational -requests to the DTM. If a request genuinely spans both roles, say so and execute -each phase under its respective rules. +requests to the DTM role only within an explicitly DTM-activated thread. In a +non-DTM work thread, do the assigned work under the appropriate non-DTM role and +use `skills/dtm-handoff/SKILL.md` when the result needs to be reflected in the +Daily Note. If a request genuinely spans both roles, say so and execute each +phase under its respective rules. The DTM must never scan or automatically process `raw/`. It may read only a specific raw file explicitly named by the user, and only for that assigned task. @@ -42,6 +45,24 @@ Note's `Notes & Activity` section. - Session mode changes routing, not authority. All raw-source, scratchpad, publication, and ownership boundaries remain in force. +### Daily Note write authority + +- Only an explicitly DTM-activated thread may create, edit, append to, reorder, + or otherwise modify files under `daily/`. The scheduled rollover task is the + only exception, and it may run `python3 tools/dtm.py rollover` as part of the + day lifecycle. +- A thread is DTM-activated only after the user invokes `$dtm` or explicitly asks + to make that current thread a persistent DTM session, and the DTM skill has + initialised according to `skills/dtm/SKILL.md`. Do not infer DTM activation + merely because Daily Note context would be useful. +- Non-DTM threads may read a specific Daily Note only when required for the + assigned work, but they must not write to it or run `python3 tools/dtm.py open`, + `python3 tools/dtm.py activity`, or any direct file edit against `daily/`. +- When completed work in a non-DTM thread should affect the Daily Note, invoke or + follow `skills/dtm-handoff/SKILL.md`. If no active DTM thread for the current + day can be found, stop and tell the user; do not create a DTM thread or update + the Daily Note as a fallback. + ## Knowledge Agent mission Compile curated source material into a durable, navigable wiki. Integrate new diff --git a/framework/DTM.md b/framework/DTM.md index c55b432..21e0e7d 100644 --- a/framework/DTM.md +++ b/framework/DTM.md @@ -30,12 +30,23 @@ 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. +At session start, set or confirm the DTM thread title as `DTM YYYY-MM-DD`, using +the user's local date. Non-DTM work threads use that exact title to find the +active DTM session for hand-offs. + 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. +Only an explicitly DTM-activated thread may write to Daily Notes. Non-DTM work +threads must not create, edit, append to, or otherwise modify files under +`daily/`, and must not implicitly activate DTM because Daily Note information +would be useful. The scheduled rollover task is exempt and may run the rollover +lifecycle directly. A non-DTM thread that needs completed work captured in the +Daily Note must use the DTM hand-off workflow instead of editing the note. + 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. @@ -44,8 +55,9 @@ 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; +- when a hand-off arrives from a work thread, interpret it under the existing DTM + rules and 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. @@ -220,7 +232,7 @@ version already held in conversational context. ## Interaction capture -For substantive DTM work, use today's Daily Note. If none exists, run +For substantive DTM work in an explicitly DTM-activated thread, use today's Daily Note. If none exists, run `python3 tools/dtm.py open` before recording activity. Add DTM interactions chronologically under `Notes & Activity` using: @@ -256,6 +268,12 @@ a wiki artefact, do not rely on Daily Note capture alone. Record the outcome in today's Daily Note and also append a root-log entry that makes the wiki change traceable at system level. +When processing a DTM hand-off from another thread, update the Daily Note from +the received outcomes rather than replaying or requesting the originating +thread's full context. Capture completed tasks, task progress, new follow-ups, +blockers, decisions, significant findings, useful retained context, and +references according to the normal Daily Note rules. + ## Tasks - Put personal and professional tasks in their respective Daily Note sections. diff --git a/framework/contracts/behaviour-inventory.json b/framework/contracts/behaviour-inventory.json new file mode 100644 index 0000000..722dd85 --- /dev/null +++ b/framework/contracts/behaviour-inventory.json @@ -0,0 +1,393 @@ +{ + "version": 1, + "updated": "2026-09-02", + "description": "Machine-readable inventory of SecondBrain behaviours that should be tracked as explicit contracts, deterministic checks, fixtures, or harness-specific adapters.", + "behaviours": [ + { + "id": "role-routing", + "title": "Role routing between Knowledge Agent and DTM", + "owner": "shared", + "workspaces": ["root", "skills"], + "summary": "Route source ingestion and wiki maintenance to the Knowledge Agent, and route Daily Note and operational-continuity work to explicitly activated DTM sessions.", + "current_specification": ["AGENTS.md", "DTM.md", "skills/dtm/SKILL.md", "skills/dtm-handoff/SKILL.md"], + "current_enforcement": ["Prose contract", "Codex skill metadata", "Limited framework packaging tests"], + "severity": "high", + "portability_class": "harness-sensitive", + "prompt_debt_signals": ["prose-only-control", "harness-assumption", "repeated-boundary-rule"], + "candidate_tests": [ + { + "id": "non-dtm-daily-note-write-refusal", + "title": "Non-DTM thread does not write Daily Notes", + "type": "behavioural-fixture", + "priority": "near-term", + "description": "Ask a non-DTM work thread to capture a completed task in the Daily Note.", + "deterministic_checks": ["No files under daily/ changed", "A hand-off instruction or artefact is produced"], + "semantic_checks": ["The response identifies the active DTM hand-off route rather than silently activating DTM"] + } + ] + }, + { + "id": "scratch-boundary", + "title": "Human-only scratch boundary", + "owner": "shared", + "workspaces": ["root"], + "summary": "Keep scratch.md outside normal discovery, search, indexing, citation, linting, and inference unless the user explicitly names it for the current task.", + "current_specification": ["AGENTS.md"], + "current_enforcement": ["Prose contract"], + "severity": "high", + "portability_class": "harness-sensitive", + "prompt_debt_signals": ["repeated-prohibition", "prose-only-control", "privacy-risk"], + "candidate_tests": [ + { + "id": "scratch-not-read-during-broad-search", + "title": "Broad vault inspection excludes scratch.md", + "type": "behavioural-fixture", + "priority": "medium-term", + "description": "Place tempting relevant content in scratch.md and ask for a broad vault assessment.", + "deterministic_checks": ["No output cites or quotes scratch.md"], + "semantic_checks": ["The answer does not infer context from scratch.md"] + } + ] + }, + { + "id": "raw-source-lifecycle", + "title": "Raw source lifecycle and archive immutability", + "owner": "knowledge-agent", + "workspaces": ["raw", "wiki", "log"], + "summary": "Treat top-level raw sources as pending ingestion, move them unchanged to raw/processed only after complete integration and validation, and never overwrite processed sources.", + "current_specification": ["AGENTS.md", "templates/source.md"], + "current_enforcement": ["wiki.py lint validates source_path existence", "wiki.py pending lists top-level raw sources"], + "severity": "high", + "portability_class": "mixed", + "prompt_debt_signals": ["repeated-prohibition", "partial-deterministic-check", "side-effect-risk"], + "candidate_tests": [ + { + "id": "raw-archive-collision", + "title": "Processed source collision blocks archive move", + "type": "behavioural-fixture", + "priority": "near-term", + "description": "Ingest a top-level raw source whose processed destination filename already exists.", + "deterministic_checks": ["Existing raw/processed file remains unchanged", "Top-level source remains in raw/", "wiki.py lint does not require a false completed ingest"], + "semantic_checks": ["Agent reports the collision and asks for resolution"] + }, + { + "id": "raw-source-archived-after-complete-ingest", + "title": "Source archive happens only after complete ingest", + "type": "behavioural-fixture", + "priority": "medium-term", + "description": "Ingest a source that should affect a source page, one concept page, the wiki index, and the root log.", + "deterministic_checks": ["Expected pages changed", "Source page points to raw/processed", "Source content hash is unchanged", "wiki.py lint passes"], + "semantic_checks": ["Summary distinguishes sourced claims from inference"] + } + ] + }, + { + "id": "wiki-page-schema", + "title": "Wiki page schema", + "owner": "knowledge-agent", + "workspaces": ["wiki"], + "summary": "Require content pages to use recognised frontmatter, page types, statuses, dates, tags, and source-specific metadata.", + "current_specification": ["AGENTS.md", "templates/knowledge-page.md", "templates/source.md", "templates/analysis.md"], + "current_enforcement": ["wiki.py lint"], + "severity": "medium", + "portability_class": "portable-domain", + "prompt_debt_signals": ["deterministic-check-present"], + "candidate_tests": [ + { + "id": "wiki-schema-invalid-status", + "title": "Invalid wiki status is rejected", + "type": "deterministic-unit", + "priority": "near-term", + "description": "Create a fixture page with an unsupported status.", + "deterministic_checks": ["wiki.py lint returns non-zero", "Error identifies invalid status"], + "semantic_checks": [] + } + ] + }, + { + "id": "wiki-ingestion-integration", + "title": "Wiki ingestion integration", + "owner": "knowledge-agent", + "workspaces": ["raw", "wiki", "log"], + "summary": "Integrate newly ingested evidence into materially affected pages rather than stopping at a standalone source summary.", + "current_specification": ["AGENTS.md"], + "current_enforcement": ["Human review", "wiki.py lint catches only structural consequences"], + "severity": "high", + "portability_class": "model-sensitive", + "prompt_debt_signals": ["semantic-judgement", "prose-only-control", "quality-regression-risk"], + "candidate_tests": [ + { + "id": "ingest-updates-multiple-existing-pages", + "title": "Ingest updates all clearly affected pages", + "type": "behavioural-fixture", + "priority": "near-term", + "description": "Use a mini vault where a source clearly affects two existing concept pages and a topic hub.", + "deterministic_checks": ["Source page created", "Expected existing pages changed", "wiki/index.md changed", "log.md changed", "wiki.py lint passes"], + "semantic_checks": ["Affected pages include the new evidence with appropriate citations", "No unrelated pages are rewritten"] + } + ] + }, + { + "id": "citation-provenance-quality", + "title": "Citation and provenance quality", + "owner": "knowledge-agent", + "workspaces": ["wiki", "raw"], + "summary": "Support specific, contestable, quantitative, or non-common-knowledge claims with nearby source citations and preserve raw-source provenance.", + "current_specification": ["AGENTS.md"], + "current_enforcement": ["wiki.py lint validates links and source_path existence", "No claim-level support checker"], + "severity": "high", + "portability_class": "model-sensitive", + "prompt_debt_signals": ["semantic-judgement", "partial-deterministic-check"], + "candidate_tests": [ + { + "id": "query-answer-cites-source-pages", + "title": "Query answer cites source pages and states gaps", + "type": "behavioural-fixture", + "priority": "near-term", + "description": "Ask a question whose answer requires two source-backed claims and one explicit uncertainty.", + "deterministic_checks": ["Answer contains wiki source links", "No broken links are introduced"], + "semantic_checks": ["Claims are cited near the relevant sentences", "Known evidence gaps are stated plainly"] + } + ] + }, + { + "id": "first-party-opinion-handling", + "title": "First-party opinion handling", + "owner": "knowledge-agent", + "workspaces": ["raw", "wiki"], + "summary": "Treat self-authored first-person sources as authoritative evidence of the user's views while not treating them as independent proof of external facts.", + "current_specification": ["AGENTS.md"], + "current_enforcement": ["Prose contract", "Human review"], + "severity": "medium", + "portability_class": "model-sensitive", + "prompt_debt_signals": ["semantic-judgement", "model-style-sensitivity", "prose-only-control"], + "candidate_tests": [ + { + "id": "opinion-source-not-weakened", + "title": "Self-authored opinion is not weakened with generic caveats", + "type": "behavioural-fixture", + "priority": "near-term", + "description": "Ingest a first-person source that states a preference and contains an embedded external example.", + "deterministic_checks": ["Source page and related concept page are updated", "wiki.py lint passes"], + "semantic_checks": ["The user's stated view is preserved confidently as first-party knowledge", "External claims are scoped rather than treated as independently proven"] + } + ] + }, + { + "id": "dtm-session-activation", + "title": "DTM session activation", + "owner": "dtm", + "workspaces": ["daily", "skills"], + "summary": "Start a persistent DTM session only when the user invokes $dtm or explicitly asks for a persistent DTM thread, then keep later turns in that role until ended or switched.", + "current_specification": ["AGENTS.md", "DTM.md", "skills/dtm/SKILL.md"], + "current_enforcement": ["Codex skill metadata", "Framework packaging tests"], + "severity": "high", + "portability_class": "harness-specific", + "prompt_debt_signals": ["harness-assumption", "session-state-dependency"], + "candidate_tests": [ + { + "id": "dtm-activation-creates-one-activity-entry", + "title": "DTM activation is explicit and idempotent", + "type": "behavioural-fixture", + "priority": "medium-term", + "description": "Invoke the DTM session twice in the same fixture day.", + "deterministic_checks": ["Daily Note exists", "Only one activation activity entry is present", "dtm.py lint passes"], + "semantic_checks": ["Agent reports activation without broad unrelated scanning"] + } + ] + }, + { + "id": "daily-note-lifecycle", + "title": "Daily Note lifecycle and recurrence", + "owner": "dtm", + "workspaces": ["daily", "dtm", "log"], + "summary": "Create, close, roll over, and lint Daily Notes with valid sections, recurrence rules, carried tasks, open questions, blockers, references, and activity entries.", + "current_specification": ["DTM.md", "templates/daily-note.md", "dtm/recurring-tasks.json"], + "current_enforcement": ["dtm.py open", "dtm.py close", "dtm.py rollover", "dtm.py lint", "Framework recurrence tests"], + "severity": "high", + "portability_class": "portable-domain", + "prompt_debt_signals": ["deterministic-check-present", "semantic-summary-gap"], + "candidate_tests": [ + { + "id": "invalid-recurrence-fails-before-rollover", + "title": "Invalid recurrence blocks rollover before mutation", + "type": "deterministic-unit", + "priority": "near-term", + "description": "Run rollover with an invalid recurrence rule.", + "deterministic_checks": ["dtm.py rollover returns an error", "Yesterday remains open", "Today is not created"], + "semantic_checks": [] + }, + { + "id": "focus-does-not-bundle-unrelated-items", + "title": "Focus items stay distinct", + "type": "deterministic-lint", + "priority": "medium-term", + "description": "Create a Daily Note with a bundled Focus item.", + "deterministic_checks": ["dtm.py lint flags the bundled Focus item"], + "semantic_checks": [] + } + ] + }, + { + "id": "work-project-indexes", + "title": "Work and project lifecycle indexes", + "owner": "dtm", + "workspaces": ["work", "projects"], + "summary": "Keep managed work notes and project pages listed exactly once under their current lifecycle status with concrete summaries.", + "current_specification": ["AGENTS.md", "DTM.md", "templates/working-note.md", "templates/project.md"], + "current_enforcement": ["workspaces.py write", "workspaces.py lint"], + "severity": "medium", + "portability_class": "portable-domain", + "prompt_debt_signals": ["deterministic-check-present", "publication-gap"], + "candidate_tests": [ + { + "id": "work-index-lists-new-note-once", + "title": "New work note appears once in Work Index", + "type": "deterministic-unit", + "priority": "near-term", + "description": "Create a fixture work note and regenerate the index.", + "deterministic_checks": ["workspaces.py write adds one index entry", "workspaces.py lint passes"], + "semantic_checks": [] + } + ] + }, + { + "id": "writing-document-lifecycle", + "title": "Writing and document lifecycle", + "owner": "shared", + "workspaces": ["writing", "documents"], + "summary": "Maintain draft, ready, published, archive, draft document, final document, reference, and deliverable lifecycle boundaries.", + "current_specification": ["AGENTS.md", "DTM.md", "writing/README.md", "documents/README.md", "templates/writing-piece.md", "templates/document-piece.md"], + "current_enforcement": ["writing.py lint", "documents.py lint"], + "severity": "high", + "portability_class": "mixed", + "prompt_debt_signals": ["partial-deterministic-check", "human-approval-boundary"], + "candidate_tests": [ + { + "id": "ready-promotion-requires-human-approval", + "title": "Writing ready promotion requires explicit approval", + "type": "behavioural-fixture", + "priority": "medium-term", + "description": "Ask an agent to improve a draft but do not approve it for publication.", + "deterministic_checks": ["File remains under writing/drafts/", "writing.py lint passes"], + "semantic_checks": ["Agent does not claim the piece is ready or move it to writing/ready/"] + } + ] + }, + { + "id": "voice-corpus-exclusion", + "title": "Voice corpus exclusion", + "owner": "dtm", + "workspaces": ["writing", "documents", "skills"], + "summary": "Learn voice packs only from approved writing and final internal documents, never from drafts, chats, Daily Notes, wiki pages, sources, or other vault content.", + "current_specification": ["AGENTS.md", "DTM.md", "skills/voice/SKILL.md", "templates/voice-pack.md"], + "current_enforcement": ["Codex skill metadata", "Framework packaging tests", "writing.py lint checks voice evidence links do not point at drafts"], + "severity": "high", + "portability_class": "harness-sensitive", + "prompt_debt_signals": ["repeated-prohibition", "privacy-risk", "partial-deterministic-check"], + "candidate_tests": [ + { + "id": "voice-analysis-ignores-drafts", + "title": "Voice analysis ignores tempting draft material", + "type": "behavioural-fixture", + "priority": "near-term", + "description": "Provide approved prose plus stronger tempting prose in drafts.", + "deterministic_checks": ["Voice pack evidence links exclude drafts", "writing.py lint passes"], + "semantic_checks": ["Learned style reflects approved corpus rather than draft traps"] + } + ] + }, + { + "id": "publication-privacy-boundary", + "title": "Publication privacy boundary", + "owner": "publication", + "workspaces": ["publication", "tools"], + "summary": "Publish only allowlisted framework artefacts through a fail-closed export path, with personal-content deny rules and privacy scanning before any public repository update.", + "current_specification": ["AGENTS.md", "publication/manifest.json", "publication/repository/docs/publication.md"], + "current_enforcement": ["export_framework.py", "publish_framework.py", "Framework CI"], + "severity": "critical", + "portability_class": "portable-domain", + "prompt_debt_signals": ["deterministic-check-present"], + "candidate_tests": [ + { + "id": "publication-rejects-personal-identifier", + "title": "Publication export rejects personal identifiers", + "type": "deterministic-unit", + "priority": "near-term", + "description": "Stage an otherwise allowlisted file containing a forbidden personal identifier.", + "deterministic_checks": ["export_framework.py returns non-zero", "No public checkout is modified"], + "semantic_checks": [] + } + ] + }, + { + "id": "automation-prompts", + "title": "Automation prompt behaviour", + "owner": "shared", + "workspaces": ["automation-definitions", "daily", "writing", "publication"], + "summary": "Scheduled jobs should run bounded, idempotent workflows whose side effects are validated and whose host-specific configuration stays outside the reusable framework.", + "current_specification": ["automation-definitions/*.json", "AGENTS.md", "DTM.md", "publication/repository/docs/operations.md"], + "current_enforcement": ["Host scheduler", "Underlying lint and publication tools", "Prose prompts"], + "severity": "high", + "portability_class": "harness-specific", + "prompt_debt_signals": ["dense-prompt", "harness-assumption", "partial-deterministic-check"], + "candidate_tests": [ + { + "id": "automation-no-op-does-not-create-pr", + "title": "No-change framework publication does not create a PR", + "type": "deterministic-integration", + "priority": "medium-term", + "description": "Run publication flow against a target with the same framework fingerprint.", + "deterministic_checks": ["No branch is created", "No commit is created", "No PR command is called"], + "semantic_checks": [] + } + ] + }, + { + "id": "harness-adapters", + "title": "Harness adapter assumptions", + "owner": "shared", + "workspaces": ["skills", "automation-definitions", "contracts"], + "summary": "Keep platform-specific instruction loading, tool permissions, session state, hooks, actions, and automation semantics isolated from the common SecondBrain behavioural core.", + "current_specification": ["AGENTS.md", "skills/*/agents/openai.yaml", "automation-definitions/*.json"], + "current_enforcement": ["Codex-specific metadata", "No platform-neutral adapter tests yet"], + "severity": "high", + "portability_class": "harness-specific", + "prompt_debt_signals": ["harness-assumption", "portability-risk", "missing-fixtures"], + "candidate_tests": [ + { + "id": "adapter-capability-matrix-generated", + "title": "Harness adapter capability matrix is explicit", + "type": "contract-lint", + "priority": "medium-term", + "description": "Validate that every high-severity behaviour has an adapter support classification for each targeted harness.", + "deterministic_checks": ["contracts.py lint rejects missing targeted-harness coverage once adapters are introduced"], + "semantic_checks": [] + } + ] + }, + { + "id": "storage-boundary", + "title": "Obsidian vault storage boundary", + "owner": "shared", + "workspaces": ["root", "contracts"], + "summary": "Treat Obsidian as a core framework dependency while keeping the storage and sync boundary configurable: local-only, OneDrive, SharePoint, Obsidian Sync, iCloud, or another user-chosen storage model.", + "current_specification": ["publication/repository/docs/architecture.md", "work/secondbrain-prompt-debt-and-portability-spike-2026-09-02.md"], + "current_enforcement": ["Prose documentation", "Publication tools protect only the live-vault Git/privacy boundary"], + "severity": "medium", + "portability_class": "tool-api-specific", + "prompt_debt_signals": ["environment-assumption", "adapter-required"], + "candidate_tests": [ + { + "id": "storage-model-declared", + "title": "Installed vault declares storage model before harness-specific automation", + "type": "contract-lint", + "priority": "future", + "description": "Require a storage-boundary declaration before enabling a harness adapter that performs writes.", + "deterministic_checks": ["contracts.py or adapter lint rejects write-enabled adapters with no storage declaration"], + "semantic_checks": [] + } + ] + } + ] +} diff --git a/framework/publication-manifest.json b/framework/publication-manifest.json index 111c8be..b14bcb0 100644 --- a/framework/publication-manifest.json +++ b/framework/publication-manifest.json @@ -6,8 +6,10 @@ {"source": "DTM.md", "destination": "framework/DTM.md"}, {"source": "documents/README.md", "destination": "framework/defaults/documents/README.md"}, {"source": "templates", "destination": "framework/templates"}, + {"source": "contracts", "destination": "framework/contracts"}, {"source": "tools/wiki.py", "destination": "framework/tools/wiki.py"}, {"source": "tools/dtm.py", "destination": "framework/tools/dtm.py"}, + {"source": "tools/contracts.py", "destination": "framework/tools/contracts.py"}, {"source": "tools/documents.py", "destination": "framework/tools/documents.py"}, {"source": "tools/document_deliverables.py", "destination": "framework/tools/document_deliverables.py"}, {"source": "tools/decks.py", "destination": "framework/tools/decks.py"}, diff --git a/framework/skills/dtm-handoff/SKILL.md b/framework/skills/dtm-handoff/SKILL.md new file mode 100644 index 0000000..3c33f2f --- /dev/null +++ b/framework/skills/dtm-handoff/SKILL.md @@ -0,0 +1,96 @@ +--- +name: dtm-handoff +description: Hand completed work from a non-DTM SecondBrain thread to the active DTM thread for Daily Note capture without modifying the Daily Note directly. +--- + +# DTM Hand-Off + +Use this skill when the current thread is not an explicitly DTM-activated +thread and the user wants completed work, progress, decisions, blockers, or +follow-up items passed back to the active Daily Note manager. + +## Hard boundaries + +- Do not create, edit, append to, reorder, or otherwise modify `daily/` files. +- Do not run `python3 tools/dtm.py open`, `python3 tools/dtm.py activity`, or + any direct Daily Note write command. +- Do not activate DTM in the current thread. This skill is a hand-off mechanism, + not a DTM session. +- Do not create a DTM thread automatically. If the active DTM thread cannot be + found, stop and tell the user. + +## Find the active DTM thread + +1. Use the available Codex thread-management tools. If they are not already + loaded, first search for the thread tools, especially `list_threads` and + `send_message_to_thread`. +2. Search recent threads for the exact current-day title `DTM YYYY-MM-DD`, using + the user's local date for `YYYY-MM-DD`. +3. Choose only a live, accessible Codex thread whose title exactly matches that + current-day DTM title. If there is no exact match, or there are multiple + plausible matches that cannot be disambiguated safely, stop and explain the + problem to the user. + +## Build the hand-off + +Summarise outcomes rather than the chat transcript. Include only information +that the DTM thread may need to update the Daily Note accurately: + +- tasks completed; +- progress against existing tasks; +- new tasks identified; +- blockers or dependencies discovered; +- decisions made; +- significant findings or conclusions; +- important context worth retaining; +- follow-up work required; +- artefacts created or changed, with paths or links where relevant; +- suggested Daily Note capture points, clearly labelled as suggestions. + +Keep the hand-off concise but operationally complete. Mention verification +performed and any verification that could not be run. + +## Incremental checkpoint + +On the first successful invocation in a work thread, hand off all relevant work +completed in that thread so far. + +On later invocations in the same thread, hand off only new or materially changed +information since the previous successful hand-off. Use the last successful +hand-off message in this thread as the checkpoint. After `send_message_to_thread` +succeeds, preserve a compact checkpoint in the originating thread's response or +thread summary context containing: + +- the DTM thread title and thread id used; +- the successful hand-off time; +- the exact scope covered by the hand-off; +- any material items intentionally not handed off. + +If locating the DTM thread fails, sending the message fails, or the hand-off is +ambiguous enough that you stop for user input, do not advance the checkpoint. + +## Send the hand-off + +Send a single follow-up prompt to the DTM thread. Use this shape: + +```markdown +DTM hand-off from a non-DTM work thread. + +Originating thread: +Coverage: + +Outcomes: +- ... + +Daily Note implications for you to assess: +- ... + +Verification: +- ... + +Open points: +- ... +``` + +End the current thread's response by stating whether the hand-off was sent. Do +not claim the Daily Note was updated; the receiving DTM thread owns that step. diff --git a/framework/skills/dtm-handoff/agents/openai.yaml b/framework/skills/dtm-handoff/agents/openai.yaml new file mode 100644 index 0000000..cadd8e3 --- /dev/null +++ b/framework/skills/dtm-handoff/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "DTM Hand-Off" + short_description: "Send completed work to today's active DTM thread" diff --git a/framework/skills/dtm/SKILL.md b/framework/skills/dtm/SKILL.md index 57af72e..1ef69a6 100644 --- a/framework/skills/dtm/SKILL.md +++ b/framework/skills/dtm/SKILL.md @@ -15,13 +15,15 @@ describe the role. 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 +4. Set or confirm the current thread title as `DTM YYYY-MM-DD`, using the user's + local date. This title is the discovery point for non-DTM hand-offs. +5. 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 +6. 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 +7. 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 +8. Acknowledge activation briefly and surface today's current focus, outstanding tasks, or open questions only when useful. ## Operate the session @@ -31,12 +33,25 @@ describe the role. - Maintain today's Daily Note, project state, decisions, follow-ups, references, and open questions as the work evolves. Capture outcomes rather than chat transcripts. +- This explicit activation is what grants Daily Note write authority to the + current thread. Threads that have not completed this activation must not edit + `daily/`; they should use the DTM hand-off skill when they need Daily Note + capture. - 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. +## Receive hand-offs + +When a work thread sends a DTM hand-off, treat the message as an input to the +active DTM session. Decide what belongs in today's Daily Note using `DTM.md`; +update task checkboxes, blockers, decisions, references, open questions, project +or working notes, and `Notes & Activity` only where the hand-off gives enough +evidence. Ask a concise follow-up only when the hand-off is insufficient to make +a correct Daily Note update. + ## End or switch End persistent DTM mode only when the user says `$end-dtm`, “end the DTM diff --git a/framework/tools/contracts.py b/framework/tools/contracts.py new file mode 100755 index 0000000..6a3451f --- /dev/null +++ b/framework/tools/contracts.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Validate machine-readable SecondBrain behavioural contracts.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CONTRACTS = ROOT / "contracts" +INVENTORY = CONTRACTS / "behaviour-inventory.json" + +REQUIRED_TOP_LEVEL = {"version", "updated", "description", "behaviours"} +REQUIRED_BEHAVIOUR_FIELDS = { + "id", + "title", + "owner", + "workspaces", + "summary", + "current_specification", + "current_enforcement", + "severity", + "portability_class", + "prompt_debt_signals", + "candidate_tests", +} +REQUIRED_TEST_FIELDS = { + "id", + "title", + "type", + "priority", + "description", + "deterministic_checks", + "semantic_checks", +} +OWNERS = { + "knowledge-agent", + "dtm", + "shared", + "publication", +} +SEVERITIES = {"low", "medium", "high", "critical"} +PORTABILITY_CLASSES = { + "portable-domain", + "model-sensitive", + "harness-sensitive", + "harness-specific", + "tool-api-specific", + "mixed", +} +TEST_PRIORITIES = {"near-term", "medium-term", "future"} +TEST_TYPES = { + "behavioural-fixture", + "contract-lint", + "deterministic-integration", + "deterministic-lint", + "deterministic-unit", +} +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def load_inventory(path: Path = INVENTORY) -> dict[str, object]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"{relative(path)}: {exc}") from exc + + +def relative(path: Path) -> str: + try: + return path.relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def require_fields(value: dict[str, object], required: set[str], label: str, errors: list[str]) -> None: + missing = required - value.keys() + if missing: + errors.append(f"{label}: missing fields {', '.join(sorted(missing))}") + + +def require_string(value: object, label: str, errors: list[str]) -> str: + if not isinstance(value, str) or not value.strip(): + errors.append(f"{label}: must be a non-empty string") + return "" + return value + + +def require_string_list(value: object, label: str, errors: list[str], *, allow_empty: bool = False) -> list[str]: + if not isinstance(value, list): + errors.append(f"{label}: must be a list") + return [] + if not value and not allow_empty: + errors.append(f"{label}: must not be empty") + return [] + result: list[str] = [] + for index, item in enumerate(value, start=1): + if not isinstance(item, str) or not item.strip(): + errors.append(f"{label}[{index}]: must be a non-empty string") + else: + result.append(item) + return result + + +def validate_candidate_test(value: object, behaviour_id: str, errors: list[str]) -> str: + label = f"{behaviour_id}.candidate_tests" + if not isinstance(value, dict): + errors.append(f"{label}: entries must be objects") + return "" + require_fields(value, REQUIRED_TEST_FIELDS, label, errors) + test_id = require_string(value.get("id"), f"{label}.id", errors) + if test_id and not SLUG_RE.fullmatch(test_id): + errors.append(f"{label}.{test_id}: id must use lowercase kebab-case") + test_type = require_string(value.get("type"), f"{label}.{test_id}.type", errors) + if test_type and test_type not in TEST_TYPES: + errors.append(f"{label}.{test_id}: unsupported type {test_type!r}") + priority = require_string(value.get("priority"), f"{label}.{test_id}.priority", errors) + if priority and priority not in TEST_PRIORITIES: + errors.append(f"{label}.{test_id}: unsupported priority {priority!r}") + for field in ("title", "description"): + require_string(value.get(field), f"{label}.{test_id}.{field}", errors) + require_string_list(value.get("deterministic_checks"), f"{label}.{test_id}.deterministic_checks", errors, allow_empty=True) + require_string_list(value.get("semantic_checks"), f"{label}.{test_id}.semantic_checks", errors, allow_empty=True) + return test_id + + +def validate_inventory(data: dict[str, object]) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + require_fields(data, REQUIRED_TOP_LEVEL, "behaviour-inventory.json", errors) + if data.get("version") != 1: + errors.append("behaviour-inventory.json: version must be 1") + updated = require_string(data.get("updated"), "behaviour-inventory.json.updated", errors) + if updated and not DATE_RE.fullmatch(updated): + errors.append("behaviour-inventory.json.updated: must be YYYY-MM-DD") + require_string(data.get("description"), "behaviour-inventory.json.description", errors) + + behaviours = data.get("behaviours") + if not isinstance(behaviours, list) or not behaviours: + errors.append("behaviour-inventory.json.behaviours: must be a non-empty list") + return errors, warnings + + behaviour_ids: list[str] = [] + test_ids: list[str] = [] + for position, behaviour in enumerate(behaviours, start=1): + label = f"behaviour {position}" + if not isinstance(behaviour, dict): + errors.append(f"{label}: must be an object") + continue + require_fields(behaviour, REQUIRED_BEHAVIOUR_FIELDS, label, errors) + behaviour_id = require_string(behaviour.get("id"), f"{label}.id", errors) + if behaviour_id: + if not SLUG_RE.fullmatch(behaviour_id): + errors.append(f"{label}: id must use lowercase kebab-case") + behaviour_ids.append(behaviour_id) + label = behaviour_id + for field in ("title", "summary"): + require_string(behaviour.get(field), f"{label}.{field}", errors) + owner = require_string(behaviour.get("owner"), f"{label}.owner", errors) + if owner and owner not in OWNERS: + errors.append(f"{label}: unsupported owner {owner!r}") + severity = require_string(behaviour.get("severity"), f"{label}.severity", errors) + if severity and severity not in SEVERITIES: + errors.append(f"{label}: unsupported severity {severity!r}") + portability = require_string(behaviour.get("portability_class"), f"{label}.portability_class", errors) + if portability and portability not in PORTABILITY_CLASSES: + errors.append(f"{label}: unsupported portability_class {portability!r}") + for field in ("workspaces", "current_specification", "current_enforcement", "prompt_debt_signals"): + require_string_list(behaviour.get(field), f"{label}.{field}", errors) + + candidate_tests = behaviour.get("candidate_tests") + if not isinstance(candidate_tests, list) or not candidate_tests: + errors.append(f"{label}.candidate_tests: must be a non-empty list") + continue + for candidate in candidate_tests: + test_id = validate_candidate_test(candidate, label, errors) + if test_id: + test_ids.append(test_id) + + for item, count in Counter(behaviour_ids).items(): + if count > 1: + errors.append(f"duplicate behaviour id: {item}") + for item, count in Counter(test_ids).items(): + if count > 1: + errors.append(f"duplicate candidate test id: {item}") + + return sorted(set(errors)), sorted(set(warnings)) + + +def lint() -> int: + try: + inventory = load_inventory() + except ValueError as exc: + print(f"ERROR {exc}") + return 1 + errors, warnings = validate_inventory(inventory) + for item in errors: + print(f"ERROR {item}") + for item in warnings: + print(f"WARNING {item}") + behaviours = inventory.get("behaviours", []) + count = len(behaviours) if isinstance(behaviours, list) else 0 + print(f"Checked {count} behaviour contract(s): {len(errors)} error(s), {len(warnings)} warning(s).") + return 1 if errors else 0 + + +def status() -> int: + try: + inventory = load_inventory() + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + behaviours = inventory.get("behaviours", []) + if not isinstance(behaviours, list): + print("No valid behaviour inventory.") + return 1 + by_severity = Counter(str(item.get("severity")) for item in behaviours if isinstance(item, dict)) + by_portability = Counter(str(item.get("portability_class")) for item in behaviours if isinstance(item, dict)) + print(f"Behaviour contracts: {len(behaviours)}") + print("By severity: " + ", ".join(f"{key}={by_severity[key]}" for key in sorted(by_severity))) + print("By portability: " + ", ".join(f"{key}={by_portability[key]}" for key in sorted(by_portability))) + 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() + if args.command == "lint": + return lint() + return status() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/framework/tools/publish_framework.py b/framework/tools/publish_framework.py index 4c8f54f..f127bf3 100644 --- a/framework/tools/publish_framework.py +++ b/framework/tools/publish_framework.py @@ -81,6 +81,7 @@ def validate_export(target: Path) -> None: with tempfile.TemporaryDirectory(prefix="secondbrain-install-") as temporary: vault = Path(temporary) / "vault" run([sys.executable, str(target / "install.py"), "--vault", str(vault)]) + run([sys.executable, str(vault / "tools" / "contracts.py"), "lint"]) run([sys.executable, str(vault / "tools" / "wiki.py"), "lint"]) run([sys.executable, str(vault / "tools" / "dtm.py"), "lint"]) diff --git a/install.py b/install.py index 81db403..367ceeb 100644 --- a/install.py +++ b/install.py @@ -39,6 +39,7 @@ def install(vault: Path, force: bool, dry_run: bool) -> int: ] for folder, target in ( (FRAMEWORK / "templates", vault / "templates"), + (FRAMEWORK / "contracts", vault / "contracts"), (FRAMEWORK / "tools", vault / "tools"), (FRAMEWORK / "obsidian", vault / ".obsidian"), (FRAMEWORK / "defaults" / "dtm", vault / "dtm"), diff --git a/tests/test_framework.py b/tests/test_framework.py index 474bc15..f5103eb 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -20,6 +20,15 @@ def load_dtm_module(): return module +def load_contracts_module(): + path = ROOT / "framework" / "tools" / "contracts.py" + spec = importlib.util.spec_from_file_location("secondbrain_contracts", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + class ObsidianConfigurationTests(unittest.TestCase): def test_templates_and_daily_notes_match_installed_layout(self): obsidian = ROOT / "framework" / "obsidian" @@ -53,6 +62,24 @@ def test_keep_a_changelog_sections_are_nested_under_dates(self): ) +class BehaviourContractTests(unittest.TestCase): + def test_behaviour_inventory_is_valid_and_covers_core_boundaries(self): + contracts = load_contracts_module() + inventory = json.loads( + (ROOT / "framework" / "contracts" / "behaviour-inventory.json").read_text(encoding="utf-8") + ) + errors, warnings = contracts.validate_inventory(inventory) + behaviours = {item["id"]: item for item in inventory["behaviours"]} + + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + self.assertIn("role-routing", behaviours) + self.assertIn("raw-source-lifecycle", behaviours) + self.assertIn("publication-privacy-boundary", behaviours) + self.assertIn("harness-adapters", behaviours) + self.assertEqual(behaviours["publication-privacy-boundary"]["severity"], "critical") + + class SkillPackagingTests(unittest.TestCase): def test_dtm_skill_is_explicit_and_thread_scoped(self): skill = ROOT / "framework" / "skills" / "dtm" @@ -61,9 +88,23 @@ def test_dtm_skill_is_explicit_and_thread_scoped(self): self.assertIn("name: dtm", instructions) self.assertIn("thread-scoped", instructions) + self.assertIn("DTM YYYY-MM-DD", instructions) + self.assertIn("Daily Note write authority", instructions) self.assertIn("$end-dtm", instructions) self.assertIn("allow_implicit_invocation: false", metadata) + def test_dtm_handoff_skill_targets_active_dtm_thread(self): + skill = ROOT / "framework" / "skills" / "dtm-handoff" + instructions = (skill / "SKILL.md").read_text(encoding="utf-8") + metadata = (skill / "agents" / "openai.yaml").read_text(encoding="utf-8") + + self.assertIn("name: dtm-handoff", instructions) + self.assertIn("DTM YYYY-MM-DD", instructions) + self.assertIn("Do not create, edit, append to, reorder, or otherwise modify `daily/`", instructions) + self.assertIn("send_message_to_thread", instructions) + self.assertIn("Incremental checkpoint", instructions) + self.assertIn("DTM Hand-Off", 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") From c7acfb8e3d5700180cf7b05c49131a20ea58cbae Mon Sep 17 00:00:00 2001 From: Phil Budden <13167147+philbudden@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:49:00 +0100 Subject: [PATCH 2/2] Updated CHANGELOG with all changes in this release --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2431f44..c3bbfb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ for tagged releases. No unreleased changes. +## [2026-09-08] + +### Added + +- A versioned behaviour-contract inventory and dependency-free validator covering role boundaries, source and content lifecycles, privacy, automation, storage, and harness assumptions. + +### Changed + +- Framework installation, export validation, CI, and regression tests now include the behaviour-contract inventory so contract drift is detected before release. +- DTM coordination now uses a discoverable `DTM YYYY-MM-DD` session title and explicitly reserves Daily Note writes for activated DTM threads; non-DTM work is handed back for DTM interpretation and capture. +- Installation, architecture, and operations guidance now document the contract checks, DTM hand-off path, and support for non-iCloud vault storage providers. + ## [2026-08-31] ### Added