diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce5d65..5253f78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,19 @@ for tagged releases. ## [Unreleased] -No unreleased changes. +### Changed + +- DTM task guidance treats normal sharing, sending, handover, and publication as part of completing work unless a separate follow-up is requested. + +## [2026-09-29] + +### Added + +- Provider-neutral `no-llm`, `routine`, `balanced`, and `strong` task profiles, plus versioned task and completion envelopes with dependency-free packet validation. The contracts retain the existing DTM hand-off and Daily Note authority boundary without selecting models or dispatching workers. + +### Changed + +- Daily Note guidance now classifies tasks by whether they are work for the user's employer, while treating extracurricular work as personal and using contextual judgement for ambiguous cases. ## [2026-09-21] diff --git a/docs/architecture.md b/docs/architecture.md index 5f9d725..ea01940 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,16 @@ 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. +`contracts/task-profiles.json` defines the provider-neutral `no-llm`, +`routine`, `balanced`, and `strong` capability profiles. A task packet declares +the task class, context and risk level, allowed paths, write authority, tools, +validation, acceptance checks, and escalation conditions. Its completion packet +records outcomes, artefacts, verification, uncertainty, blockers, follow-up, +and Daily Note implications. `tools/task_envelopes.py` validates both contracts +and individual packets. These contracts do not select a model or dispatch work; +harness adapters retain that responsibility, and non-DTM hand-offs retain the +existing Daily Note boundary. + ## Role boundary The Knowledge Agent owns systematic source ingestion and wiki maintenance. An diff --git a/docs/operations.md b/docs/operations.md index 1d6ec89..f16bc56 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -15,6 +15,11 @@ captures decisions, and maintains project continuity. Durable knowledge can graduate to the wiki while remaining clearly distinguished from externally sourced evidence. +Completing work includes its normal delivery: sharing, sending, handing over, +or publishing an output does not need a separate follow-up task unless the user +asks for one. When a hand-off confirms delivery is complete, do not create or +carry a delivery task; correct any stale Daily Note entry. + 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 diff --git a/framework/DTM.md b/framework/DTM.md index 9bfd287..8b38763 100644 --- a/framework/DTM.md +++ b/framework/DTM.md @@ -278,10 +278,16 @@ references according to the normal Daily Note rules. ## Tasks - Put personal and professional tasks in their respective Daily Note sections. +- Classify Daily Note tasks by whether they are Codi work: **professional** means work for Codi, and **personal** means work outside the day job. Extracurricular work can look professional but remains personal when it is outside Codi duties; an internal Codi adaptation and the Housing Technology presentation are professional, while the separate 45-minute Practical Delegation Test talk is personal. Use context and best judgement for work that blurs the boundary, and accept the user's correction. - Use Obsidian task syntax: `- [ ] Action` and `- [x] Completed action`. - Treat the Daily Note `Focus` section as the morning commitment for the day, not a live next-action list. After the day has started, do not rewrite `Focus` to reflect progress, newly discovered details, or the next task unless the user explicitly asks to correct the focus wording itself. Track completion and changing operational state in to-do items, `Notes & Activity`, and linked project or working notes. - Make tasks concrete and outcome-oriented. Link the relevant project when one exists. +- Treat normal delivery of an output as part of completing its task: sharing, + sending, handing over, or publishing the result does not need a separate + follow-up task unless the user explicitly asks to track that action. When a + completed-work hand-off confirms that delivery has already happened, do not + create or carry a delivery task; correct any stale Daily Note entry instead. - 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. @@ -333,8 +339,7 @@ entry. ## References and questions -- `References` contains only documents created or updated by the DTM that day. - Do not list Knowledge-Agent-only changes. +- `References` contains documents created or updated by the DTM that day, including documents created in delegated work threads and handed off to the DTM. Do not list Knowledge-Agent-only changes. - Add a reference once, with a short description of the change. - `Open Questions` contains active uncertainties as plain bullets. Carry them forward until resolved. When answered, remove the question from the current diff --git a/framework/contracts/behaviour-inventory.json b/framework/contracts/behaviour-inventory.json index e7bc8e7..6085fc2 100644 --- a/framework/contracts/behaviour-inventory.json +++ b/framework/contracts/behaviour-inventory.json @@ -1,6 +1,6 @@ { "version": 1, - "updated": "2026-09-21", + "updated": "2026-09-29", "description": "Machine-readable inventory of SecondBrain behaviours that should be tracked as explicit contracts, deterministic checks, fixtures, or harness-specific adapters.", "behaviours": [ { @@ -343,6 +343,29 @@ } ] }, + { + "id": "task-profiles-and-completion-envelopes", + "title": "Provider-neutral task profiles and completion envelopes", + "owner": "shared", + "workspaces": ["contracts", "tools", "skills"], + "summary": "Classify bounded work by provider-neutral capability profile and require an explicit task packet and completion packet before any future routing or automated dispatch.", + "current_specification": ["contracts/task-profiles.json", "contracts/task-envelope-contract.json", "skills/dtm-handoff/SKILL.md"], + "current_enforcement": ["tools/task_envelopes.py lint", "tools/task_envelopes.py validate-task", "tools/task_envelopes.py validate-completion", "Unit tests"], + "severity": "high", + "portability_class": "portable-domain", + "prompt_debt_signals": ["model-name-coupling", "implicit-handoff", "routing-judgement"], + "candidate_tests": [ + { + "id": "task-envelope-authority-validation", + "title": "Task packets enforce declared profile and path authority", + "type": "deterministic-unit", + "priority": "near-term", + "description": "Validate a bounded routine task and reject a non-DTM packet that declares Daily Note paths.", + "deterministic_checks": ["Declared capability profile exists", "Path is vault-relative", "daily/** requires dtm-session-only authority", "Completion packet carries DTM hand-off fields"], + "semantic_checks": ["The router selects a profile only after the later dry-run routing experiment"] + } + ] + }, { "id": "harness-adapters", "title": "Harness adapter assumptions", diff --git a/framework/contracts/task-envelope-contract.json b/framework/contracts/task-envelope-contract.json new file mode 100644 index 0000000..11536c4 --- /dev/null +++ b/framework/contracts/task-envelope-contract.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "updated": "2026-09-29", + "description": "Versioned provider-neutral task and completion envelope contract. It defines bounded work and return information; it does not dispatch workers, select models, or grant filesystem authority.", + "task_packet": { + "required_fields": ["version", "task_id", "objective", "task_class", "capability_profile", "context_volume", "risk", "allowed_paths", "write_authority", "required_tools", "validation", "acceptance_criteria", "escalate_when", "return_schema"], + "context_volume_values": ["none", "small", "bounded", "large"], + "risk_values": ["low", "moderate", "high", "consequential"], + "write_authority_values": ["none", "declared-paths", "dtm-session-only"], + "return_schema_values": ["completion-packet-v1"], + "path_rules": ["Paths are vault-relative, non-empty, and may not contain '..'.", "daily/** requires write_authority 'dtm-session-only'.", "A task with write_authority 'none' must not declare writable paths."], + "manual_transport": "A non-DTM worker uses the DTM Hand-Off skill to transport a completion packet to the active DTM thread; the packet does not authorise a Daily Note write." + }, + "completion_packet": { + "required_fields": ["version", "task_id", "status", "outcomes", "artifacts", "verification", "uncertainty", "blockers", "follow_up", "daily_note_implications"], + "status_values": ["completed", "partial", "blocked", "failed"], + "dtm_handoff_projection": { + "outcomes": "Outcomes", + "daily_note_implications": "Daily Note implications for you to assess", + "verification": "Verification", + "blockers": "Open points" + }, + "manual_transport": "When hand-off is required, send one concise DTM Hand-Off containing these fields and preserve the originating-thread checkpoint after delivery." + } +} diff --git a/framework/contracts/task-profiles.json b/framework/contracts/task-profiles.json new file mode 100644 index 0000000..775511f --- /dev/null +++ b/framework/contracts/task-profiles.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "updated": "2026-09-29", + "description": "Provider-neutral capability profiles for SecondBrain task routing. Profile names describe the capability needed; harness adapters may map them to current models and reasoning settings.", + "profiles": [ + { + "id": "no-llm", + "title": "No language model", + "description": "A deterministic command, transformation, or inspection can complete the request without semantic judgement.", + "semantic_judgement": "none", + "context_volume": "none", + "risk_ceiling": "low", + "escalate_when": ["The request needs interpretation, synthesis, or ambiguous policy judgement."] + }, + { + "id": "routine", + "title": "Routine", + "description": "A bounded, low-ambiguity task with clear inputs, outputs, authority, and inexpensive validation.", + "semantic_judgement": "limited", + "context_volume": "small", + "risk_ceiling": "moderate", + "escalate_when": ["Required context is unavailable or exceeds the packet.", "The task reveals material ambiguity, consequential risk, or a missing acceptance check."] + }, + { + "id": "balanced", + "title": "Balanced", + "description": "Bounded implementation, analysis, or synthesis that needs considered judgement but has explicit ownership and acceptance checks.", + "semantic_judgement": "moderate", + "context_volume": "bounded", + "risk_ceiling": "high", + "escalate_when": ["The work becomes cross-domain, materially ambiguous, or consequential beyond the declared acceptance checks.", "A required validator, authority, or source is missing."] + }, + { + "id": "strong", + "title": "Strong", + "description": "Difficult synthesis, consequential ambiguity, or high-value judgement where lower profiles would create avoidable correction or escalation risk.", + "semantic_judgement": "high", + "context_volume": "large", + "risk_ceiling": "consequential", + "escalate_when": ["The user must make a decision outside the packet's delegated authority.", "Evidence is insufficient for a consequential recommendation."] + } + ] +} diff --git a/framework/defaults/dtm/recurring-tasks.json b/framework/defaults/dtm/recurring-tasks.json index 32521a4..848d166 100644 --- a/framework/defaults/dtm/recurring-tasks.json +++ b/framework/defaults/dtm/recurring-tasks.json @@ -35,7 +35,7 @@ "id": "monday-review-wiki-blog-post", "task": "Review the generated weekly wiki blog draft", "area": "personal", - "enabled": true, + "enabled": false, "schedule": { "frequency": "weekly", "weekdays": ["Monday"] diff --git a/framework/publication-manifest.json b/framework/publication-manifest.json index 815b0e9..52c055e 100644 --- a/framework/publication-manifest.json +++ b/framework/publication-manifest.json @@ -12,6 +12,7 @@ {"source": "tools/dtm.py", "destination": "framework/tools/dtm.py"}, {"source": "tools/workspaces.py", "destination": "framework/tools/workspaces.py"}, {"source": "tools/contracts.py", "destination": "framework/tools/contracts.py"}, + {"source": "tools/task_envelopes.py", "destination": "framework/tools/task_envelopes.py"}, {"source": "tools/behavioural_fixtures.py", "destination": "framework/tools/behavioural_fixtures.py"}, {"source": "tools/documents.py", "destination": "framework/tools/documents.py"}, {"source": "tools/document_deliverables.py", "destination": "framework/tools/document_deliverables.py"}, diff --git a/framework/tools/task_envelopes.py b/framework/tools/task_envelopes.py new file mode 100644 index 0000000..70dc963 --- /dev/null +++ b/framework/tools/task_envelopes.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Validate SecondBrain's provider-neutral task-profile and envelope contracts.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +CONTRACTS = ROOT / "contracts" +PROFILES = CONTRACTS / "task-profiles.json" +ENVELOPES = CONTRACTS / "task-envelope-contract.json" +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +PATH_RE = re.compile(r"^(?:[A-Za-z0-9._-]+|\*\*)(?:/(?:[A-Za-z0-9._-]+|\*\*))*$") + + +def load_json(path: Path) -> dict[str, object]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"{path.relative_to(ROOT)}: {exc}") from exc + if not isinstance(value, dict): + raise ValueError(f"{path.relative_to(ROOT)}: top-level value must be an object") + return value + + +def string_list(value: object, label: str, errors: list[str], *, allow_empty: bool = False) -> list[str]: + if not isinstance(value, list) or (not value and not allow_empty): + errors.append(f"{label}: must be a {'possibly empty ' if allow_empty else 'non-empty '}list") + return [] + if not all(isinstance(item, str) and item.strip() for item in value): + errors.append(f"{label}: entries must be non-empty strings") + return [] + return list(value) + + +def lint_contracts() -> list[str]: + errors: list[str] = [] + try: + profiles, envelopes = load_json(PROFILES), load_json(ENVELOPES) + except ValueError as exc: + return [str(exc)] + if profiles.get("version") != 1: + errors.append("task-profiles.json: version must be 1") + profile_rows = profiles.get("profiles") + if not isinstance(profile_rows, list) or not profile_rows: + errors.append("task-profiles.json: profiles must be a non-empty list") + profile_rows = [] + profile_ids: set[str] = set() + expected_profiles = {"no-llm", "routine", "balanced", "strong"} + for row in profile_rows: + if not isinstance(row, dict): + errors.append("task-profiles.json: profile entries must be objects") + continue + profile_id = row.get("id") + if not isinstance(profile_id, str) or not SLUG_RE.fullmatch(profile_id): + errors.append("task-profiles.json: profile id must be lowercase kebab-case") + continue + if profile_id in profile_ids: + errors.append(f"task-profiles.json: duplicate profile id {profile_id}") + profile_ids.add(profile_id) + for field in ("title", "description", "semantic_judgement", "context_volume", "risk_ceiling"): + if not isinstance(row.get(field), str) or not row[field].strip(): + errors.append(f"task-profiles.json: {profile_id}.{field} must be a non-empty string") + string_list(row.get("escalate_when"), f"task-profiles.json: {profile_id}.escalate_when", errors) + if profile_ids != expected_profiles: + errors.append("task-profiles.json: profiles must be exactly no-llm, routine, balanced, strong") + + if envelopes.get("version") != 1: + errors.append("task-envelope-contract.json: version must be 1") + for packet_name in ("task_packet", "completion_packet"): + packet = envelopes.get(packet_name) + if not isinstance(packet, dict): + errors.append(f"task-envelope-contract.json: {packet_name} must be an object") + continue + string_list(packet.get("required_fields"), f"task-envelope-contract.json: {packet_name}.required_fields", errors) + task_packet = envelopes.get("task_packet") + if isinstance(task_packet, dict): + for field, values in { + "context_volume_values": {"none", "small", "bounded", "large"}, + "risk_values": {"low", "moderate", "high", "consequential"}, + "write_authority_values": {"none", "declared-paths", "dtm-session-only"}, + "return_schema_values": {"completion-packet-v1"}, + }.items(): + if set(string_list(task_packet.get(field), f"task-envelope-contract.json: task_packet.{field}", errors)) != values: + errors.append(f"task-envelope-contract.json: task_packet.{field} must contain the declared vocabulary") + completion_packet = envelopes.get("completion_packet") + if isinstance(completion_packet, dict): + if set(string_list(completion_packet.get("status_values"), "task-envelope-contract.json: completion_packet.status_values", errors)) != {"completed", "partial", "blocked", "failed"}: + errors.append("task-envelope-contract.json: completion_packet.status_values must contain the declared vocabulary") + return sorted(set(errors)) + + +def validate_task(packet: dict[str, object]) -> list[str]: + errors = lint_contracts() + if errors: + return errors + profiles, envelopes = load_json(PROFILES), load_json(ENVELOPES) + contract = envelopes["task_packet"] + assert isinstance(contract, dict) + for field in contract["required_fields"]: + if field not in packet: + errors.append(f"task packet: missing required field {field}") + if packet.get("version") != 1: + errors.append("task packet: version must be 1") + task_id = packet.get("task_id") + if not isinstance(task_id, str) or not SLUG_RE.fullmatch(task_id): + errors.append("task packet: task_id must be lowercase kebab-case") + profile_ids = {row["id"] for row in profiles["profiles"] if isinstance(row, dict) and isinstance(row.get("id"), str)} + if packet.get("capability_profile") not in profile_ids: + errors.append("task packet: capability_profile must be a declared profile") + for field, vocabulary in (("context_volume", contract["context_volume_values"]), ("risk", contract["risk_values"]), ("write_authority", contract["write_authority_values"]), ("return_schema", contract["return_schema_values"])): + if packet.get(field) not in vocabulary: + errors.append(f"task packet: {field} must use the contract vocabulary") + paths = string_list(packet.get("allowed_paths"), "task packet: allowed_paths", errors, allow_empty=True) + for path in paths: + if not PATH_RE.fullmatch(path) or ".." in Path(path).parts: + errors.append(f"task packet: invalid vault-relative path {path!r}") + if path == "daily/**" and packet.get("write_authority") != "dtm-session-only": + errors.append("task packet: daily/** requires dtm-session-only authority") + if packet.get("write_authority") == "none" and paths: + errors.append("task packet: write_authority none cannot declare allowed_paths") + for field in ("required_tools", "validation", "acceptance_criteria", "escalate_when"): + string_list(packet.get(field), f"task packet: {field}", errors, allow_empty=True) + return sorted(set(errors)) + + +def validate_completion(packet: dict[str, object]) -> list[str]: + errors = lint_contracts() + if errors: + return errors + envelopes = load_json(ENVELOPES) + contract = envelopes["completion_packet"] + assert isinstance(contract, dict) + for field in contract["required_fields"]: + if field not in packet: + errors.append(f"completion packet: missing required field {field}") + if packet.get("version") != 1: + errors.append("completion packet: version must be 1") + if not isinstance(packet.get("task_id"), str) or not SLUG_RE.fullmatch(packet["task_id"]): + errors.append("completion packet: task_id must be lowercase kebab-case") + if packet.get("status") not in contract["status_values"]: + errors.append("completion packet: status must use the contract vocabulary") + for field in ("outcomes", "artifacts", "verification", "uncertainty", "blockers", "follow_up", "daily_note_implications"): + string_list(packet.get(field), f"completion packet: {field}", errors, allow_empty=True) + return sorted(set(errors)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + subcommands = parser.add_subparsers(dest="command", required=True) + subcommands.add_parser("lint") + for name in ("validate-task", "validate-completion"): + command = subcommands.add_parser(name) + command.add_argument("--packet", type=Path, required=True) + args = parser.parse_args() + if args.command == "lint": + errors = lint_contracts() + else: + try: + packet = load_json(args.packet) + except ValueError as exc: + print(f"ERROR {exc}") + return 1 + errors = validate_task(packet) if args.command == "validate-task" else validate_completion(packet) + for error in errors: + print(f"ERROR {error}") + print(f"Task-envelope contract: {len(errors)} error(s).") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_task_envelopes.py b/tests/test_task_envelopes.py new file mode 100644 index 0000000..2b36478 --- /dev/null +++ b/tests/test_task_envelopes.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +SPEC = importlib.util.spec_from_file_location("task_envelopes", ROOT / "tools" / "task_envelopes.py") +assert SPEC and SPEC.loader +task_envelopes = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(task_envelopes) + + +class TaskEnvelopeContractTests(unittest.TestCase): + def test_contracts_are_valid(self): + self.assertEqual(task_envelopes.lint_contracts(), []) + + def test_task_packet_enforces_profile_and_daily_authority(self): + packet = { + "version": 1, + "task_id": "work-index-check", + "objective": "Check the work index.", + "task_class": "workspace-maintenance", + "capability_profile": "routine", + "context_volume": "small", + "risk": "low", + "allowed_paths": ["work/**"], + "write_authority": "declared-paths", + "required_tools": ["tools/workspaces.py"], + "validation": ["tools/workspaces.py lint"], + "acceptance_criteria": ["The work index lint passes."], + "escalate_when": ["The index has an unresolved structural error."], + "return_schema": "completion-packet-v1" + } + self.assertEqual(task_envelopes.validate_task(packet), []) + packet["allowed_paths"] = ["daily/**"] + self.assertIn("task packet: daily/** requires dtm-session-only authority", task_envelopes.validate_task(packet)) + + def test_completion_packet_projects_the_dtm_handoff_fields(self): + packet = { + "version": 1, + "task_id": "work-index-check", + "status": "completed", + "outcomes": ["Work index checked."], + "artifacts": ["work/index.md"], + "verification": ["tools/workspaces.py lint passed."], + "uncertainty": [], + "blockers": [], + "follow_up": [], + "daily_note_implications": ["A DTM may record the completed maintenance task."] + } + self.assertEqual(task_envelopes.validate_completion(packet), []) + + +if __name__ == "__main__": + unittest.main()