diff --git a/.docket/ledger-prepackage-backup.jsonl b/.docket/ledger-prepackage-backup.jsonl new file mode 100644 index 0000000..c69e40d --- /dev/null +++ b/.docket/ledger-prepackage-backup.jsonl @@ -0,0 +1,82 @@ +{"answers": [], "author": "", "branch": "", "cost_if_wrong": "Any formalism or implementation that infers intent from realized outcomes alone must be revised.", "depends_on": [], "evidence": [], "id": "c1", "kind": "claim", "legacy": {"raw": {"answer": "No. Identical chains and realized outcomes can be classified differently under different intent relations. Intent must be supplied or derived from additional structure.", "because": [], "cost_if_wrong": "Any formalism or implementation that infers intent from realized outcomes alone must be revised.", "id": "d1", "question": "Does the realization relation alone determine intent classification?", "session": "", "state": "settled", "ts": "2026-09-06T18:04:53+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d1"}, "pinned": false, "rationale": "No. Identical chains and realized outcomes can be classified differently under different intent relations. Intent must be supplied or derived from additional structure.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [], "text": "The realization relation alone does not determine intent classification.", "ts": "2026-09-06T18:04:53+00:00"} +{"alternatives": ["A change that persists after the chain ends. Two kinds: world changes (file written, row inserted, branch pushed) which are observable by diffing, and epistemic changes (question settled, option ruled out) which the ledger already records."], "answers": [], "author": "", "branch": "", "choice": "A change that persists after the chain ends. Two kinds: world changes (file written, row inserted, branch pushed) which are observable by diffing, and epistemic changes (question settled, option ruled out) which the ledger already records.", "cost_if_wrong": "phase 2 schema and the whole unintended-outcome report depend on this split", "decided_by": "", "depends_on": [], "evidence": [], "id": "d2", "kind": "decision", "legacy": {"raw": {"answer": "A change that persists after the chain ends. Two kinds: world changes (file written, row inserted, branch pushed) which are observable by diffing, and epistemic changes (question settled, option ruled out) which the ledger already records.", "because": [], "cost_if_wrong": "phase 2 schema and the whole unintended-outcome report depend on this split", "id": "d2", "question": "What is an outcome?", "session": "", "state": "settled", "ts": "2026-09-06T18:04:57+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d2"}, "pinned": false, "rationale": "A change that persists after the chain ends. Two kinds: world changes (file written, row inserted, branch pushed) which are observable by diffing, and epistemic changes (question settled, option ruled out) which the ledger already records.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "What is an outcome?", "ts": "2026-09-06T18:04:57+00:00"} +{"answers": [], "author": "", "branch": "", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c3", "kind": "claim", "legacy": {"raw": {"answer": "No. World outcomes get computed from tool-call logs and the filesystem. Asking inherits the faithfulness problem.", "because": ["d1"], "cost_if_wrong": "", "id": "d3", "question": "Ask the model what it changed?", "session": "", "state": "ruled-out", "ts": "2026-09-06T18:04:57+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c1"]], "overrides": [], "source_answers": [], "source_because": ["d1"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d3"}, "pinned": false, "rationale": "No. World outcomes get computed from tool-call logs and the filesystem. Asking inherits the faithfulness problem.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [["c1"]], "text": "An agent's self-report is insufficient evidence for world outcomes.", "ts": "2026-09-06T18:04:57+00:00"} +{"answers": [], "author": "", "branch": "", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c4", "kind": "claim", "legacy": {"raw": {"answer": "d3 cites d1 in error. It follows from d2, the outcome definition, not from the intent-derivability result. The ledger is append-only, so this entry stands as the correction.", "because": ["d2"], "cost_if_wrong": "", "id": "d4", "question": "Correct the justification on d3", "session": "", "state": "settled", "ts": "2026-09-06T18:05:26+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d2"]], "overrides": [], "source_answers": [], "source_because": ["d2"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d4"}, "pinned": false, "rationale": "d3 cites d1 in error. It follows from d2, the outcome definition, not from the intent-derivability result. The ledger is append-only, so this entry stands as the correction.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [["d2"]], "text": "The justification recorded for the original d3 was incorrect: it follows from the outcome definition in original d2.", "ts": "2026-09-06T18:05:26+00:00"} +{"answers": [], "author": "", "branch": "", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c5", "kind": "claim", "legacy": {"raw": {"answer": "Yes, for any exhaustive and disjoint binary partition of realized outcomes. Choosing the intended class as ι reconstructs intended = R ∩ ι and unintended = R \\ ι; once R and ι are fixed, the classification is unique. This is a representation result, not a way to infer ι.", "because": ["d1"], "cost_if_wrong": "", "id": "d5", "question": "Is the intended/unintended reduction complete?", "session": "", "state": "settled", "ts": "2026-09-06T18:12:24+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c1"]], "overrides": [], "source_answers": [], "source_because": ["d1"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d5"}, "pinned": false, "rationale": "Yes, for any exhaustive and disjoint binary partition of realized outcomes. Choosing the intended class as ι reconstructs intended = R ∩ ι and unintended = R \\ ι; once R and ι are fixed, the classification is unique. This is a representation result, not a way to infer ι.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [["c1"]], "text": "For an exhaustive, disjoint binary partition of realized outcomes, fixing realization and intent uniquely determines the intended and unintended classes; this does not infer intent.", "ts": "2026-09-06T18:12:24+00:00"} +{"answers": [], "author": "", "branch": "", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c6", "kind": "claim", "legacy": {"raw": {"answer": "No. Counterexample: for any chosen set D, define Cl_D(X) = X ∪ D when X is nonempty and Cl_D(∅) = ∅. This operator is extensive, monotone, idempotent, and preserves ∅, yet for any nonempty R(c) disjoint from D it induces exactly D as the indirect outcomes. Cl must instead be fixed independently by causal, logical, or transition semantics, with a consequence-path witness for every indirect outcome.", "because": ["d5"], "cost_if_wrong": "", "id": "d6", "question": "Do the standard closure laws make direct versus indirect outcomes meaningful?", "session": "", "state": "ruled-out", "ts": "2026-09-06T18:14:29+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c5"]], "overrides": [], "source_answers": [], "source_because": ["d5"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d6"}, "pinned": false, "rationale": "No. Counterexample: for any chosen set D, define Cl_D(X) = X ∪ D when X is nonempty and Cl_D(∅) = ∅. This operator is extensive, monotone, idempotent, and preserves ∅, yet for any nonempty R(c) disjoint from D it induces exactly D as the indirect outcomes. Cl must instead be fixed independently by causal, logical, or transition semantics, with a consequence-path witness for every indirect outcome.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [["c5"]], "text": "Generic closure laws alone do not determine a meaningful direct-versus-indirect outcome distinction.", "ts": "2026-09-06T18:14:29+00:00"} +{"answers": [], "author": "", "branch": "", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c7", "kind": "claim", "legacy": {"raw": {"answer": "No. Counterexample: declare no dependency between two sections; one sets state to 1 and the other doubles it. The dependency relation is a strict partial order and no edge crosses the boundary, but set-then-double returns 2 while double-then-set returns 1. The dependency relation must be complete for semantic interference.", "because": ["d2"], "cost_if_wrong": "", "id": "d7", "question": "Does a strict dependency order with no crossing edge guarantee valid parallel composition?", "session": "", "state": "ruled-out", "ts": "2026-09-06T18:16:17+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d2"]], "overrides": [], "source_answers": [], "source_because": ["d2"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d7"}, "pinned": false, "rationale": "No. Counterexample: declare no dependency between two sections; one sets state to 1 and the other doubles it. The dependency relation is a strict partial order and no edge crosses the boundary, but set-then-double returns 2 while double-then-set returns 1. The dependency relation must be complete for semantic interference.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [["d2"]], "text": "A strict dependency order without crossing edges does not establish parallel validity unless it accounts for semantic interference.", "ts": "2026-09-06T18:16:17+00:00"} +{"answers": [], "author": "", "branch": "", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c8", "kind": "claim", "legacy": {"raw": {"answer": "Cross-section state transformations must commute: applying section A then B must equal applying B then A for every initial state. Under this condition the two sequential schedules are equal. A dependency graph can certify parallel validity only when every noncommuting pair is represented by a dependency edge.", "because": ["d7"], "cost_if_wrong": "", "id": "d8", "question": "What condition repairs parallel composition soundness?", "session": "", "state": "settled", "ts": "2026-09-06T18:16:24+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c7"]], "overrides": [], "source_answers": [], "source_because": ["d7"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d8"}, "pinned": false, "rationale": "Cross-section state transformations must commute: applying section A then B must equal applying B then A for every initial state. Under this condition the two sequential schedules are equal. A dependency graph can certify parallel validity only when every noncommuting pair is represented by a dependency edge.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [["c7"]], "text": "Commuting state transformations make the two sequential section schedules equal; a dependency graph certifies parallel validity only if every noncommuting pair is represented.", "ts": "2026-09-06T18:16:24+00:00"} +{"answers": [], "author": "", "branch": "", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c9", "kind": "claim", "legacy": {"raw": {"answer": "Yes, if every member of j(γ) is required jointly. Defining removal as the least set containing the retracted premise and closed under reverse support edges makes survivors closed under j, and the removed set is contained in every other set satisfying those rules.", "because": ["d2"], "cost_if_wrong": "", "id": "d9", "question": "Is transitive retraction sound and minimal for j : Γ → 2^Γ?", "session": "", "state": "settled", "ts": "2026-09-06T18:17:38+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d2"]], "overrides": [], "source_answers": [], "source_because": ["d2"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d9"}, "pinned": false, "rationale": "Yes, if every member of j(γ) is required jointly. Defining removal as the least set containing the retracted premise and closed under reverse support edges makes survivors closed under j, and the removed set is contained in every other set satisfying those rules.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [["d2"]], "text": "Transitive retraction is sound and minimal for conjunctive justification: every member of the support set must be required jointly.", "ts": "2026-09-06T18:17:38+00:00"} +{"answers": [], "author": "", "branch": "", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c10", "kind": "claim", "legacy": {"raw": {"answer": "No. Counterexample: Q has independent supports A and B. Retracting A removes Q by the transitive-dependent rule even though B survives and still supports Q. Phase 4 must either define j(γ) conjunctively or represent alternatives as a set of justification sets and retain γ while any complete justification survives.", "because": ["d9"], "cost_if_wrong": "", "id": "d10", "question": "Can a flat support set represent alternative justifications without over-retraction?", "session": "", "state": "ruled-out", "ts": "2026-09-06T18:17:45+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c9"]], "overrides": [], "source_answers": [], "source_because": ["d9"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d10"}, "pinned": false, "rationale": "No. Counterexample: Q has independent supports A and B. Retracting A removes Q by the transitive-dependent rule even though B survives and still supports Q. Phase 4 must either define j(γ) conjunctively or represent alternatives as a set of justification sets and retain γ while any complete justification survives.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [["c9"]], "text": "Flat conjunctive support sets cannot represent independent alternative justifications without over-retraction.", "ts": "2026-09-06T18:17:45+00:00"} +{"answers": [], "author": "", "branch": "", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c11", "kind": "claim", "legacy": {"raw": {"answer": "When the state transformations produced by the sub-question answers commute. Pairwise commutativity is sufficient for evaluation to be invariant under every permutation; for two questions it is also necessary. Logical independence matters only if it guarantees this operational noninterference. Counterexample: set-state-to-1 and double-state are reorderings of the same two updates but yield 2 versus 1.", "because": ["d8"], "cost_if_wrong": "", "id": "d11", "question": "When is a decomposition invariant to sub-question order?", "session": "", "state": "settled", "ts": "2026-09-06T18:20:20+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c8"]], "overrides": [], "source_answers": [], "source_because": ["d8"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d11"}, "pinned": false, "rationale": "When the state transformations produced by the sub-question answers commute. Pairwise commutativity is sufficient for evaluation to be invariant under every permutation; for two questions it is also necessary. Logical independence matters only if it guarantees this operational noninterference. Counterexample: set-state-to-1 and double-state are reorderings of the same two updates but yield 2 versus 1.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [["c8"]], "text": "Pairwise commutativity of sub-question state transformations is sufficient for order-invariant evaluation; for two transformations it is also necessary.", "ts": "2026-09-06T18:20:20+00:00"} +{"alternatives": ["~/.claude/docket keyed by git root. A project .docket wins when present, and docket init creates one. DOCKET_HOME and CLAUDE_CONFIG_DIR relocate the store."], "answers": [], "author": "claude-code", "branch": "codex/lean-outcome-experiment", "choice": "~/.claude/docket keyed by git root. A project .docket wins when present, and docket init creates one. DOCKET_HOME and CLAUDE_CONFIG_DIR relocate the store.", "cost_if_wrong": "changing the key strands every existing global ledger", "decided_by": "", "depends_on": [], "evidence": [], "id": "d12", "kind": "decision", "legacy": {"raw": {"answer": "~/.claude/docket keyed by git root. A project .docket wins when present, and docket init creates one. DOCKET_HOME and CLAUDE_CONFIG_DIR relocate the store.", "author": "claude-code", "because": [], "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "changing the key strands every existing global ledger", "id": "d12", "question": "Where does the ledger live by default?", "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "settled", "ts": "2026-09-06T18:40:16+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d12"}, "pinned": false, "rationale": "~/.claude/docket keyed by git root. A project .docket wins when present, and docket init creates one. DOCKET_HOME and CLAUDE_CONFIG_DIR relocate the store.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "adopted", "supersedes": [], "supports": [], "text": "Where does the ledger live by default?", "ts": "2026-09-06T18:40:16+00:00"} +{"alternatives": ["Yes. session, author, and branch are written from the start. The log is append-only, so an entry can never gain them later. Nothing filters on branch yet."], "answers": [], "author": "claude-code", "branch": "codex/lean-outcome-experiment", "choice": "Yes. session, author, and branch are written from the start. The log is append-only, so an entry can never gain them later. Nothing filters on branch yet.", "cost_if_wrong": "", "decided_by": "", "depends_on": [], "evidence": [], "id": "d13", "kind": "decision", "legacy": {"raw": {"answer": "Yes. session, author, and branch are written from the start. The log is append-only, so an entry can never gain them later. Nothing filters on branch yet.", "author": "claude-code", "because": [], "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "", "id": "d13", "question": "Capture provenance fields before anything consumes them?", "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "settled", "ts": "2026-09-06T18:40:16+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d13"}, "pinned": false, "rationale": "Yes. session, author, and branch are written from the start. The log is append-only, so an entry can never gain them later. Nothing filters on branch yet.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "adopted", "supersedes": [], "supports": [], "text": "Capture provenance fields before anything consumes them?", "ts": "2026-09-06T18:40:16+00:00"} +{"alternatives": ["No. Codex ships as .codex-plugin/plugin.json because it is in use. OpenCode, Gemini, Copilot, and Cursor are documented in docs/installation.md with copyable config. Only the Claude Code path is tested."], "answers": [], "author": "claude-code", "branch": "codex/lean-outcome-experiment", "choice": "No. Codex ships as .codex-plugin/plugin.json because it is in use. OpenCode, Gemini, Copilot, and Cursor are documented in docs/installation.md with copyable config. Only the Claude Code path is tested.", "cost_if_wrong": "", "decided_by": "", "depends_on": [], "evidence": [], "id": "d14", "kind": "decision", "legacy": {"raw": {"answer": "No. Codex ships as .codex-plugin/plugin.json because it is in use. OpenCode, Gemini, Copilot, and Cursor are documented in docs/installation.md with copyable config. Only the Claude Code path is tested.", "author": "claude-code", "because": [], "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "", "id": "d14", "question": "Ship config for every harness?", "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "ruled-out", "ts": "2026-09-06T18:40:17+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d14"}, "pinned": false, "rationale": "No. Codex ships as .codex-plugin/plugin.json because it is in use. OpenCode, Gemini, Copilot, and Cursor are documented in docs/installation.md with copyable config. Only the Claude Code path is tested.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "adopted", "supersedes": [], "supports": [], "text": "Ship config for every harness?", "ts": "2026-09-06T18:40:17+00:00"} +{"answers": [], "author": "claude-code", "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "phase 4 retraction built on the flat form will remove claims that still hold", "depends_on": [], "evidence": [], "id": "q15", "kind": "question", "legacy": {"raw": {"answer": "d10 shows a flat j: G -> 2^G over-retracts when a claim has alternative supports. definitions.md still states the flat form. It should be a set of justification sets, retaining a claim while any complete justification survives. Doc not yet updated.", "author": "claude-code", "because": ["d10"], "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "phase 4 retraction built on the flat form will remove claims that still hold", "id": "d15", "question": "Correct the justification type in docs/definitions.md", "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "open", "ts": "2026-09-06T18:40:25+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c10"]], "overrides": [], "source_answers": [], "source_because": ["d10"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d15"}, "pinned": false, "rationale": "d10 shows a flat j: G -> 2^G over-retracts when a claim has alternative supports. definitions.md still states the flat form. It should be a set of justification sets, retaining a claim while any complete justification survives. Doc not yet updated.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "open", "supersedes": [], "supports": [["c10"]], "text": "How should justification represent alternative support sets?", "ts": "2026-09-06T18:40:25+00:00"} +{"alternatives": ["GitHub issues on NovusEdge/docket. The ledger records decisions, issues track work. d15 is issue #1 and the blank-author problem is issue #2."], "answers": [], "author": "claude-code", "branch": "codex/lean-outcome-experiment", "choice": "GitHub issues on NovusEdge/docket. The ledger records decisions, issues track work. d15 is issue #1 and the blank-author problem is issue #2.", "cost_if_wrong": "two places to look if they drift apart", "decided_by": "", "depends_on": [], "evidence": [], "id": "d16", "kind": "decision", "legacy": {"raw": {"answer": "GitHub issues on NovusEdge/docket. The ledger records decisions, issues track work. d15 is issue #1 and the blank-author problem is issue #2.", "author": "claude-code", "because": [], "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "two places to look if they drift apart", "id": "d16", "question": "Track open defects where?", "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "settled", "ts": "2026-09-06T18:41:36+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d16"}, "pinned": false, "rationale": "GitHub issues on NovusEdge/docket. The ledger records decisions, issues track work. d15 is issue #1 and the blank-author problem is issue #2.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "adopted", "supersedes": [], "supports": [], "text": "Track open defects where?", "ts": "2026-09-06T18:41:36+00:00"} +{"alternatives": ["Represent justification as alternative conjunctive support sets."], "answers": ["q15"], "author": "claude-code", "branch": "codex/lean-outcome-experiment", "choice": "Represent justification as alternative conjunctive support sets.", "cost_if_wrong": "phase 4 retraction depends on this shape", "decided_by": "", "depends_on": [], "evidence": [], "id": "d17", "kind": "decision", "legacy": {"raw": {"answer": "Done in PR #3. because is now a list of alternative conjunctive sets, j maps to a set of justification sets, and a claim is retained while any complete set survives. Legacy flat entries read as one set.", "author": "claude-code", "because": ["d15"], "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "phase 4 retraction depends on this shape", "id": "d17", "question": "Resolve d15, the justification type", "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "settled", "ts": "2026-09-06T18:58:19+00:00"}, "relation_map": {"mapped_answers": ["q15"], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c10"]], "overrides": ["answers", "supports"], "source_answers": [], "source_because": ["d15"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d17"}, "pinned": false, "rationale": "Done in PR #3. because is now a list of alternative conjunctive sets, j maps to a set of justification sets, and a claim is retained while any complete set survives. Legacy flat entries read as one set. Migration explicitly converts the old link to the question into an answers relation and cites the counterexample as support.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "adopted", "supersedes": [], "supports": [["c10"]], "text": "Resolve d15, the justification type", "ts": "2026-09-06T18:58:19+00:00"} +{"answers": [], "author": "", "branch": "", "cost_if_wrong": "a ledger that becomes unreadable or slow to retract once entries carry many alternative supports", "depends_on": [], "evidence": [], "id": "q18", "kind": "question", "legacy": {"raw": {"answer": "Nothing yet. d17 gave because the shape of an ATMS label, a set of environments. de Kleer 1986 shows that set grows exponentially in the number of assumptions, which is what killed the ATMS line. docs/decision-chains.md now records the risk.", "because": ["d17", "d10"], "cost_if_wrong": "a ledger that becomes unreadable or slow to retract once entries carry many alternative supports", "id": "d18", "question": "What bounds label growth under alternative justifications?", "session": "", "state": "open", "ts": "2026-09-07T14:21:07+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d17", "c10"]], "overrides": [], "source_answers": [], "source_because": ["d17", "d10"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d18"}, "pinned": false, "rationale": "Nothing yet. d17 gave because the shape of an ATMS label, a set of environments. de Kleer 1986 shows that set grows exponentially in the number of assumptions, which is what killed the ATMS line. docs/decision-chains.md now records the risk.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "open", "supersedes": [], "supports": [["d17", "c10"]], "text": "What bounds label growth under alternative justifications?", "ts": "2026-09-07T14:21:07+00:00"} +{"answers": [], "author": "claude-code", "branch": "main", "cost_if_wrong": "if the lift is wrong, a dependency graph cannot certify parallel validity at all", "depends_on": [], "evidence": [], "id": "c19", "kind": "claim", "legacy": {"raw": {"answer": "It is now. d8 previously rested on a theorem that restated the definition of Commute. StressTests.lean proves the lift instead: model a section as a list of steps, and if every step of one section commutes with every step of the other, the two section effects commute. Proof is by induction on both lists.", "author": "claude-code", "because": [["d8"]], "branch": "main", "cost_if_wrong": "if the lift is wrong, a dependency graph cannot certify parallel validity at all", "id": "d19", "question": "Is the parallel-composition repair in d8 actually proved?", "session": "session_013saodNY3WoAKeDn9ySLjUH", "state": "settled", "ts": "2026-09-07T14:36:37+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c8"]], "overrides": [], "source_answers": [], "source_because": [["d8"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d19"}, "pinned": false, "rationale": "It is now. d8 previously rested on a theorem that restated the definition of Commute. StressTests.lean proves the lift instead: model a section as a list of steps, and if every step of one section commutes with every step of the other, the two section effects commute. Proof is by induction on both lists.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "session_013saodNY3WoAKeDn9ySLjUH", "state": "accepted", "supersedes": [], "supports": [["c8"]], "text": "StressTests.lean proves that pairwise commutativity of steps across two sections lifts to commutativity of the section effects.", "ts": "2026-09-07T14:36:37+00:00"} +{"answers": ["q15"], "author": "claude-code", "branch": "main", "cost_if_wrong": "if supersedes is wrong, a decision that still holds disappears from the injected context", "depends_on": [], "evidence": [], "id": "c20", "kind": "claim", "legacy": {"raw": {"answer": "It did, and that was the defect. d15 keeps its own open state because the log is append-only, so list --state open and the injected context both misreported it. add --supersedes now records the retirement and hides the retired entry; list --superseded shows it again.", "author": "claude-code", "because": [["d17", "d16"]], "branch": "main", "cost_if_wrong": "if supersedes is wrong, a decision that still holds disappears from the injected context", "id": "d20", "question": "Does the ledger show d15 as open after d17 resolved it?", "session": "session_013saodNY3WoAKeDn9ySLjUH", "state": "settled", "supersedes": ["d15"], "ts": "2026-09-07T14:53:43+00:00"}, "relation_map": {"mapped_answers": ["q15"], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d17", "d16"]], "overrides": ["answers", "supersedes"], "source_answers": [], "source_because": [["d17", "d16"]], "source_depends_on": [], "source_supersedes": ["d15"]}, "source_id": "d20"}, "pinned": false, "rationale": "It did, and that was the defect. d15 keeps its own open state because the log is append-only, so list --state open and the injected context both misreported it. add --supersedes now records the retirement and hides the retired entry; list --superseded shows it again. Migration explicitly converts the old question supersession into an answers relation.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "session_013saodNY3WoAKeDn9ySLjUH", "state": "accepted", "supersedes": [], "supports": [["d17", "d16"]], "text": "The earlier ledger reported a retired question as open until supersession filtering was added.", "ts": "2026-09-07T14:53:43+00:00"} +{"alternatives": ["Apply de-slopify and ASD-STE100 Issue 9 principles. Keep the public README free of internal implementation details, plans, and status."], "answers": [], "author": "novusedge", "branch": "main", "choice": "Apply de-slopify and ASD-STE100 Issue 9 principles. Keep the public README free of internal implementation details, plans, and status.", "cost_if_wrong": "Documentation can become ambiguous, stale, or unsuitable for public readers.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d21", "kind": "decision", "legacy": {"raw": {"answer": "Apply de-slopify and ASD-STE100 Issue 9 principles. Keep the public README free of internal implementation details, plans, and status.", "author": "novusedge", "because": [], "branch": "main", "cost_if_wrong": "Documentation can become ambiguous, stale, or unsuitable for public readers.", "id": "d21", "question": "What writing standard must Docket documentation follow?", "session": "", "state": "settled", "ts": "2026-09-07T15:35:33+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d21"}, "pinned": true, "rationale": "Apply de-slopify and ASD-STE100 Issue 9 principles. Keep the public README free of internal implementation details, plans, and status.", "revisit": "", "schema": 2, "scope": ["docs/**", "README.md"], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "What writing standard must Docket documentation follow?", "ts": "2026-09-07T15:35:33+00:00"} +{"alternatives": ["A stdlib-only install.py at the repo root. One script serves both 'curl | python3 -' and 'python3 install.py' from a checkout: it detects which by whether __file__ resolves next to bin/docket."], "answers": [], "author": "claude-code", "branch": "main", "choice": "A stdlib-only install.py at the repo root. One script serves both 'curl | python3 -' and 'python3 install.py' from a checkout: it detects which by whether __file__ resolves next to bin/docket.", "cost_if_wrong": "Changing the entry point invalidates the README one-liner and any URL people have bookmarked.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d22", "kind": "decision", "legacy": {"raw": {"answer": "A stdlib-only install.py at the repo root. One script serves both 'curl | python3 -' and 'python3 install.py' from a checkout: it detects which by whether __file__ resolves next to bin/docket.", "author": "claude-code", "because": [], "branch": "main", "cost_if_wrong": "Changing the entry point invalidates the README one-liner and any URL people have bookmarked.", "id": "d22", "question": "How do users install docket?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": [], "ts": "2026-09-11T17:36:51+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d22"}, "pinned": false, "rationale": "A stdlib-only install.py at the repo root. One script serves both 'curl | python3 -' and 'python3 install.py' from a checkout: it detects which by whether __file__ resolves next to bin/docket.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": [], "supports": [], "text": "How do users install docket?", "ts": "2026-09-11T17:36:51+00:00"} +{"alternatives": ["It merges a SessionStart hook into ~/.claude/settings.json and installs the skill, rather than printing the two /plugin slash commands. It skips the hook when the marketplace plugin is already present under ~/.claude/plugins/, because both would inject the ledger twice."], "answers": [], "author": "claude-code", "branch": "main", "choice": "It merges a SessionStart hook into ~/.claude/settings.json and installs the skill, rather than printing the two /plugin slash commands. It skips the hook when the marketplace plugin is already present under ~/.claude/plugins/, because both would inject the ledger twice.", "cost_if_wrong": "Both paths injecting at once doubles the ledger in context; the guard is the only thing preventing it.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d23", "kind": "decision", "legacy": {"raw": {"answer": "It merges a SessionStart hook into ~/.claude/settings.json and installs the skill, rather than printing the two /plugin slash commands. It skips the hook when the marketplace plugin is already present under ~/.claude/plugins/, because both would inject the ledger twice.", "author": "claude-code", "because": [["d22"]], "branch": "main", "cost_if_wrong": "Both paths injecting at once doubles the ledger in context; the guard is the only thing preventing it.", "id": "d23", "question": "How does the installer wire Claude Code?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": [], "ts": "2026-09-11T17:36:59+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d22"]], "overrides": [], "source_answers": [], "source_because": [["d22"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d23"}, "pinned": false, "rationale": "It merges a SessionStart hook into ~/.claude/settings.json and installs the skill, rather than printing the two /plugin slash commands. It skips the hook when the marketplace plugin is already present under ~/.claude/plugins/, because both would inject the ledger twice.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": [], "supports": [["d22"]], "text": "How does the installer wire Claude Code?", "ts": "2026-09-11T17:36:59+00:00"} +{"alternatives": ["User-global by default, so one install covers every repo. --project writes into the current repository instead. This matches the ledger, which is already global and keyed by git root."], "answers": [], "author": "claude-code", "branch": "main", "choice": "User-global by default, so one install covers every repo. --project writes into the current repository instead. This matches the ledger, which is already global and keyed by git root.", "cost_if_wrong": "", "decided_by": "", "depends_on": [], "evidence": [], "id": "d24", "kind": "decision", "legacy": {"raw": {"answer": "User-global by default, so one install covers every repo. --project writes into the current repository instead. This matches the ledger, which is already global and keyed by git root.", "author": "claude-code", "because": [["d22"]], "branch": "main", "cost_if_wrong": "", "id": "d24", "question": "Where does the installer write harness config?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": [], "ts": "2026-09-11T17:36:59+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d22"]], "overrides": [], "source_answers": [], "source_because": [["d22"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d24"}, "pinned": false, "rationale": "User-global by default, so one install covers every repo. --project writes into the current repository instead. This matches the ledger, which is already global and keyed by git root.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": [], "supports": [["d22"]], "text": "Where does the installer write harness config?", "ts": "2026-09-11T17:36:59+00:00"} +{"alternatives": ["They re-run the installer. It is idempotent, and in bootstrap mode it runs git pull --ff-only on an existing checkout. No docket update subcommand and no network version check on the session-start path."], "answers": [], "author": "claude-code", "branch": "main", "choice": "They re-run the installer. It is idempotent, and in bootstrap mode it runs git pull --ff-only on an existing checkout. No docket update subcommand and no network version check on the session-start path.", "cost_if_wrong": "A version check on the hook path would add a network call to something that must stay fast and offline-safe.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d25", "kind": "decision", "legacy": {"raw": {"answer": "They re-run the installer. It is idempotent, and in bootstrap mode it runs git pull --ff-only on an existing checkout. No docket update subcommand and no network version check on the session-start path.", "author": "claude-code", "because": [["d22"]], "branch": "main", "cost_if_wrong": "A version check on the hook path would add a network call to something that must stay fast and offline-safe.", "id": "d25", "question": "How do users update docket?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": [], "ts": "2026-09-11T17:36:59+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d22"]], "overrides": [], "source_answers": [], "source_because": [["d22"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d25"}, "pinned": false, "rationale": "They re-run the installer. It is idempotent, and in bootstrap mode it runs git pull --ff-only on an existing checkout. No docket update subcommand and no network version check on the session-start path.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": [], "supports": [["d22"]], "text": "How do users update docket?", "ts": "2026-09-11T17:36:59+00:00"} +{"alternatives": ["Planning is pure: every step returns Action records (WriteJSON, WriteText, AppendLine, Link, Shim, Remove) carrying target path and final content. A single apply() is the only code that writes. --dry-run skips apply; tests assert on planned actions against a synthetic HOME, so the Windows and POSIX branches are both testable from either platform."], "answers": [], "author": "claude-code", "branch": "main", "choice": "Planning is pure: every step returns Action records (WriteJSON, WriteText, AppendLine, Link, Shim, Remove) carrying target path and final content. A single apply() is the only code that writes. --dry-run skips apply; tests assert on planned actions against a synthetic HOME, so the Windows and POSIX branches are both testable from either platform.", "cost_if_wrong": "Mixing writes back into the planners removes --dry-run and most of the test suite at once.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d26", "kind": "decision", "legacy": {"raw": {"answer": "Planning is pure: every step returns Action records (WriteJSON, WriteText, AppendLine, Link, Shim, Remove) carrying target path and final content. A single apply() is the only code that writes. --dry-run skips apply; tests assert on planned actions against a synthetic HOME, so the Windows and POSIX branches are both testable from either platform.", "author": "claude-code", "because": [["d22"]], "branch": "main", "cost_if_wrong": "Mixing writes back into the planners removes --dry-run and most of the test suite at once.", "id": "d26", "question": "How is install.py structured so it can be tested and dry-run?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": [], "ts": "2026-09-11T17:37:24+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d22"]], "overrides": [], "source_answers": [], "source_because": [["d22"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d26"}, "pinned": false, "rationale": "Planning is pure: every step returns Action records (WriteJSON, WriteText, AppendLine, Link, Shim, Remove) carrying target path and final content. A single apply() is the only code that writes. --dry-run skips apply; tests assert on planned actions against a synthetic HOME, so the Windows and POSIX branches are both testable from either platform.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": [], "supports": [["d22"]], "text": "How is install.py structured so it can be tested and dry-run?", "ts": "2026-09-11T17:37:24+00:00"} +{"alternatives": ["A docket.cmd and docket.ps1 shim that invoke a resolved absolute interpreter path, not a symlink. Symlinks need Developer Mode or elevation, bin/docket has no .py extension, and python3 is not the interpreter name on Windows. PATH is edited through PowerShell's [Environment]::SetEnvironmentVariable(...,'User'), never setx, which truncates PATH at 1024 characters."], "answers": [], "author": "claude-code", "branch": "main", "choice": "A docket.cmd and docket.ps1 shim that invoke a resolved absolute interpreter path, not a symlink. Symlinks need Developer Mode or elevation, bin/docket has no .py extension, and python3 is not the interpreter name on Windows. PATH is edited through PowerShell's [Environment]::SetEnvironmentVariable(...,'User'), never setx, which truncates PATH at 1024 characters.", "cost_if_wrong": "setx silently destroying a user PATH is unrecoverable for that user.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d27", "kind": "decision", "legacy": {"raw": {"answer": "A docket.cmd and docket.ps1 shim that invoke a resolved absolute interpreter path, not a symlink. Symlinks need Developer Mode or elevation, bin/docket has no .py extension, and python3 is not the interpreter name on Windows. PATH is edited through PowerShell's [Environment]::SetEnvironmentVariable(...,'User'), never setx, which truncates PATH at 1024 characters.", "author": "claude-code", "because": [["d22"]], "branch": "main", "cost_if_wrong": "setx silently destroying a user PATH is unrecoverable for that user.", "id": "d27", "question": "How does the command get onto PATH on Windows?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": [], "ts": "2026-09-11T17:37:24+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d22"]], "overrides": [], "source_answers": [], "source_because": [["d22"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d27"}, "pinned": false, "rationale": "A docket.cmd and docket.ps1 shim that invoke a resolved absolute interpreter path, not a symlink. Symlinks need Developer Mode or elevation, bin/docket has no .py extension, and python3 is not the interpreter name on Windows. PATH is edited through PowerShell's [Environment]::SetEnvironmentVariable(...,'User'), never setx, which truncates PATH at 1024 characters.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": [], "supports": [["d22"]], "text": "How does the command get onto PATH on Windows?", "ts": "2026-09-11T17:37:24+00:00"} +{"alternatives": ["It links the checkout into ~/.claude/skills/docket. Claude Code loads any folder there that carries .claude-plugin/plugin.json as a plugin, with no marketplace and no install step, and it brings the bundled hooks and skill with it. The installer skips when installed_plugins.json already lists docket. No settings.json merge, no backup file, no marker key."], "answers": [], "author": "claude-code", "branch": "main", "choice": "It links the checkout into ~/.claude/skills/docket. Claude Code loads any folder there that carries .claude-plugin/plugin.json as a plugin, with no marketplace and no install step, and it brings the bundled hooks and skill with it. The installer skips when installed_plugins.json already lists docket. No settings.json merge, no backup file, no marker key.", "cost_if_wrong": "If a skills-directory plugin does not set CLAUDE_PLUGIN_ROOT, hooks/hooks.json cannot resolve bin/docket and the fallback is 'claude plugin marketplace add '.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d28", "kind": "decision", "legacy": {"raw": {"answer": "It links the checkout into ~/.claude/skills/docket. Claude Code loads any folder there that carries .claude-plugin/plugin.json as a plugin, with no marketplace and no install step, and it brings the bundled hooks and skill with it. The installer skips when installed_plugins.json already lists docket. No settings.json merge, no backup file, no marker key.", "author": "claude-code", "because": [["d22"]], "branch": "main", "cost_if_wrong": "If a skills-directory plugin does not set CLAUDE_PLUGIN_ROOT, hooks/hooks.json cannot resolve bin/docket and the fallback is 'claude plugin marketplace add '.", "id": "d28", "question": "How does the installer wire Claude Code?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": ["d23"], "ts": "2026-09-11T17:46:22+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d23"], "mapped_supports": [["d22"]], "overrides": [], "source_answers": [], "source_because": [["d22"]], "source_depends_on": [], "source_supersedes": ["d23"]}, "source_id": "d28"}, "pinned": false, "rationale": "It links the checkout into ~/.claude/skills/docket. Claude Code loads any folder there that carries .claude-plugin/plugin.json as a plugin, with no marketplace and no install step, and it brings the bundled hooks and skill with it. The installer skips when installed_plugins.json already lists docket. No settings.json merge, no backup file, no marker key.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": ["d23"], "supports": [["d22"]], "text": "How does the installer wire Claude Code?", "ts": "2026-09-11T17:46:22+00:00"} +{"alternatives": ["Through stdlib winreg on HKCU\\Environment\\Path, preserving the existing value type, then broadcasting WM_SETTINGCHANGE by ctypes. [Environment]::SetEnvironmentVariable expands every %VAR% already in PATH and rewrites it as REG_SZ, which corrupts it the same way setx does. The command shim is docket.cmd only; a .ps1 on PATH fails under the default Restricted execution policy."], "answers": [], "author": "claude-code", "branch": "main", "choice": "Through stdlib winreg on HKCU\\Environment\\Path, preserving the existing value type, then broadcasting WM_SETTINGCHANGE by ctypes. [Environment]::SetEnvironmentVariable expands every %VAR% already in PATH and rewrites it as REG_SZ, which corrupts it the same way setx does. The command shim is docket.cmd only; a .ps1 on PATH fails under the default Restricted execution policy.", "cost_if_wrong": "Writing the wrong registry value type turns an expandable user PATH into a literal one.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d29", "kind": "decision", "legacy": {"raw": {"answer": "Through stdlib winreg on HKCU\\Environment\\Path, preserving the existing value type, then broadcasting WM_SETTINGCHANGE by ctypes. [Environment]::SetEnvironmentVariable expands every %VAR% already in PATH and rewrites it as REG_SZ, which corrupts it the same way setx does. The command shim is docket.cmd only; a .ps1 on PATH fails under the default Restricted execution policy.", "author": "claude-code", "because": [["d22"]], "branch": "main", "cost_if_wrong": "Writing the wrong registry value type turns an expandable user PATH into a literal one.", "id": "d29", "question": "How does the installer edit PATH on Windows?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": ["d27"], "ts": "2026-09-11T17:46:23+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d27"], "mapped_supports": [["d22"]], "overrides": [], "source_answers": [], "source_because": [["d22"]], "source_depends_on": [], "source_supersedes": ["d27"]}, "source_id": "d29"}, "pinned": false, "rationale": "Through stdlib winreg on HKCU\\Environment\\Path, preserving the existing value type, then broadcasting WM_SETTINGCHANGE by ctypes. [Environment]::SetEnvironmentVariable expands every %VAR% already in PATH and rewrites it as REG_SZ, which corrupts it the same way setx does. The command shim is docket.cmd only; a .ps1 on PATH fails under the default Restricted execution policy.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": ["d27"], "supports": [["d22"]], "text": "How does the installer edit PATH on Windows?", "ts": "2026-09-11T17:46:23+00:00"} +{"alternatives": ["It shells out to 'codex plugin marketplace add ' and 'codex plugin add docket@NovusEdge', and the repo ships .agents/plugins/marketplace.json named NovusEdge so that ref resolves. Codex enablement lives in ~/.codex/config.toml and tomllib is read-only, so the CLI has to own those writes. Verified end to end: codex plugin list reports docket@NovusEdge installed at 0.6.4, and codex plugin remove reverses it."], "answers": [], "author": "claude-code", "branch": "main", "choice": "It shells out to 'codex plugin marketplace add ' and 'codex plugin add docket@NovusEdge', and the repo ships .agents/plugins/marketplace.json named NovusEdge so that ref resolves. Codex enablement lives in ~/.codex/config.toml and tomllib is read-only, so the CLI has to own those writes. Verified end to end: codex plugin list reports docket@NovusEdge installed at 0.6.4, and codex plugin remove reverses it.", "cost_if_wrong": "A marketplace.json named anything but NovusEdge breaks the documented install command.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d30", "kind": "decision", "legacy": {"raw": {"answer": "It shells out to 'codex plugin marketplace add ' and 'codex plugin add docket@NovusEdge', and the repo ships .agents/plugins/marketplace.json named NovusEdge so that ref resolves. Codex enablement lives in ~/.codex/config.toml and tomllib is read-only, so the CLI has to own those writes. Verified end to end: codex plugin list reports docket@NovusEdge installed at 0.6.4, and codex plugin remove reverses it.", "author": "claude-code", "because": [["d22"]], "branch": "main", "cost_if_wrong": "A marketplace.json named anything but NovusEdge breaks the documented install command.", "id": "d30", "question": "How does the installer configure Codex?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": [], "ts": "2026-09-11T18:00:39+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d22"]], "overrides": [], "source_answers": [], "source_because": [["d22"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d30"}, "pinned": false, "rationale": "It shells out to 'codex plugin marketplace add ' and 'codex plugin add docket@NovusEdge', and the repo ships .agents/plugins/marketplace.json named NovusEdge so that ref resolves. Codex enablement lives in ~/.codex/config.toml and tomllib is read-only, so the CLI has to own those writes. Verified end to end: codex plugin list reports docket@NovusEdge installed at 0.6.4, and codex plugin remove reverses it.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": [], "supports": [["d22"]], "text": "How does the installer configure Codex?", "ts": "2026-09-11T18:00:39+00:00"} +{"answers": [], "author": "claude-code", "branch": "main", "cost_if_wrong": "Each wrong snippet is an integration that silently never injects the ledger.", "depends_on": [], "evidence": [], "id": "c31", "kind": "claim", "legacy": {"raw": {"answer": "Three. OpenCode showed Plugin.define, the unreleased v2 API; v1 is a named export returning experimental.chat.system.transform. Gemini used a regex matcher, but a lifecycle matcher is an exact string, so it matched no event. Copilot and Cursor piped through 'python3 -c', which needs sh; 'docket context --for X' replaces the pipe.", "author": "claude-code", "because": [], "branch": "main", "cost_if_wrong": "Each wrong snippet is an integration that silently never injects the ledger.", "id": "d31", "question": "Which harness snippets in docs/installation.md were wrong?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": [], "ts": "2026-09-11T18:00:39+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d31"}, "pinned": false, "rationale": "Three. OpenCode showed Plugin.define, the unreleased v2 API; v1 is a named export returning experimental.chat.system.transform. Gemini used a regex matcher, but a lifecycle matcher is an exact string, so it matched no event. Copilot and Cursor piped through 'python3 -c', which needs sh; 'docket context --for X' replaces the pipe.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "accepted", "supersedes": [], "supports": [], "text": "The installation snippets reviewed on 2026-09-11 contained incorrect OpenCode, Gemini, and Copilot/Cursor examples.", "ts": "2026-09-11T18:00:39+00:00"} +{"answers": [], "author": "claude-code", "branch": "feat/installer", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c32", "kind": "claim", "legacy": {"raw": {"answer": "No. World outcomes get computed from tool-call logs and the filesystem. Asking inherits the faithfulness problem.", "author": "claude-code", "because": [["d2"]], "branch": "feat/installer", "cost_if_wrong": "", "id": "d32", "question": "Ask the model what it changed?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "ruled-out", "supersedes": ["d3", "d4"], "ts": "2026-09-11T20:35:13+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["c3", "c4"], "mapped_supports": [["d2"]], "overrides": [], "source_answers": [], "source_because": [["d2"]], "source_depends_on": [], "source_supersedes": ["d3", "d4"]}, "source_id": "d32"}, "pinned": false, "rationale": "No. World outcomes get computed from tool-call logs and the filesystem. Asking inherits the faithfulness problem.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "accepted", "supersedes": ["c3", "c4"], "supports": [["d2"]], "text": "An agent's self-report is insufficient evidence for world outcomes.", "ts": "2026-09-11T20:35:13+00:00"} +{"alternatives": ["By superseding the entry with a restatement carrying the right support. A dedicated 'amend' event that folds field-level corrections over the log waits for stage 3 retraction: nothing walks because edges yet, so a wrong edge today is a wrong picture, not a wrong retraction. Stage 3 must define j over some view of the log anyway, so the fold gets designed once."], "answers": [], "author": "claude-code", "branch": "feat/installer", "choice": "By superseding the entry with a restatement carrying the right support. A dedicated 'amend' event that folds field-level corrections over the log waits for stage 3 retraction: nothing walks because edges yet, so a wrong edge today is a wrong picture, not a wrong retraction. Stage 3 must define j over some view of the log anyway, so the fold gets designed once.", "cost_if_wrong": "If amend ships before retraction, every reader must honour a fold invariant before the code that needs it exists, and the on-disk schema is expensive to reverse.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d33", "kind": "decision", "legacy": {"raw": {"answer": "By superseding the entry with a restatement carrying the right support. A dedicated 'amend' event that folds field-level corrections over the log waits for stage 3 retraction: nothing walks because edges yet, so a wrong edge today is a wrong picture, not a wrong retraction. Stage 3 must define j over some view of the log anyway, so the fold gets designed once.", "author": "claude-code", "because": [["d2"]], "branch": "feat/installer", "cost_if_wrong": "If amend ships before retraction, every reader must honour a fold invariant before the code that needs it exists, and the on-disk schema is expensive to reverse.", "id": "d33", "question": "How is a wrong justification corrected?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": [], "ts": "2026-09-11T20:35:26+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d2"]], "overrides": [], "source_answers": [], "source_because": [["d2"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d33"}, "pinned": false, "rationale": "By superseding the entry with a restatement carrying the right support. A dedicated 'amend' event that folds field-level corrections over the log waits for stage 3 retraction: nothing walks because edges yet, so a wrong edge today is a wrong picture, not a wrong retraction. Stage 3 must define j over some view of the log anyway, so the fold gets designed once.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": [], "supports": [["d2"]], "text": "How is a wrong justification corrected?", "ts": "2026-09-11T20:35:26+00:00"} +{"alternatives": ["Port the whole installer engine and inline UI to Go under installer/, including its own Go module, tests, and release tooling. Use Bubble Tea v2, Bubbles, and Lip Gloss with Stoat at ~/vms/stoat as the interaction reference. Keep the Docket ledger CLI in Python and retain install.py only as a compatibility launcher."], "answers": [], "author": "codex", "branch": "feat/textual-installer", "choice": "Port the whole installer engine and inline UI to Go under installer/, including its own Go module, tests, and release tooling. Use Bubble Tea v2, Bubbles, and Lip Gloss with Stoat at ~/vms/stoat as the interaction reference. Keep the Docket ledger CLI in Python and retain install.py only as a compatibility launcher.", "cost_if_wrong": "Moving installer code outside installer or retaining a Python installation engine contradicts the approved port scope.", "decided_by": "user", "depends_on": [], "evidence": [], "id": "d34", "kind": "decision", "legacy": {"raw": {"answer": "Port the whole installer engine and inline UI to Go under installer/, including its own Go module, tests, and release tooling. Use Bubble Tea v2, Bubbles, and Lip Gloss with Stoat at ~/vms/stoat as the interaction reference. Keep the Docket ledger CLI in Python and retain install.py only as a compatibility launcher.", "author": "codex", "because": [], "branch": "feat/textual-installer", "cost_if_wrong": "Moving installer code outside installer or retaining a Python installation engine contradicts the approved port scope.", "id": "d34", "question": "Where should the native installer implementation live?", "session": "", "state": "settled", "supersedes": [], "ts": "2026-09-12T01:12:03+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d34"}, "pinned": true, "rationale": "Port the whole installer engine and inline UI to Go under installer/, including its own Go module, tests, and release tooling. Use Bubble Tea v2, Bubbles, and Lip Gloss with Stoat at ~/vms/stoat as the interaction reference. Keep the Docket ledger CLI in Python and retain install.py only as a compatibility launcher.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "Where should the native installer implementation live?", "ts": "2026-09-12T01:12:03+00:00"} +{"alternatives": ["Use Go golang.org/x/sys/windows/registry for HKCU Environment Path, preserve REG_SZ or REG_EXPAND_SZ without expanding percent variables, then broadcast WM_SETTINGCHANGE. Continue using docket.cmd rather than a PowerShell shim. This replaces the Python winreg and ctypes implementation in d29 while preserving its registry semantics."], "answers": [], "author": "codex", "branch": "feat/textual-installer", "choice": "Use Go golang.org/x/sys/windows/registry for HKCU Environment Path, preserve REG_SZ or REG_EXPAND_SZ without expanding percent variables, then broadcast WM_SETTINGCHANGE. Continue using docket.cmd rather than a PowerShell shim. This replaces the Python winreg and ctypes implementation in d29 while preserving its registry semantics.", "cost_if_wrong": "Changing the registry value type or expanding variables corrupts PATH.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d35", "kind": "decision", "legacy": {"raw": {"answer": "Use Go golang.org/x/sys/windows/registry for HKCU Environment Path, preserve REG_SZ or REG_EXPAND_SZ without expanding percent variables, then broadcast WM_SETTINGCHANGE. Continue using docket.cmd rather than a PowerShell shim. This replaces the Python winreg and ctypes implementation in d29 while preserving its registry semantics.", "author": "codex", "because": [["d22"]], "branch": "feat/textual-installer", "cost_if_wrong": "Changing the registry value type or expanding variables corrupts PATH.", "id": "d35", "question": "How does the native installer edit PATH on Windows?", "session": "", "state": "settled", "supersedes": ["d29"], "ts": "2026-09-12T01:12:04+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d29"], "mapped_supports": [["d22"]], "overrides": [], "source_answers": [], "source_because": [["d22"]], "source_depends_on": [], "source_supersedes": ["d29"]}, "source_id": "d35"}, "pinned": false, "rationale": "Use Go golang.org/x/sys/windows/registry for HKCU Environment Path, preserve REG_SZ or REG_EXPAND_SZ without expanding percent variables, then broadcast WM_SETTINGCHANGE. Continue using docket.cmd rather than a PowerShell shim. This replaces the Python winreg and ctypes implementation in d29 while preserving its registry semantics.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d29"], "supports": [["d22"]], "text": "How does the native installer edit PATH on Windows?", "ts": "2026-09-12T01:12:04+00:00"} +{"alternatives": ["Use a native Bubble Tea and Bubbles graph viewer by default when docket graph has terminal input and output. Show compact selectable and collapsible decision rows with a separate detail pane and search. Preserve static output for pipes and explicit static flags. Keep ledger reading and filtering in the Python CLI, put viewer code in graph/, and let the installer deliver its native binary without requiring Go for normal users."], "answers": [], "author": "novusedge", "branch": "feat/textual-installer", "choice": "Use a native Bubble Tea and Bubbles graph viewer by default when docket graph has terminal input and output. Show compact selectable and collapsible decision rows with a separate detail pane and search. Preserve static output for pipes and explicit static flags. Keep ledger reading and filtering in the Python CLI, put viewer code in graph/, and let the installer deliver its native binary without requiring Go for normal users.", "cost_if_wrong": "A pager alone leaves the wall of inline prose and width problems unresolved; making the whole ledger CLI native would expand the approved scope.", "decided_by": "user", "depends_on": [], "evidence": [], "id": "d36", "kind": "decision", "legacy": {"raw": {"answer": "Use a native Bubble Tea and Bubbles graph viewer by default when docket graph has terminal input and output. Show compact selectable and collapsible decision rows with a separate detail pane and search. Preserve static output for pipes and explicit static flags. Keep ledger reading and filtering in the Python CLI, put viewer code in graph/, and let the installer deliver its native binary without requiring Go for normal users.", "author": "novusedge", "because": [], "branch": "feat/textual-installer", "cost_if_wrong": "A pager alone leaves the wall of inline prose and width problems unresolved; making the whole ledger CLI native would expand the approved scope.", "id": "d36", "question": "How should people browse the decision graph by default?", "session": "", "state": "settled", "ts": "2026-09-12T01:38:20+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d36"}, "pinned": true, "rationale": "Use a native Bubble Tea and Bubbles graph viewer by default when docket graph has terminal input and output. Show compact selectable and collapsible decision rows with a separate detail pane and search. Preserve static output for pipes and explicit static flags. Keep ledger reading and filtering in the Python CLI, put viewer code in graph/, and let the installer deliver its native binary without requiring Go for normal users.", "revisit": "", "schema": 2, "scope": ["graph/**", "bin/docket"], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "How should people browse the decision graph by default?", "ts": "2026-09-12T01:38:20+00:00"} +{"alternatives": ["Docket ships installer integrations for Claude Code, Codex, Gemini CLI, Cursor, GitHub Copilot CLI, and OpenCode. The installer detects available harnesses and configures the selected set."], "answers": [], "author": "novusedge", "branch": "codex/native-installer-graph", "choice": "Docket ships installer integrations for Claude Code, Codex, Gemini CLI, Cursor, GitHub Copilot CLI, and OpenCode. The installer detects available harnesses and configures the selected set.", "cost_if_wrong": "An incorrect integration can make sessions start without the ledger context.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d37", "kind": "decision", "legacy": {"raw": {"answer": "Docket ships installer integrations for Claude Code, Codex, Gemini CLI, Cursor, GitHub Copilot CLI, and OpenCode. The installer detects available harnesses and configures the selected set.", "author": "novusedge", "because": [], "branch": "codex/native-installer-graph", "cost_if_wrong": "An incorrect integration can make sessions start without the ledger context.", "id": "d37", "question": "Which harness integrations does Docket ship?", "session": "", "state": "settled", "supersedes": ["d14"], "ts": "2026-09-12T02:09:30+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d14"], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": ["d14"]}, "source_id": "d37"}, "pinned": false, "rationale": "Docket ships installer integrations for Claude Code, Codex, Gemini CLI, Cursor, GitHub Copilot CLI, and OpenCode. The installer detects available harnesses and configures the selected set.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d14"], "supports": [], "text": "Which harness integrations does Docket ship?", "ts": "2026-09-12T02:09:30+00:00"} +{"alternatives": ["Keep installer/install.py as the stable user entry point. Outside a source checkout, it verifies and runs the platform-specific released Go installer. Inside a source checkout, it builds and runs the Go installer under installer/ and requires Go. The installed ledger CLI remains Python 3.10+."], "answers": [], "author": "novusedge", "branch": "codex/native-installer-graph", "choice": "Keep installer/install.py as the stable user entry point. Outside a source checkout, it verifies and runs the platform-specific released Go installer. Inside a source checkout, it builds and runs the Go installer under installer/ and requires Go. The installed ledger CLI remains Python 3.10+.", "cost_if_wrong": "Changing the entry point invalidates the documented download URL, release assets, and existing installation instructions.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d38", "kind": "decision", "legacy": {"raw": {"answer": "Keep installer/install.py as the stable user entry point. Outside a source checkout, it verifies and runs the platform-specific released Go installer. Inside a source checkout, it builds and runs the Go installer under installer/ and requires Go. The installed ledger CLI remains Python 3.10+.", "author": "novusedge", "because": [["d34"]], "branch": "codex/native-installer-graph", "cost_if_wrong": "Changing the entry point invalidates the documented download URL, release assets, and existing installation instructions.", "id": "d38", "question": "What is Docket's installation entry point?", "session": "", "state": "settled", "supersedes": ["d22"], "ts": "2026-09-12T02:09:31+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d22"], "mapped_supports": [["d34"]], "overrides": [], "source_answers": [], "source_because": [["d34"]], "source_depends_on": [], "source_supersedes": ["d22"]}, "source_id": "d38"}, "pinned": true, "rationale": "Keep installer/install.py as the stable user entry point. Outside a source checkout, it verifies and runs the platform-specific released Go installer. Inside a source checkout, it builds and runs the Go installer under installer/ and requires Go. The installed ledger CLI remains Python 3.10+.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d22"], "supports": [["d34"]], "text": "What is Docket's installation entry point?", "ts": "2026-09-12T02:09:31+00:00"} +{"alternatives": ["Harness integration is user-global by default. --project places the Claude skill and Cursor rule in the current repository; hook configuration remains user-global."], "answers": [], "author": "novusedge", "branch": "codex/native-installer-graph", "choice": "Harness integration is user-global by default. --project places the Claude skill and Cursor rule in the current repository; hook configuration remains user-global.", "cost_if_wrong": "Treating --project as fully project-local can cause users to modify or remove global hook configuration unintentionally.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d39", "kind": "decision", "legacy": {"raw": {"answer": "Harness integration is user-global by default. --project places the Claude skill and Cursor rule in the current repository; hook configuration remains user-global.", "author": "novusedge", "because": [["d38"]], "branch": "codex/native-installer-graph", "cost_if_wrong": "Treating --project as fully project-local can cause users to modify or remove global hook configuration unintentionally.", "id": "d39", "question": "Where does the installer write harness configuration?", "session": "", "state": "settled", "supersedes": ["d24"], "ts": "2026-09-12T02:09:31+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d24"], "mapped_supports": [["d38"]], "overrides": [], "source_answers": [], "source_because": [["d38"]], "source_depends_on": [], "source_supersedes": ["d24"]}, "source_id": "d39"}, "pinned": false, "rationale": "Harness integration is user-global by default. --project places the Claude skill and Cursor rule in the current repository; hook configuration remains user-global.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d24"], "supports": [["d38"]], "text": "Where does the installer write harness configuration?", "ts": "2026-09-12T02:09:31+00:00"} +{"alternatives": ["Use the installer --update option to refresh an existing installation while preserving harness and PATH configuration. A downloaded or native installer updates its managed checkout with fetch and a fast-forward merge, then refreshes the graph viewer. From a source checkout, just update or installer/install.py --update rebuilds the current source without Git changes; update that checkout separately. There is no docket update subcommand or session-start network check."], "answers": [], "author": "novusedge", "branch": "codex/native-installer-graph", "choice": "Use the installer --update option to refresh an existing installation while preserving harness and PATH configuration. A downloaded or native installer updates its managed checkout with fetch and a fast-forward merge, then refreshes the graph viewer. From a source checkout, just update or installer/install.py --update rebuilds the current source without Git changes; update that checkout separately. There is no docket update subcommand or session-start network check.", "cost_if_wrong": "Conflating source builds, managed updates, and full installation can unexpectedly rewrite files or leave users on old source.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d40", "kind": "decision", "legacy": {"raw": {"answer": "Use the installer --update option to refresh an existing installation while preserving harness and PATH configuration. A downloaded or native installer updates its managed checkout with fetch and a fast-forward merge, then refreshes the graph viewer. From a source checkout, just update or installer/install.py --update rebuilds the current source without Git changes; update that checkout separately. There is no docket update subcommand or session-start network check.", "author": "novusedge", "because": [["d38", "d36"]], "branch": "codex/native-installer-graph", "cost_if_wrong": "Conflating source builds, managed updates, and full installation can unexpectedly rewrite files or leave users on old source.", "id": "d40", "question": "How do users update Docket?", "session": "", "state": "settled", "supersedes": ["d25"], "ts": "2026-09-12T02:09:31+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d25"], "mapped_supports": [["d38", "d36"]], "overrides": [], "source_answers": [], "source_because": [["d38", "d36"]], "source_depends_on": [], "source_supersedes": ["d25"]}, "source_id": "d40"}, "pinned": true, "rationale": "Use the installer --update option to refresh an existing installation while preserving harness and PATH configuration. A downloaded or native installer updates its managed checkout with fetch and a fast-forward merge, then refreshes the graph viewer. From a source checkout, just update or installer/install.py --update rebuilds the current source without Git changes; update that checkout separately. There is no docket update subcommand or session-start network check.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d25"], "supports": [["d38", "d36"]], "text": "How do users update Docket?", "ts": "2026-09-12T02:09:31+00:00"} +{"alternatives": ["The Python installer is only a bootstrap launcher. The Go installer separates BuildPlan and PreparePlan from ExecutePlan, represents persistent operations as Action values, and skips ExecutePlan for --dry-run. Tests inject an Environment or use temporary homes so platform-specific planning can be checked without modifying the user's installation."], "answers": [], "author": "novusedge", "branch": "codex/native-installer-graph", "choice": "The Python installer is only a bootstrap launcher. The Go installer separates BuildPlan and PreparePlan from ExecutePlan, represents persistent operations as Action values, and skips ExecutePlan for --dry-run. Tests inject an Environment or use temporary homes so platform-specific planning can be checked without modifying the user's installation.", "cost_if_wrong": "Mixing persistent writes into planning would make dry-run output unreliable and remove isolated cross-platform testing.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d41", "kind": "decision", "legacy": {"raw": {"answer": "The Python installer is only a bootstrap launcher. The Go installer separates BuildPlan and PreparePlan from ExecutePlan, represents persistent operations as Action values, and skips ExecutePlan for --dry-run. Tests inject an Environment or use temporary homes so platform-specific planning can be checked without modifying the user's installation.", "author": "novusedge", "because": [["d34", "d38"]], "branch": "codex/native-installer-graph", "cost_if_wrong": "Mixing persistent writes into planning would make dry-run output unreliable and remove isolated cross-platform testing.", "id": "d41", "question": "How is the native installer structured for testing and dry runs?", "session": "", "state": "settled", "supersedes": ["d26"], "ts": "2026-09-12T02:09:32+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d26"], "mapped_supports": [["d34", "d38"]], "overrides": [], "source_answers": [], "source_because": [["d34", "d38"]], "source_depends_on": [], "source_supersedes": ["d26"]}, "source_id": "d41"}, "pinned": false, "rationale": "The Python installer is only a bootstrap launcher. The Go installer separates BuildPlan and PreparePlan from ExecutePlan, represents persistent operations as Action values, and skips ExecutePlan for --dry-run. Tests inject an Environment or use temporary homes so platform-specific planning can be checked without modifying the user's installation.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d26"], "supports": [["d34", "d38"]], "text": "How is the native installer structured for testing and dry runs?", "ts": "2026-09-12T02:09:32+00:00"} +{"alternatives": ["Let the Codex CLI own plugin registration: run codex plugin marketplace add , then codex plugin add docket@NovusEdge. Keep the repository marketplace named NovusEdge. Do not hand-edit Codex configuration. The end-to-end Codex installation evidence in d30 concerns the former 0.6.4 installer; it does not establish live native-installer integration coverage. Native planning is tested, but its real Codex registration still needs separate qualification."], "answers": [], "author": "codex", "branch": "main", "choice": "Let the Codex CLI own plugin registration: run codex plugin marketplace add , then codex plugin add docket@NovusEdge. Keep the repository marketplace named NovusEdge. Do not hand-edit Codex configuration. The end-to-end Codex installation evidence in d30 concerns the former 0.6.4 installer; it does not establish live native-installer integration coverage. Native planning is tested, but its real Codex registration still needs separate qualification.", "cost_if_wrong": "Changing marketplace identity breaks registration; treating historical tests as current coverage can hide harness integration failures.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d42", "kind": "decision", "legacy": {"raw": {"answer": "Let the Codex CLI own plugin registration: run codex plugin marketplace add , then codex plugin add docket@NovusEdge. Keep the repository marketplace named NovusEdge. Do not hand-edit Codex configuration. The end-to-end Codex installation evidence in d30 concerns the former 0.6.4 installer; it does not establish live native-installer integration coverage. Native planning is tested, but its real Codex registration still needs separate qualification.", "author": "codex", "because": [["d34", "d38"]], "branch": "main", "cost_if_wrong": "Changing marketplace identity breaks registration; treating historical tests as current coverage can hide harness integration failures.", "id": "d42", "question": "How does the native installer configure Codex?", "session": "", "state": "settled", "supersedes": ["d30"], "ts": "2026-09-12T02:27:26+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d30"], "mapped_supports": [["d34", "d38"]], "overrides": [], "source_answers": [], "source_because": [["d34", "d38"]], "source_depends_on": [], "source_supersedes": ["d30"]}, "source_id": "d42"}, "pinned": false, "rationale": "Let the Codex CLI own plugin registration: run codex plugin marketplace add , then codex plugin add docket@NovusEdge. Keep the repository marketplace named NovusEdge. Do not hand-edit Codex configuration. The end-to-end Codex installation evidence in d30 concerns the former 0.6.4 installer; it does not establish live native-installer integration coverage. Native planning is tested, but its real Codex registration still needs separate qualification.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d30"], "supports": [["d34", "d38"]], "text": "How does the native installer configure Codex?", "ts": "2026-09-12T02:27:26+00:00"} +{"alternatives": ["Merge PR #5 and reconcile the project ledger first, then explore richer metadata and efficient context delivery. Implement the selected design later on a separate branch. Dependency semantics and the context architecture are not selected yet; preserve the existing alternative conjunctive because sets during this design work."], "answers": [], "author": "codex", "branch": "main", "choice": "Merge PR #5 and reconcile the project ledger first, then explore richer metadata and efficient context delivery. Implement the selected design later on a separate branch. Dependency semantics and the context architecture are not selected yet; preserve the existing alternative conjunctive because sets during this design work.", "cost_if_wrong": "Mixing an unapproved schema or context redesign into the installer and graph work makes compatibility and validation harder to assess.", "decided_by": "user", "depends_on": [], "evidence": [], "id": "d43", "kind": "decision", "legacy": {"raw": {"answer": "Merge PR #5 and reconcile the project ledger first, then explore richer metadata and efficient context delivery. Implement the selected design later on a separate branch. Dependency semantics and the context architecture are not selected yet; preserve the existing alternative conjunctive because sets during this design work.", "author": "codex", "because": [["d17"]], "branch": "main", "cost_if_wrong": "Mixing an unapproved schema or context redesign into the installer and graph work makes compatibility and validation harder to assess.", "id": "d43", "question": "When should richer decision metadata and agent context delivery be implemented?", "session": "", "state": "settled", "supersedes": [], "ts": "2026-09-12T02:27:26+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d17"]], "overrides": [], "source_answers": [], "source_because": [["d17"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d43"}, "pinned": false, "rationale": "Merge PR #5 and reconcile the project ledger first, then explore richer metadata and efficient context delivery. Implement the selected design later on a separate branch. Dependency semantics and the context architecture are not selected yet; preserve the existing alternative conjunctive because sets during this design work.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "", "state": "adopted", "supersedes": [], "supports": [["d17"]], "text": "When should richer decision metadata and agent context delivery be implemented?", "ts": "2026-09-12T02:27:26+00:00"} +{"alternatives": ["No. The user explicitly permits breaking changes because Docket is still new. Design claim, decision, and question records and their IDs without legacy-format compatibility requirements. This does not authorize discarding the existing project history; decide how to migrate our ledger explicitly when implementing the redesign."], "answers": [], "author": "codex", "branch": "main", "choice": "No. The user explicitly permits breaking changes because Docket is still new. Design claim, decision, and question records and their IDs without legacy-format compatibility requirements. This does not authorize discarding the existing project history; decide how to migrate our ledger explicitly when implementing the redesign.", "cost_if_wrong": "Existing ledgers and integrations may need an explicit migration or update when the new format ships.", "decided_by": "user", "depends_on": [], "evidence": [], "id": "d44", "kind": "decision", "legacy": {"raw": {"answer": "No. The user explicitly permits breaking changes because Docket is still new. Design claim, decision, and question records and their IDs without legacy-format compatibility requirements. This does not authorize discarding the existing project history; decide how to migrate our ledger explicitly when implementing the redesign.", "author": "codex", "because": [], "branch": "main", "cost_if_wrong": "Existing ledgers and integrations may need an explicit migration or update when the new format ships.", "id": "d44", "question": "Must the typed ledger redesign preserve compatibility with the current format?", "session": "", "state": "settled", "supersedes": [], "ts": "2026-09-12T02:35:52+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d44"}, "pinned": true, "rationale": "No. The user explicitly permits breaking changes because Docket is still new. Design claim, decision, and question records and their IDs without legacy-format compatibility requirements. This does not authorize discarding the existing project history; decide how to migrate our ledger explicitly when implementing the redesign.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "Must the typed ledger redesign preserve compatibility with the current format?", "ts": "2026-09-12T02:35:52+00:00"} +{"schema":2,"kind":"decision","id":"d45","text":"What record and context model should Docket 0.8.0 use?","state":"adopted","ts":"2026-09-12T02:49:48+00:00","author":"codex","session":"","branch":"codex/typed-ledger-context","scope":["lib/**","bin/docket"],"rationale":"The user approved typed records, waived backward compatibility, and authorized implementation and release. Keep historical claims distinct from commitments and preserve the original ledger during explicit migration.","supports":[["d17","d44"]],"depends_on":[],"answers":[],"supersedes":["d43"],"evidence":[],"revisit":"","cost_if_wrong":"Incorrect typing or lost relationship semantics could cause later agents to treat uncertain premises as commitments or silently forget constraints.","pinned":true,"choice":"Use explicit claims, decisions, and questions with type-specific states, scoped evidence, separate support and prerequisite relations, and bounded task context. Implement the breaking schema on its own branch, then verify, review, merge, and release it.","alternatives":["Use explicit claims, decisions, and questions with type-specific states, scoped evidence, separate support and prerequisite relations, and bounded task context. Implement the breaking schema on its own branch, then verify, review, merge, and release it."],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d46","text":"What Python version does Docket require?","state":"adopted","ts":"2026-09-12T15:04:41+00:00","author":"claude-code","session":"session_01EQrSFLFsHa2FVUYvbCXqRW","branch":"feat/briefing-core","scope":["installer/**","docs/installation.md","README.md","lib/**"],"rationale":"TOML reads better for hand-edited configuration than JSON, and vendoring tomli would add about a thousand lines of third-party code to own. Python 3.10 reaches end of life in October 2026.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Anyone running Python 3.10 must upgrade before installing or updating Docket.","pinned":false,"choice":"Python 3.11 or later. The floor rises from 3.10 so the ledger CLI can read tunable configuration from .docket/config.toml with stdlib tomllib, keeping the zero-dependency promise.","alternatives":["Python 3.11 or later. The floor rises from 3.10 so the ledger CLI can read tunable configuration from .docket/config.toml with stdlib tomllib, keeping the zero-dependency promise.","Keep 3.10 and use JSON configuration","Keep 3.10 and vendor a TOML parser"],"decided_by":""} +{"schema":2,"kind":"claim","id":"c47","text":"Grounded semantics is the only standard argumentation semantics that yields a unique extension in polynomial time.","state":"accepted","ts":"2026-09-12T20:50:30+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"Least fixpoint of the defended operator. Preferred and stable semantics admit many extensions and sit at Sigma-2-p for credulous acceptance.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://arxiv.org/pdf/1310.4986"}],"revisit":"","cost_if_wrong":"A non-unique resolver would make a briefing show several incompatible answers.","pinned":false} +{"schema":2,"kind":"claim","id":"c48","text":"AGM iterated belief revision is order-dependent in general, and no fully satisfactory iterated-revision theory exists.","state":"accepted","ts":"2026-09-12T20:50:30+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"Darwiche-Pearl repairs part of it by operating on epistemic states, then conflicts with Parikh relevance-sensitivity.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://www.ijcai.org/proceedings/2019/0209.pdf"}],"revisit":"","cost_if_wrong":"A sequential conflict resolver would give session-order-dependent ledgers.","pinned":false} +{"schema":2,"kind":"claim","id":"c49","text":"ATMS-style labels over assumption environments are exponential in the worst case.","state":"accepted","ts":"2026-09-12T20:50:31+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"A node label is the set of minimal environments supporting it. Practical systems prune with nogoods.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://dekleer.org/Publications/An%20Assumption-Based%20TMS.pdf"}],"revisit":"","cost_if_wrong":"Uncapped alternative justification sets make briefing cost unbounded.","pinned":false} +{"schema":2,"kind":"claim","id":"c50","text":"Information placed mid-context loses over 30 percent recall, and related items lose further recall as the distance between them grows.","state":"accepted","ts":"2026-09-12T20:50:43+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/docket_context.py"],"rationale":"Lost-in-the-middle, independently replicated, plus the lost-in-distance follow-up.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://arxiv.org/pdf/2410.01985"}],"revisit":"","cost_if_wrong":"An arrangement that buries high-priority records in the middle of the budget wastes them.","pinned":false} +{"schema":2,"kind":"claim","id":"c51","text":"Leiden and Louvain community detection is unstable on sparse knowledge graphs, because exponentially many partitions have near-equal modularity.","state":"accepted","ts":"2026-09-12T20:50:43+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/docket_context.py"],"rationale":"Reported against GraphRAG. Independent audit also finds its published retrieval gains overstated.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://arxiv.org/pdf/2506.06331"}],"revisit":"","cost_if_wrong":"A community-grouped briefing would reorder itself between runs and break the reproducibility the settings fingerprint promises.","pinned":false} +{"schema":2,"kind":"claim","id":"c52","text":"Bitemporal storage separates valid time from transaction time, so a correction appends a new record and destroys no history.","state":"accepted","ts":"2026-09-12T20:50:43+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/docket_ledger.py"],"rationale":"XTDB models both axes orthogonally. Datomic manages transaction time only.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://v1-docs.xtdb.com/concepts/bitemporality/"}],"revisit":"","cost_if_wrong":"Without valid time, a correction cannot restate when a decision actually held.","pinned":false} +{"schema":2,"kind":"decision","id":"d53","text":"How does Docket decide which records survive a conflict?","state":"adopted","ts":"2026-09-12T20:50:58+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"A fixpoint over the whole graph makes confluence definitional, so no per-topology confluence proof is needed.","supports":[],"depends_on":["c47","c48"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Switching to a non-unique semantics later would make a briefing ambiguous and invalidate the Lean obligations.","pinned":false,"choice":"Compute the grounded extension of a bipolar argumentation framework over the ledger. Conflicts enter as declared attack witnesses, never by pairwise discovery. The result is a least fixpoint, so it is unique and independent of the order conflicts were found.","alternatives":["Compute the grounded extension of a bipolar argumentation framework over the ledger. Conflicts enter as declared attack witnesses, never by pairwise discovery. The result is a least fixpoint, so it is unique and independent of the order conflicts were found.","Sequential revision with a proven-confluent cascade","Preferred or stable extensions"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d54","text":"Does the storage layout depend on the chosen topology?","state":"adopted","ts":"2026-09-12T20:50:58+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"Keeping storage topology-agnostic makes the topology a view, which is cheap to be wrong about.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Topology-specific storage would make every switch a migration and every experiment expensive.","pinned":false,"choice":"No. One canonical append-only record format holds every ledger. A topology is a pure function from stored records to an arrangement, so switching topologies is lossless, reversible, and runs no migration.","alternatives":["No. One canonical append-only record format holds every ledger. A topology is a pure function from stored records to an arrangement, so switching topologies is lossless, reversible, and runs no migration."],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d55","text":"How much of the resolver may a topology change?","state":"adopted","ts":"2026-09-12T20:50:59+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"Per-topology resolvers grow proof debt without bound and let a new topology silently revoke records.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Per-topology resolvers would need a termination and confluence proof each.","pinned":false,"choice":"None of the algorithm. One resolver implementation is parameterized by the cascade edge kinds and a winner ordering. A topology supplies those parameters and its arrangement function only. The proof is universally quantified over the parameter space, and each hypothesis becomes a load-time validation.","alternatives":["None of the algorithm. One resolver implementation is parameterized by the cascade edge kinds and a winner ordering. A topology supplies those parameters and its arrangement function only. The proof is universally quantified over the parameter space, and each hypothesis becomes a load-time validation.","Each topology carries its own conflict predicate and cascade","Resolver fixed with no parameters at all"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d56","text":"How does Docket represent support so retraction stops over-retracting?","state":"adopted","ts":"2026-09-12T20:51:18+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/docket_ledger.py"],"rationale":"A flat list cannot mean both joint requirement and independent alternative. The stress tests already proved it over-retracts.","supports":[],"depends_on":["c49"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Uncapped alternative sets reintroduce the exponential ATMS label blowup.","pinned":false,"choice":"Replace the flat supports list with a set of justification sets. A record survives while any one complete set survives. Today's flat list becomes a single set. Cap the number of alternative sets per record so the label space stays bounded.","alternatives":["Replace the flat supports list with a set of justification sets. A record survives while any one complete set survives. Today's flat list becomes a single set. Cap the number of alternative sets per record so the label space stays bounded."],"decided_by":""} +{"schema":2,"kind":"decision","id":"d57","text":"How does Docket keep briefings cheap when many agents share one project?","state":"adopted","ts":"2026-09-12T20:51:19+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"Grounded extension is O(n+e) with a worklist, so contention and repeated full scans cost more than the algorithm.","supports":[],"depends_on":["c47"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Recomputing the global layer per agent would multiply cost by the number of agents.","pinned":false,"choice":"Cache the grounded extension and the causal attribution under the ledger revision hash, because both are global and identical for every agent. Recompute scoring and arrangement per query, since they are local and need no coordination. Appends stay serialized behind the existing lock.","alternatives":["Cache the grounded extension and the causal attribution under the ledger revision hash, because both are global and identical for every agent. Recompute scoring and arrangement per query, since they are local and need no coordination. Appends stay serialized behind the existing lock."],"decided_by":""} +{"schema":2,"kind":"question","id":"q58","text":"Which topologies ship as built-ins, and what is the per-project default?","state":"open","ts":"2026-09-12T20:51:19+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"Bus, star, tree, mesh and hybrid map onto real ledger structure. Ring has no mapping because support graphs are acyclic. Frontier, justification-closure and conflict-first are use-case shaped and have no network name.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} +{"schema":2,"kind":"question","id":"q59","text":"At what record count does archival become required, and how is the archive addressed?","state":"open","ts":"2026-09-12T20:51:28+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/docket_ledger.py"],"rationale":"Selection and compaction tiers already work. Archival must keep the pruned set closed downward under every relation field, or the retraction closure computes the wrong survivors.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} +{"schema":2,"kind":"claim","id":"c60","text":"cost_if_wrong is free text and cannot order records: it is unscored, and 11 of 47 current records leave it empty.","state":"accepted","ts":"2026-09-12T22:17:33+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_context.py"],"rationale":"Validated as any string in docket_ledger.py. No weight in docket_config.py reads it.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"An arrangement that sorts by it would sort English prose lexicographically.","pinned":false} +{"schema":2,"kind":"claim","id":"c61","text":"A retired record never reaches a briefing on its own account, because docket_context.py excludes it from the scored set before selection.","state":"accepted","ts":"2026-09-12T22:17:33+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_context.py"],"rationale":"It enters only when a live record cites it through supports, depends_on, answers or resolved_by. supersedes is not an expansion edge.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Any feature that collapses or reorders superseded records addresses a case that does not occur.","pinned":false} +{"schema":2,"kind":"claim","id":"c62","text":"The lost-in-the-middle finding governs position in the whole context window, and the briefing is injected whole at session start.","state":"disputed","ts":"2026-09-12T22:17:33+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_context.py"],"rationale":"Placing high scorers at the edges of an 8000-character briefing is therefore unverified. The end of a briefing is its index block and footer.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Reordering records for recall could spend effort on an effect that does not apply at document scale.","pinned":false} +{"schema":2,"kind":"decision","id":"d63","text":"What does the first topology-related change actually build?","state":"adopted","ts":"2026-09-12T22:17:45+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_context.py"],"rationale":"Supersession is the only structure this ledger grows: 11 superseding records over 12 edges in 59 records. The three parts of the frontier proposal each rest on something the code or the data refutes.","supports":[],"depends_on":["c60","c61","c62"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Building the arrangement layer first would ship configuration for an ordering nobody has asked for.","pinned":false,"choice":"Render the full transitive supersede chain on the record that heads it. A record prints its direct supersedes targets and stops, so d27 -> d29 -> d35 reads as a two-record commitment. This reads edges the ledger already holds, changes no schema, and adds no configuration. The arrangement layer, ordering rules and additional topologies are deferred until a second ordering has a reason to exist.","alternatives":["Render the full transitive supersede chain on the record that heads it. A record prints its direct supersedes targets and stops, so d27 -> d29 -> d35 reads as a two-record commitment. This reads edges the ledger already holds, changes no schema, and adds no configuration. The arrangement layer, ordering rules and additional topologies are deferred until a second ordering has a reason to exist.","Frontier arrangement: collapse superseded records, order by cost_if_wrong, place high scorers at the budget edges","Build the resolver and attack witnesses first"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d64","text":"Where do design specs live?","state":"adopted","ts":"2026-09-12T22:17:45+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["docs/**"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Tracking specs would duplicate the ledger's job and leave two places to check for a decision.","pinned":false,"choice":"Keep specs untracked. docs/superpowers/ is gitignored, and decisions belong in the ledger where they carry provenance, supersession and scope. A spec is a working document; the ledger is the record.","alternatives":["Keep specs untracked. docs/superpowers/ is gitignored, and decisions belong in the ledger where they carry provenance, supersession and scope. A spec is a working document; the ledger is the record."],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d65","text":"How does Docket represent support, and what bounds the label space?","state":"adopted","ts":"2026-09-12T22:17:53+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_ledger.py"],"rationale":"d56 proposed replacing a flat supports list. That replacement had already shipped; docket_ledger.py validates supports as a list of conjunctive ID lists.","supports":[],"depends_on":["c49"],"answers":[],"supersedes":["d56"],"evidence":[],"revisit":"","cost_if_wrong":"Recording shipped work as pending sends a later agent to build it twice.","pinned":false,"choice":"Support is already a list of conjunctive ID lists, so alternative justification sets exist in the schema today and the c10 over-retraction is fixed. No record uses more than one set. What remains open is the cardinality cap that bounds the ATMS label space; its number waits for the first record that needs a second set.","alternatives":["Support is already a list of conjunctive ID lists, so alternative justification sets exist in the schema today and the c10 over-retraction is fixed. No record uses more than one set. What remains open is the cardinality cap that bounds the ATMS label space; its number waits for the first record that needs a second set."],"decided_by":""} +{"schema":2,"kind":"decision","id":"d66","text":"When does Docket tell someone that archival is due?","state":"adopted","ts":"2026-09-12T22:21:01+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_ledger.py"],"rationale":"Measured on synthetic ledgers after the briefing became linear in ledger size. 100000 records render in about 21 seconds.","supports":[],"depends_on":[],"answers":["q59"],"supersedes":[],"evidence":[{"ref":"fix/briefing-scaling benchmark, 2026-09-13"}],"revisit":"","cost_if_wrong":"A threshold set far from where latency actually bites would train people to ignore the warning.","pinned":false,"choice":"Warn at 10000 records. The briefing renders 1000 records in 0.41s and 10000 in 1.84s, so a session start stays under a second below the threshold and becomes noticeable above it. The warning names the count and the command; it never archives on its own. Archival itself stays unbuilt.","alternatives":["Warn at 10000 records. The briefing renders 1000 records in 0.41s and 10000 in 1.84s, so a session start stays under a second below the threshold and becomes noticeable above it. The warning names the count and the command; it never archives on its own. Archival itself stays unbuilt.","No threshold: selection and compaction suffice indefinitely","A hard limit that refuses to render"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d67","text":"Which topologies ship, and what is the per-project default?","state":"adopted","ts":"2026-09-12T22:21:22+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/**"],"rationale":"Every candidate dissolved against the ledger data. A scope tree is nearly flat at 12 overlapping scopes. Mesh is the absence of arrangement. Closure has no audit to serve. Building a seam for zero implementations gets the seam wrong.","supports":[],"depends_on":["c60","c61","c62","d63"],"answers":["q58"],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Config surface and a validation path, once shipped, are carried whether or not anyone uses them. Deferring costs only the work of building later.","pinned":false,"choice":"None ship, and there is no default. The arrangement layer stays unbuilt and the config surface stays at zero. The trigger to revisit is a second ordering someone actually wants after supersede chains become legible through d63.","alternatives":["None ship, and there is no default. The arrangement layer stays unbuilt and the config surface stays at zero. The trigger to revisit is a second ordering someone actually wants after supersede chains become legible through d63.","Ship frontier as behaviour with no config","Ship frontier and a scope tree, selectable per project"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d68","text":"What does the first topology-related change actually build?","state":"adopted","ts":"2026-09-12T22:22:12+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_context.py"],"rationale":"Supersession is the only structure this ledger grows: 11 superseding records over 12 edges in 59 records. The three parts of the frontier proposal each rest on something the code or the data refutes. Recorded against d63, which used depends_on for the same claims and so read them as prerequisites rather than as justification.","supports":[["c60","c61","c62"]],"depends_on":[],"answers":[],"supersedes":["d63"],"evidence":[],"revisit":"","cost_if_wrong":"Building the arrangement layer first would ship configuration for an ordering nobody has asked for.","pinned":false,"choice":"Render the full transitive supersede chain on the record that heads it. A record prints its direct supersedes targets and stops, so d27 -> d29 -> d35 reads as a two-record commitment. This reads edges the ledger already holds, changes no schema, and adds no configuration. The arrangement layer, ordering rules and additional topologies are deferred until a second ordering has a reason to exist.","alternatives":["Render the full transitive supersede chain on the record that heads it. A record prints its direct supersedes targets and stops, so d27 -> d29 -> d35 reads as a two-record commitment. This reads edges the ledger already holds, changes no schema, and adds no configuration. The arrangement layer, ordering rules and additional topologies are deferred until a second ordering has a reason to exist.","Frontier arrangement: collapse superseded records, order by cost_if_wrong, place high scorers at the budget edges","Build the resolver and attack witnesses first"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d69","text":"Which topologies ship, and what is the per-project default?","state":"adopted","ts":"2026-09-12T22:22:21+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/**"],"rationale":"Every candidate dissolved against the ledger data. A scope tree is nearly flat at 12 overlapping scopes. Mesh is the absence of arrangement. Closure has no audit to serve. Building a seam for zero implementations gets the seam wrong.","supports":[["c60","c61","c62","d68"]],"depends_on":[],"answers":["q58"],"supersedes":["d67"],"evidence":[],"revisit":"","cost_if_wrong":"Config surface and a validation path, once shipped, are carried whether or not anyone uses them. Deferring costs only the work of building later.","pinned":false,"choice":"None ship, and there is no default. The arrangement layer stays unbuilt and the config surface stays at zero. The trigger to revisit is a second ordering someone actually wants after supersede chains become legible through d68.","alternatives":["None ship, and there is no default. The arrangement layer stays unbuilt and the config surface stays at zero. The trigger to revisit is a second ordering someone actually wants after supersede chains become legible through d68.","Ship frontier as behaviour with no config","Ship frontier and a scope tree, selectable per project"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d70","text":"What bounds label growth under alternative justifications?","state":"adopted","ts":"2026-09-12T22:23:37+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_ledger.py"],"rationale":"d65 named a cap as the mechanism. A cap refuses a valid justification to save space, which corrupts the record. Minimality is the standard ATMS answer and is lossless, though de Kleer shows minimal labels still grow exponentially in the worst case, so a backstop remains.","supports":[["c49"]],"depends_on":[],"answers":["q18"],"supersedes":["d65"],"evidence":[{"ref":"https://dekleer.org/Publications/An%20Assumption-Based%20TMS.pdf"}],"revisit":"","cost_if_wrong":"Capping without subsumption would drop justifications that subsumption would have shown redundant.","pinned":false,"choice":"Reconcile by subsumption, and keep a cardinality cap only as a backstop. When a record gains a justification set, drop any stored set that is a strict superset of another, which is how an ATMS keeps labels minimal. Subsumption never discards a justification the remaining sets do not already imply. A cap does discard one, so it fires last and should never fire. Neither is built: no record carries a second justification set.","alternatives":["Reconcile by subsumption, and keep a cardinality cap only as a backstop. When a record gains a justification set, drop any stored set that is a strict superset of another, which is how an ATMS keeps labels minimal. Subsumption never discards a justification the remaining sets do not already imply. A cap does discard one, so it fires last and should never fire. Neither is built: no record carries a second justification set.","A hard cardinality cap alone","No bound; accept exponential growth"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d71","text":"When does the project ledger get committed?","state":"adopted","ts":"2026-09-12T23:27:53+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["justfile"],"rationale":"Only main ever commits the ledger, so two branches appending records never conflict and docket rebase is not needed here. A release snapshot also matches what a reader of that tag would want: the decisions in force at the time.","supports":[["d64"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A decision recorded and then lost before a release never reaches the repository. The working tree carries it in the meantime.","pinned":false,"choice":"Only at a release. The ledger is tracked, and just release stages .docket alongside the version bump, so each release ships a snapshot of the decisions that held when it went out. Between releases the ledger stays dirty in the working tree; the release recipe excludes .docket from its clean check and refuses any other uncommitted change.","alternatives":["Only at a release. The ledger is tracked, and just release stages .docket alongside the version bump, so each release ships a snapshot of the decisions that held when it went out. Between releases the ledger stays dirty in the working tree; the release recipe excludes .docket from its clean check and refuses any other uncommitted change.","Commit the ledger with every change that records a decision","Keep the ledger untracked"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d72","text":"Where do the published docs live, and how is the GitBook site structured?","state":"adopted","ts":"2026-09-13T15:03:56+00:00","author":"claude-code","session":"session_01Ba1BDHfYkW4jh4foQWhT1K","branch":"main","scope":["docs/**","gitbook-docs.yaml"],"rationale":"The flat directory already held every public page. SUMMARY.md gives sidebar grouping without moving a file or breaking an internal link.","supports":[["d21"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Moving pages into subdirectories later means new space keys, a changed site URL for every page, and rewritten internal links.","pinned":false,"choice":"Keep every published page flat in docs/ and map it onto a single GitBook space through gitbook-docs.yaml at the repository root. docs/SUMMARY.md groups the sidebar; GitBook sections would require subdirectories and rewritten links for no gain. docs/superpowers/ stays gitignored, so GitBook cannot see it. experiments/ and installer/PORT.md stay unpublished.","alternatives":["Keep every published page flat in docs/ and map it onto a single GitBook space through gitbook-docs.yaml at the repository root. docs/SUMMARY.md groups the sidebar; GitBook sections would require subdirectories and rewritten links for no gain. docs/superpowers/ stays gitignored, so GitBook cannot see it. experiments/ and installer/PORT.md stay unpublished."],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d73","text":"How are the published docs split between plain guides and technical pages?","state":"adopted","ts":"2026-09-13T15:04:05+00:00","author":"claude-code","session":"session_01Ba1BDHfYkW4jh4foQWhT1K","branch":"main","scope":["docs/**"],"rationale":"The pre-existing docs were reference and design notes, so no page carried a new user from install to daily use.","supports":[["d21"]],"depends_on":["d72"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Mixing reference detail back into the guides returns the docs to a state where a new user has no entry point.","pinned":false,"choice":"Lead with task guides written in plain declarative English: quickstart, recording, reading, agents, commands, maintenance. Keep the reference and design notes as separate pages that a guide links to when a reader wants depth. Avoid em dashes unless a sentence genuinely needs one, and prefer flowing sentences over comma-heavy ones.","alternatives":["Lead with task guides written in plain declarative English: quickstart, recording, reading, agents, commands, maintenance. Keep the reference and design notes as separate pages that a guide links to when a reader wants depth. Avoid em dashes unless a sentence genuinely needs one, and prefer flowing sentences over comma-heavy ones."],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d74","text":"How should user-facing documentation be written and checked?","state":"adopted","ts":"2026-09-13T15:18:45+00:00","author":"novusedge","session":"","branch":"main","scope":["docs/**","README.md"],"rationale":"The user clarified the requested Stoat-style documentation treatment and explicitly requested a hallucination check.","supports":[["d73"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Strict reference-style prose can make the guides hard to use, while unsupported examples can send readers down a broken setup path.","pinned":false,"choice":"Use simple, flowing, declarative English and task-based guides. Keep technical depth on separate optional pages. Apply de-slopify lightly to these guides, avoid unnecessary em dashes, and check generated claims and examples against source, executable walkthroughs, or current primary references.","alternatives":["Use simple, flowing, declarative English and task-based guides. Keep technical depth on separate optional pages. Apply de-slopify lightly to these guides, avoid unnecessary em dashes, and check generated claims and examples against source, executable walkthroughs, or current primary references."],"decided_by":"novusedge"} +{"schema":2,"kind":"question","id":"q75","text":"How should the installer place the OpenCode plugin so automatic discovery finds it?","state":"open","ts":"2026-09-13T15:19:14+00:00","author":"novusedge","session":"","branch":"main","scope":["installer/planner.go","docs/integrations.md"],"rationale":"planOpenCode writes plugins/docket/index.ts. The current upstream OpenCode ConfigPlugin loader scans {plugin,plugins}/*.{ts,js}. The docs now explain manual placement at plugins/docket.ts; the installer implementation still needs correction.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"installer/planner.go: planOpenCode"},{"ref":"https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/config/plugin.ts"}],"revisit":"","cost_if_wrong":"A completed install can leave OpenCode without a Docket briefing.","pinned":false} +{"schema":2,"kind":"decision","id":"d76","text":"How should users start Docket setup?","state":"adopted","ts":"2026-09-13T15:31:23+00:00","author":"codex","session":"","branch":"feat/harness-update-refresh","scope":["docs/installation.md","docs/agent-setup.md","README.md"],"rationale":"The user asked for harness-based setup and one place an agent can follow from a pasted prompt, with explicit requirements. Source inspection shows that a temporary repository clone would leave the installation pointing into that clone; only the downloaded launcher should be temporary.","supports":[["d74","d38","d37"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Users may install unnecessary build tools, configure the wrong harness, or break a source installation by removing its temporary checkout.","pinned":false,"choice":"Lead the installation docs and README with a pasteable setup prompt that points agents to docs/agent-setup.md. Use one shared agent setup procedure for the terminal command, viewer, and current harness. Keep manual harness routes and a permanent source-clone option available, and state Python 3.11+, Git, and the source-build-only Go 1.26+ requirement by route.","alternatives":["Lead the installation docs and README with a pasteable setup prompt that points agents to docs/agent-setup.md. Use one shared agent setup procedure for the terminal command, viewer, and current harness. Keep manual harness routes and a permanent source-clone option available, and state Python 3.11+, Git, and the source-build-only Go 1.26+ requirement by route."],"decided_by":"novusedge"} +{"schema":2,"kind":"decision","id":"d77","text":"How do users trigger a Docket update?","state":"adopted","ts":"2026-09-13T15:32:58+00:00","author":"claude-code","session":"session_01Ba1BDHfYkW4jh4foQWhT1K","branch":"feat/harness-update-refresh","scope":["bin/docket","installer/**"],"rationale":"The notice must name one command the user can run, and the command differs per install shape. Mutating a running plugin mid-session is unsafe, so the plugin-only shape prints.","supports":[],"depends_on":[],"answers":[],"supersedes":["d40"],"evidence":[],"revisit":"","cost_if_wrong":"A subcommand that resolves the wrong shape can run a git update against a contributor's tree or fail without Go.","pinned":false,"choice":"Docket provides a docket update subcommand. It resolves the update path from the detected install shape. A managed checkout downloads the current launcher and runs it outside the checkout, because the bundled install.py takes the --checkout branch and skips the git update. A contributor source tree rebuilds in place. A plugin-only copy prints the harness command and changes nothing, because docket is not on that user's PATH.","alternatives":["Docket provides a docket update subcommand. It resolves the update path from the detected install shape. A managed checkout downloads the current launcher and runs it outside the checkout, because the bundled install.py takes the --checkout branch and skips the git update. A contributor source tree rebuilds in place. A plugin-only copy prints the harness command and changes nothing, because docket is not on that user's PATH.","Keep installer/install.py --update as the only entry point","Run the harness CLI on the user's behalf from a plugin-only shape"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d78","text":"How does a user learn that a Docket update is available?","state":"adopted","ts":"2026-09-13T15:33:20+00:00","author":"claude-code","session":"session_01Ba1BDHfYkW4jh4foQWhT1K","branch":"feat/harness-update-refresh","scope":["bin/docket","lib/**","hooks/**"],"rationale":"d40 prohibited a session-start network check and is retired by d77. The hook runs from the stale copy itself, so that copy's own VERSION reveals staleness and a separate drift check adds no information. An inline fetch would block a 5-second hook, and an inherited pipe would stall it and inject child output into the session context.","supports":[["d77"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A child that inherits stdout stalls every session to the hook timeout and corrupts the JSON envelope for gemini, copilot, and cursor.","pinned":false,"choice":"The session hook reads a cached upstream release tag and prints at most one notice line, naming the command for the running copy's install shape. It never performs a network request inline. When the cache is due it forks a detached child with no inherited pipes and prints the value it already holds. A single next_check_at field carries the 24-hour TTL, the failure backoff, and a 5-minute concurrency lease. DOCKET_NO_UPDATE_CHECK=1 disables the fetch and the notice.","alternatives":["The session hook reads a cached upstream release tag and prints at most one notice line, naming the command for the running copy's install shape. It never performs a network request inline. When the cache is due it forks a detached child with no inherited pipes and prints the value it already holds. A single next_check_at field carries the 24-hour TTL, the failure backoff, and a 5-minute concurrency lease. DOCKET_NO_UPDATE_CHECK=1 disables the fetch and the notice.","No notification; users run the update command when they choose to","Synchronous version check at session start","Compare the running copy against an installer-recorded version instead of upstream"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d79","text":"How should Docket's Python modules be organised?","state":"adopted","ts":"2026-09-13T16:24:24+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/harness-update-refresh","scope":["lib/**","bin/docket"],"rationale":"Four sites pay for the dual import today: lib/docket_context.py:21-24, lib/docket_rebase.py:22-25, and lib/docket_migrate.py:444-455 and 529-536. A comment at lib/docket_migrate.py:531 states that no module-level import satisfies both forms. Relative imports inside a package resolve the same way however the package was found. Design reviewed over three adversarial passes; see docs/superpowers/specs/2026-09-13-docket-package-design.md.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Renaming lib/ invalidates scope paths on the project's own ledger records, so a briefing loses scope-based selection until scripts/rescope_ledger.py runs. Rewriting scopes also shifts inverse document frequency for every record, because _haystack folds scope into searchable text.","pinned":false,"choice":"Convert lib/ into a contained docket package holding the five existing modules plus the CLI. bin/docket becomes a ten-line launcher that inserts the repo root on sys.path and calls docket.cli.main(). Imports inside the package are relative, which removes the four dual-import hack sites. The CLI splits into cli/{__init__,term,record,query,graph,admin}.py, with env.py for environment and ledger-location resolution.","alternatives":["Convert lib/ into a contained docket package holding the five existing modules plus the CLI. bin/docket becomes a ten-line launcher that inserts the repo root on sys.path and calls docket.cli.main(). Imports inside the package are relative, which removes the four dual-import hack sites. The CLI splits into cli/{__init__,term,record,query,graph,admin}.py, with env.py for environment and ledger-location resolution.","Leave the flat lib/ modules and keep the try/except ImportError and runtime importlib hacks","Package the tool with zipapp into a single docket.pyz"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d80","text":"Should a Go launcher binary replace the bin/docket shebang?","state":"adopted","ts":"2026-09-13T16:24:31+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/harness-update-refresh","scope":["installer/**","bin/docket"],"rationale":"A launcher cannot remove the Python dependency, because d38 keeps the ledger CLI in Python. It would add six release builds to match the viewer matrix and duplicate what the cmd shim already does.","supports":[["d38"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"env.Python is captured at install time, so a user who removes that interpreter breaks the shim until reinstall. A launcher resolving Python at each run would fix that specific case; it stays open as a separate question.","pinned":false,"choice":"No. Keep bin/docket as a Python script with a shebang. The installer already writes a docket.cmd shim on Windows naming the interpreter absolutely (installer/planner.go:144) and symlinks the script on Unix (installer/planner.go:150-160), so the shebang is never consulted where it would fail.","alternatives":["No. Keep bin/docket as a Python script with a shebang. The installer already writes a docket.cmd shim on Windows naming the interpreter absolutely (installer/planner.go:144) and symlinks the script on Unix (installer/planner.go:150-160), so the shebang is never consulted where it would fail.","Ship a per-platform Go launcher binary that locates Python and execs bin/docket"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d81","text":"How should the installer place the OpenCode plugin so automatic discovery finds it?","state":"adopted","ts":"2026-09-13T19:17:56+00:00","author":"claude-code","session":"session_01Ba1BDHfYkW4jh4foQWhT1K","branch":"feat/harness-update-refresh","scope":["installer/planner.go","docs/integrations.md"],"rationale":"The installed opencode binary globs {plugin,plugins}/*.{ts,js} with include:\"file\". One star and a file-only filter mean a nested docket/index.ts never loads, so every OpenCode install before this shipped a plugin OpenCode ignored.","supports":[],"depends_on":[],"answers":["q75"],"supersedes":[],"evidence":[{"ref":"grep of ~/.opencode/bin/opencode: glob(\"{plugin,plugins}/*.{ts,js}\", {include:\"file\", symlink:true})"}],"revisit":"","cost_if_wrong":"Placing the file one level down leaves OpenCode with no Docket briefing and no error.","pinned":false,"choice":"Write the plugin as a single file at ~/.config/opencode/plugins/docket.ts. Remove the pre-0.11.0 plugins/docket/index.ts on install, update, and uninstall.","alternatives":["Write the plugin as a single file at ~/.config/opencode/plugins/docket.ts. Remove the pre-0.11.0 plugins/docket/index.ts on install, update, and uninstall.","Keep plugins/docket/index.ts and document manual placement"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d82","text":"How should Docket's Python modules be organised?","state":"adopted","ts":"2026-09-13T19:51:29+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/docket-package","scope":["docket/**","bin/docket"],"rationale":"The user requires absolute imports throughout. One module carries one canonical name, which resolves identically from every entry point once the repo root is on sys.path. d79 said relative. The module count is six, not five: docket_update.py shipped in 0.11.0 after the spec was written.","supports":[],"depends_on":[],"answers":[],"supersedes":["d79"],"evidence":[],"revisit":"","cost_if_wrong":"Absolute imports depend on the repo root being importable. No conftest.py exists, so the test suite resolves the package only because unittest discover runs with the repo root as cwd.","pinned":false,"choice":"Convert lib/ into a contained docket package holding the six existing modules plus the CLI. bin/docket becomes a launcher that inserts the repo root on sys.path and calls docket.cli.main(). Every import is absolute, inside the package included: from docket.config import DEFAULTS, never from .config import DEFAULTS. The CLI splits into cli/{__init__,term,record,query,graph,admin}.py, with env.py for environment and ledger-location resolution.","alternatives":["Convert lib/ into a contained docket package holding the six existing modules plus the CLI. bin/docket becomes a launcher that inserts the repo root on sys.path and calls docket.cli.main(). Every import is absolute, inside the package included: from docket.config import DEFAULTS, never from .config import DEFAULTS. The CLI splits into cli/{__init__,term,record,query,graph,admin}.py, with env.py for environment and ledger-location resolution.","Relative imports inside the package, as d79 recorded","Leave the flat lib/ modules and keep the try/except ImportError and runtime importlib hacks","Package the tool with zipapp into a single docket.pyz"],"decided_by":"user"} diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index 8fe5916..6cb1a5e 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -9,15 +9,15 @@ {"answers": [], "author": "", "branch": "", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c9", "kind": "claim", "legacy": {"raw": {"answer": "Yes, if every member of j(γ) is required jointly. Defining removal as the least set containing the retracted premise and closed under reverse support edges makes survivors closed under j, and the removed set is contained in every other set satisfying those rules.", "because": ["d2"], "cost_if_wrong": "", "id": "d9", "question": "Is transitive retraction sound and minimal for j : Γ → 2^Γ?", "session": "", "state": "settled", "ts": "2026-09-06T18:17:38+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d2"]], "overrides": [], "source_answers": [], "source_because": ["d2"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d9"}, "pinned": false, "rationale": "Yes, if every member of j(γ) is required jointly. Defining removal as the least set containing the retracted premise and closed under reverse support edges makes survivors closed under j, and the removed set is contained in every other set satisfying those rules.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [["d2"]], "text": "Transitive retraction is sound and minimal for conjunctive justification: every member of the support set must be required jointly.", "ts": "2026-09-06T18:17:38+00:00"} {"answers": [], "author": "", "branch": "", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c10", "kind": "claim", "legacy": {"raw": {"answer": "No. Counterexample: Q has independent supports A and B. Retracting A removes Q by the transitive-dependent rule even though B survives and still supports Q. Phase 4 must either define j(γ) conjunctively or represent alternatives as a set of justification sets and retain γ while any complete justification survives.", "because": ["d9"], "cost_if_wrong": "", "id": "d10", "question": "Can a flat support set represent alternative justifications without over-retraction?", "session": "", "state": "ruled-out", "ts": "2026-09-06T18:17:45+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c9"]], "overrides": [], "source_answers": [], "source_because": ["d9"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d10"}, "pinned": false, "rationale": "No. Counterexample: Q has independent supports A and B. Retracting A removes Q by the transitive-dependent rule even though B survives and still supports Q. Phase 4 must either define j(γ) conjunctively or represent alternatives as a set of justification sets and retain γ while any complete justification survives.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [["c9"]], "text": "Flat conjunctive support sets cannot represent independent alternative justifications without over-retraction.", "ts": "2026-09-06T18:17:45+00:00"} {"answers": [], "author": "", "branch": "", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "c11", "kind": "claim", "legacy": {"raw": {"answer": "When the state transformations produced by the sub-question answers commute. Pairwise commutativity is sufficient for evaluation to be invariant under every permutation; for two questions it is also necessary. Logical independence matters only if it guarantees this operational noninterference. Counterexample: set-state-to-1 and double-state are reorderings of the same two updates but yield 2 versus 1.", "because": ["d8"], "cost_if_wrong": "", "id": "d11", "question": "When is a decomposition invariant to sub-question order?", "session": "", "state": "settled", "ts": "2026-09-06T18:20:20+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c8"]], "overrides": [], "source_answers": [], "source_because": ["d8"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d11"}, "pinned": false, "rationale": "When the state transformations produced by the sub-question answers commute. Pairwise commutativity is sufficient for evaluation to be invariant under every permutation; for two questions it is also necessary. Logical independence matters only if it guarantees this operational noninterference. Counterexample: set-state-to-1 and double-state are reorderings of the same two updates but yield 2 versus 1.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "accepted", "supersedes": [], "supports": [["c8"]], "text": "Pairwise commutativity of sub-question state transformations is sufficient for order-invariant evaluation; for two transformations it is also necessary.", "ts": "2026-09-06T18:20:20+00:00"} -{"alternatives": ["~/.claude/docket keyed by git root. A project .docket wins when present, and docket init creates one. DOCKET_HOME and CLAUDE_CONFIG_DIR relocate the store."], "answers": [], "author": "claude-code", "branch": "codex/lean-outcome-experiment", "choice": "~/.claude/docket keyed by git root. A project .docket wins when present, and docket init creates one. DOCKET_HOME and CLAUDE_CONFIG_DIR relocate the store.", "cost_if_wrong": "changing the key strands every existing global ledger", "decided_by": "", "depends_on": [], "evidence": [], "id": "d12", "kind": "decision", "legacy": {"raw": {"answer": "~/.claude/docket keyed by git root. A project .docket wins when present, and docket init creates one. DOCKET_HOME and CLAUDE_CONFIG_DIR relocate the store.", "author": "claude-code", "because": [], "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "changing the key strands every existing global ledger", "id": "d12", "question": "Where does the ledger live by default?", "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "settled", "ts": "2026-09-06T18:40:16+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d12"}, "pinned": false, "rationale": "~/.claude/docket keyed by git root. A project .docket wins when present, and docket init creates one. DOCKET_HOME and CLAUDE_CONFIG_DIR relocate the store.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "adopted", "supersedes": [], "supports": [], "text": "Where does the ledger live by default?", "ts": "2026-09-06T18:40:16+00:00"} -{"alternatives": ["Yes. session, author, and branch are written from the start. The log is append-only, so an entry can never gain them later. Nothing filters on branch yet."], "answers": [], "author": "claude-code", "branch": "codex/lean-outcome-experiment", "choice": "Yes. session, author, and branch are written from the start. The log is append-only, so an entry can never gain them later. Nothing filters on branch yet.", "cost_if_wrong": "", "decided_by": "", "depends_on": [], "evidence": [], "id": "d13", "kind": "decision", "legacy": {"raw": {"answer": "Yes. session, author, and branch are written from the start. The log is append-only, so an entry can never gain them later. Nothing filters on branch yet.", "author": "claude-code", "because": [], "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "", "id": "d13", "question": "Capture provenance fields before anything consumes them?", "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "settled", "ts": "2026-09-06T18:40:16+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d13"}, "pinned": false, "rationale": "Yes. session, author, and branch are written from the start. The log is append-only, so an entry can never gain them later. Nothing filters on branch yet.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "adopted", "supersedes": [], "supports": [], "text": "Capture provenance fields before anything consumes them?", "ts": "2026-09-06T18:40:16+00:00"} -{"alternatives": ["No. Codex ships as .codex-plugin/plugin.json because it is in use. OpenCode, Gemini, Copilot, and Cursor are documented in docs/installation.md with copyable config. Only the Claude Code path is tested."], "answers": [], "author": "claude-code", "branch": "codex/lean-outcome-experiment", "choice": "No. Codex ships as .codex-plugin/plugin.json because it is in use. OpenCode, Gemini, Copilot, and Cursor are documented in docs/installation.md with copyable config. Only the Claude Code path is tested.", "cost_if_wrong": "", "decided_by": "", "depends_on": [], "evidence": [], "id": "d14", "kind": "decision", "legacy": {"raw": {"answer": "No. Codex ships as .codex-plugin/plugin.json because it is in use. OpenCode, Gemini, Copilot, and Cursor are documented in docs/installation.md with copyable config. Only the Claude Code path is tested.", "author": "claude-code", "because": [], "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "", "id": "d14", "question": "Ship config for every harness?", "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "ruled-out", "ts": "2026-09-06T18:40:17+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d14"}, "pinned": false, "rationale": "No. Codex ships as .codex-plugin/plugin.json because it is in use. OpenCode, Gemini, Copilot, and Cursor are documented in docs/installation.md with copyable config. Only the Claude Code path is tested.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "adopted", "supersedes": [], "supports": [], "text": "Ship config for every harness?", "ts": "2026-09-06T18:40:17+00:00"} -{"answers": [], "author": "claude-code", "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "phase 4 retraction built on the flat form will remove claims that still hold", "depends_on": [], "evidence": [], "id": "q15", "kind": "question", "legacy": {"raw": {"answer": "d10 shows a flat j: G -> 2^G over-retracts when a claim has alternative supports. definitions.md still states the flat form. It should be a set of justification sets, retaining a claim while any complete justification survives. Doc not yet updated.", "author": "claude-code", "because": ["d10"], "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "phase 4 retraction built on the flat form will remove claims that still hold", "id": "d15", "question": "Correct the justification type in docs/definitions.md", "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "open", "ts": "2026-09-06T18:40:25+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c10"]], "overrides": [], "source_answers": [], "source_because": ["d10"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d15"}, "pinned": false, "rationale": "d10 shows a flat j: G -> 2^G over-retracts when a claim has alternative supports. definitions.md still states the flat form. It should be a set of justification sets, retaining a claim while any complete justification survives. Doc not yet updated.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "open", "supersedes": [], "supports": [["c10"]], "text": "How should justification represent alternative support sets?", "ts": "2026-09-06T18:40:25+00:00"} -{"alternatives": ["GitHub issues on NovusEdge/docket. The ledger records decisions, issues track work. d15 is issue #1 and the blank-author problem is issue #2."], "answers": [], "author": "claude-code", "branch": "codex/lean-outcome-experiment", "choice": "GitHub issues on NovusEdge/docket. The ledger records decisions, issues track work. d15 is issue #1 and the blank-author problem is issue #2.", "cost_if_wrong": "two places to look if they drift apart", "decided_by": "", "depends_on": [], "evidence": [], "id": "d16", "kind": "decision", "legacy": {"raw": {"answer": "GitHub issues on NovusEdge/docket. The ledger records decisions, issues track work. d15 is issue #1 and the blank-author problem is issue #2.", "author": "claude-code", "because": [], "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "two places to look if they drift apart", "id": "d16", "question": "Track open defects where?", "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "settled", "ts": "2026-09-06T18:41:36+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d16"}, "pinned": false, "rationale": "GitHub issues on NovusEdge/docket. The ledger records decisions, issues track work. d15 is issue #1 and the blank-author problem is issue #2.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "adopted", "supersedes": [], "supports": [], "text": "Track open defects where?", "ts": "2026-09-06T18:41:36+00:00"} +{"alternatives":["~/.claude/docket keyed by git root. A project .docket wins when present, and docket init creates one. DOCKET_HOME and CLAUDE_CONFIG_DIR relocate the store."],"answers":[],"author":"claude-code","branch":"codex/lean-outcome-experiment","choice":"~/.claude/docket keyed by git root. A project .docket wins when present, and docket init creates one. DOCKET_HOME and CLAUDE_CONFIG_DIR relocate the store.","cost_if_wrong":"changing the key strands every existing global ledger","decided_by":"","depends_on":[],"evidence":[],"id":"d12","kind":"decision","legacy":{"raw":{"answer":"~/.claude/docket keyed by git root. A project .docket wins when present, and docket init creates one. DOCKET_HOME and CLAUDE_CONFIG_DIR relocate the store.","author":"claude-code","because":[],"branch":"codex/lean-outcome-experiment","cost_if_wrong":"changing the key strands every existing global ledger","id":"d12","question":"Where does the ledger live by default?","session":"session_01RR19nj3Epadxj44kZZsgTF","state":"settled","ts":"2026-09-06T18:40:16+00:00"},"relation_map":{"mapped_answers":[],"mapped_depends_on":[],"mapped_supersedes":[],"mapped_supports":[],"overrides":[],"source_answers":[],"source_because":[],"source_depends_on":[],"source_supersedes":[]},"source_id":"d12"},"pinned":false,"rationale":"~/.claude/docket keyed by git root. A project .docket wins when present, and docket init creates one. DOCKET_HOME and CLAUDE_CONFIG_DIR relocate the store.","revisit":"","schema":2,"scope":["docket/**","docket/env.py"],"session":"session_01RR19nj3Epadxj44kZZsgTF","state":"adopted","supersedes":[],"supports":[],"text":"Where does the ledger live by default?","ts":"2026-09-06T18:40:16+00:00"} +{"alternatives":["Yes. session, author, and branch are written from the start. The log is append-only, so an entry can never gain them later. Nothing filters on branch yet."],"answers":[],"author":"claude-code","branch":"codex/lean-outcome-experiment","choice":"Yes. session, author, and branch are written from the start. The log is append-only, so an entry can never gain them later. Nothing filters on branch yet.","cost_if_wrong":"","decided_by":"","depends_on":[],"evidence":[],"id":"d13","kind":"decision","legacy":{"raw":{"answer":"Yes. session, author, and branch are written from the start. The log is append-only, so an entry can never gain them later. Nothing filters on branch yet.","author":"claude-code","because":[],"branch":"codex/lean-outcome-experiment","cost_if_wrong":"","id":"d13","question":"Capture provenance fields before anything consumes them?","session":"session_01RR19nj3Epadxj44kZZsgTF","state":"settled","ts":"2026-09-06T18:40:16+00:00"},"relation_map":{"mapped_answers":[],"mapped_depends_on":[],"mapped_supersedes":[],"mapped_supports":[],"overrides":[],"source_answers":[],"source_because":[],"source_depends_on":[],"source_supersedes":[]},"source_id":"d13"},"pinned":false,"rationale":"Yes. session, author, and branch are written from the start. The log is append-only, so an entry can never gain them later. Nothing filters on branch yet.","revisit":"","schema":2,"scope":["docket/**","docket/env.py"],"session":"session_01RR19nj3Epadxj44kZZsgTF","state":"adopted","supersedes":[],"supports":[],"text":"Capture provenance fields before anything consumes them?","ts":"2026-09-06T18:40:16+00:00"} +{"alternatives":["No. Codex ships as .codex-plugin/plugin.json because it is in use. OpenCode, Gemini, Copilot, and Cursor are documented in docs/installation.md with copyable config. Only the Claude Code path is tested."],"answers":[],"author":"claude-code","branch":"codex/lean-outcome-experiment","choice":"No. Codex ships as .codex-plugin/plugin.json because it is in use. OpenCode, Gemini, Copilot, and Cursor are documented in docs/installation.md with copyable config. Only the Claude Code path is tested.","cost_if_wrong":"","decided_by":"","depends_on":[],"evidence":[],"id":"d14","kind":"decision","legacy":{"raw":{"answer":"No. Codex ships as .codex-plugin/plugin.json because it is in use. OpenCode, Gemini, Copilot, and Cursor are documented in docs/installation.md with copyable config. Only the Claude Code path is tested.","author":"claude-code","because":[],"branch":"codex/lean-outcome-experiment","cost_if_wrong":"","id":"d14","question":"Ship config for every harness?","session":"session_01RR19nj3Epadxj44kZZsgTF","state":"ruled-out","ts":"2026-09-06T18:40:17+00:00"},"relation_map":{"mapped_answers":[],"mapped_depends_on":[],"mapped_supersedes":[],"mapped_supports":[],"overrides":[],"source_answers":[],"source_because":[],"source_depends_on":[],"source_supersedes":[]},"source_id":"d14"},"pinned":false,"rationale":"No. Codex ships as .codex-plugin/plugin.json because it is in use. OpenCode, Gemini, Copilot, and Cursor are documented in docs/installation.md with copyable config. Only the Claude Code path is tested.","revisit":"","schema":2,"scope":["docket/**","docs/installation.md"],"session":"session_01RR19nj3Epadxj44kZZsgTF","state":"adopted","supersedes":[],"supports":[],"text":"Ship config for every harness?","ts":"2026-09-06T18:40:17+00:00"} +{"answers":[],"author":"claude-code","branch":"codex/lean-outcome-experiment","cost_if_wrong":"phase 4 retraction built on the flat form will remove claims that still hold","depends_on":[],"evidence":[],"id":"q15","kind":"question","legacy":{"raw":{"answer":"d10 shows a flat j: G -> 2^G over-retracts when a claim has alternative supports. definitions.md still states the flat form. It should be a set of justification sets, retaining a claim while any complete justification survives. Doc not yet updated.","author":"claude-code","because":["d10"],"branch":"codex/lean-outcome-experiment","cost_if_wrong":"phase 4 retraction built on the flat form will remove claims that still hold","id":"d15","question":"Correct the justification type in docs/definitions.md","session":"session_01RR19nj3Epadxj44kZZsgTF","state":"open","ts":"2026-09-06T18:40:25+00:00"},"relation_map":{"mapped_answers":[],"mapped_depends_on":[],"mapped_supersedes":[],"mapped_supports":[["c10"]],"overrides":[],"source_answers":[],"source_because":["d10"],"source_depends_on":[],"source_supersedes":[]},"source_id":"d15"},"pinned":false,"rationale":"d10 shows a flat j: G -> 2^G over-retracts when a claim has alternative supports. definitions.md still states the flat form. It should be a set of justification sets, retaining a claim while any complete justification survives. Doc not yet updated.","revisit":"","schema":2,"scope":["docket/**","docket/env.py"],"session":"session_01RR19nj3Epadxj44kZZsgTF","state":"open","supersedes":[],"supports":[["c10"]],"text":"How should justification represent alternative support sets?","ts":"2026-09-06T18:40:25+00:00"} +{"alternatives":["GitHub issues on NovusEdge/docket. The ledger records decisions, issues track work. d15 is issue #1 and the blank-author problem is issue #2."],"answers":[],"author":"claude-code","branch":"codex/lean-outcome-experiment","choice":"GitHub issues on NovusEdge/docket. The ledger records decisions, issues track work. d15 is issue #1 and the blank-author problem is issue #2.","cost_if_wrong":"two places to look if they drift apart","decided_by":"","depends_on":[],"evidence":[],"id":"d16","kind":"decision","legacy":{"raw":{"answer":"GitHub issues on NovusEdge/docket. The ledger records decisions, issues track work. d15 is issue #1 and the blank-author problem is issue #2.","author":"claude-code","because":[],"branch":"codex/lean-outcome-experiment","cost_if_wrong":"two places to look if they drift apart","id":"d16","question":"Track open defects where?","session":"session_01RR19nj3Epadxj44kZZsgTF","state":"settled","ts":"2026-09-06T18:41:36+00:00"},"relation_map":{"mapped_answers":[],"mapped_depends_on":[],"mapped_supersedes":[],"mapped_supports":[],"overrides":[],"source_answers":[],"source_because":[],"source_depends_on":[],"source_supersedes":[]},"source_id":"d16"},"pinned":false,"rationale":"GitHub issues on NovusEdge/docket. The ledger records decisions, issues track work. d15 is issue #1 and the blank-author problem is issue #2.","revisit":"","schema":2,"scope":["docket/**"],"session":"session_01RR19nj3Epadxj44kZZsgTF","state":"adopted","supersedes":[],"supports":[],"text":"Track open defects where?","ts":"2026-09-06T18:41:36+00:00"} {"alternatives": ["Represent justification as alternative conjunctive support sets."], "answers": ["q15"], "author": "claude-code", "branch": "codex/lean-outcome-experiment", "choice": "Represent justification as alternative conjunctive support sets.", "cost_if_wrong": "phase 4 retraction depends on this shape", "decided_by": "", "depends_on": [], "evidence": [], "id": "d17", "kind": "decision", "legacy": {"raw": {"answer": "Done in PR #3. because is now a list of alternative conjunctive sets, j maps to a set of justification sets, and a claim is retained while any complete set survives. Legacy flat entries read as one set.", "author": "claude-code", "because": ["d15"], "branch": "codex/lean-outcome-experiment", "cost_if_wrong": "phase 4 retraction depends on this shape", "id": "d17", "question": "Resolve d15, the justification type", "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "settled", "ts": "2026-09-06T18:58:19+00:00"}, "relation_map": {"mapped_answers": ["q15"], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c10"]], "overrides": ["answers", "supports"], "source_answers": [], "source_because": ["d15"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d17"}, "pinned": false, "rationale": "Done in PR #3. because is now a list of alternative conjunctive sets, j maps to a set of justification sets, and a claim is retained while any complete set survives. Legacy flat entries read as one set. Migration explicitly converts the old link to the question into an answers relation and cites the counterexample as support.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "session_01RR19nj3Epadxj44kZZsgTF", "state": "adopted", "supersedes": [], "supports": [["c10"]], "text": "Resolve d15, the justification type", "ts": "2026-09-06T18:58:19+00:00"} {"answers": [], "author": "", "branch": "", "cost_if_wrong": "a ledger that becomes unreadable or slow to retract once entries carry many alternative supports", "depends_on": [], "evidence": [], "id": "q18", "kind": "question", "legacy": {"raw": {"answer": "Nothing yet. d17 gave because the shape of an ATMS label, a set of environments. de Kleer 1986 shows that set grows exponentially in the number of assumptions, which is what killed the ATMS line. docs/decision-chains.md now records the risk.", "because": ["d17", "d10"], "cost_if_wrong": "a ledger that becomes unreadable or slow to retract once entries carry many alternative supports", "id": "d18", "question": "What bounds label growth under alternative justifications?", "session": "", "state": "open", "ts": "2026-09-07T14:21:07+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d17", "c10"]], "overrides": [], "source_answers": [], "source_because": ["d17", "d10"], "source_depends_on": [], "source_supersedes": []}, "source_id": "d18"}, "pinned": false, "rationale": "Nothing yet. d17 gave because the shape of an ATMS label, a set of environments. de Kleer 1986 shows that set grows exponentially in the number of assumptions, which is what killed the ATMS line. docs/decision-chains.md now records the risk.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "", "state": "open", "supersedes": [], "supports": [["d17", "c10"]], "text": "What bounds label growth under alternative justifications?", "ts": "2026-09-07T14:21:07+00:00"} {"answers": [], "author": "claude-code", "branch": "main", "cost_if_wrong": "if the lift is wrong, a dependency graph cannot certify parallel validity at all", "depends_on": [], "evidence": [], "id": "c19", "kind": "claim", "legacy": {"raw": {"answer": "It is now. d8 previously rested on a theorem that restated the definition of Commute. StressTests.lean proves the lift instead: model a section as a list of steps, and if every step of one section commutes with every step of the other, the two section effects commute. Proof is by induction on both lists.", "author": "claude-code", "because": [["d8"]], "branch": "main", "cost_if_wrong": "if the lift is wrong, a dependency graph cannot certify parallel validity at all", "id": "d19", "question": "Is the parallel-composition repair in d8 actually proved?", "session": "session_013saodNY3WoAKeDn9ySLjUH", "state": "settled", "ts": "2026-09-07T14:36:37+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["c8"]], "overrides": [], "source_answers": [], "source_because": [["d8"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d19"}, "pinned": false, "rationale": "It is now. d8 previously rested on a theorem that restated the definition of Commute. StressTests.lean proves the lift instead: model a section as a list of steps, and if every step of one section commutes with every step of the other, the two section effects commute. Proof is by induction on both lists.", "revisit": "", "schema": 2, "scope": ["docs/definitions.md", "docs/outcome-formalism.md", "experiments/**"], "session": "session_013saodNY3WoAKeDn9ySLjUH", "state": "accepted", "supersedes": [], "supports": [["c8"]], "text": "StressTests.lean proves that pairwise commutativity of steps across two sections lifts to commutativity of the section effects.", "ts": "2026-09-07T14:36:37+00:00"} -{"answers": ["q15"], "author": "claude-code", "branch": "main", "cost_if_wrong": "if supersedes is wrong, a decision that still holds disappears from the injected context", "depends_on": [], "evidence": [], "id": "c20", "kind": "claim", "legacy": {"raw": {"answer": "It did, and that was the defect. d15 keeps its own open state because the log is append-only, so list --state open and the injected context both misreported it. add --supersedes now records the retirement and hides the retired entry; list --superseded shows it again.", "author": "claude-code", "because": [["d17", "d16"]], "branch": "main", "cost_if_wrong": "if supersedes is wrong, a decision that still holds disappears from the injected context", "id": "d20", "question": "Does the ledger show d15 as open after d17 resolved it?", "session": "session_013saodNY3WoAKeDn9ySLjUH", "state": "settled", "supersedes": ["d15"], "ts": "2026-09-07T14:53:43+00:00"}, "relation_map": {"mapped_answers": ["q15"], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d17", "d16"]], "overrides": ["answers", "supersedes"], "source_answers": [], "source_because": [["d17", "d16"]], "source_depends_on": [], "source_supersedes": ["d15"]}, "source_id": "d20"}, "pinned": false, "rationale": "It did, and that was the defect. d15 keeps its own open state because the log is append-only, so list --state open and the injected context both misreported it. add --supersedes now records the retirement and hides the retired entry; list --superseded shows it again. Migration explicitly converts the old question supersession into an answers relation.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "session_013saodNY3WoAKeDn9ySLjUH", "state": "accepted", "supersedes": [], "supports": [["d17", "d16"]], "text": "The earlier ledger reported a retired question as open until supersession filtering was added.", "ts": "2026-09-07T14:53:43+00:00"} +{"answers":["q15"],"author":"claude-code","branch":"main","cost_if_wrong":"if supersedes is wrong, a decision that still holds disappears from the injected context","depends_on":[],"evidence":[],"id":"c20","kind":"claim","legacy":{"raw":{"answer":"It did, and that was the defect. d15 keeps its own open state because the log is append-only, so list --state open and the injected context both misreported it. add --supersedes now records the retirement and hides the retired entry; list --superseded shows it again.","author":"claude-code","because":[["d17","d16"]],"branch":"main","cost_if_wrong":"if supersedes is wrong, a decision that still holds disappears from the injected context","id":"d20","question":"Does the ledger show d15 as open after d17 resolved it?","session":"session_013saodNY3WoAKeDn9ySLjUH","state":"settled","supersedes":["d15"],"ts":"2026-09-07T14:53:43+00:00"},"relation_map":{"mapped_answers":["q15"],"mapped_depends_on":[],"mapped_supersedes":[],"mapped_supports":[["d17","d16"]],"overrides":["answers","supersedes"],"source_answers":[],"source_because":[["d17","d16"]],"source_depends_on":[],"source_supersedes":["d15"]},"source_id":"d20"},"pinned":false,"rationale":"It did, and that was the defect. d15 keeps its own open state because the log is append-only, so list --state open and the injected context both misreported it. add --supersedes now records the retirement and hides the retired entry; list --superseded shows it again. Migration explicitly converts the old question supersession into an answers relation.","revisit":"","schema":2,"scope":["docket/**","docket/env.py"],"session":"session_013saodNY3WoAKeDn9ySLjUH","state":"accepted","supersedes":[],"supports":[["d17","d16"]],"text":"The earlier ledger reported a retired question as open until supersession filtering was added.","ts":"2026-09-07T14:53:43+00:00"} {"alternatives": ["Apply de-slopify and ASD-STE100 Issue 9 principles. Keep the public README free of internal implementation details, plans, and status."], "answers": [], "author": "novusedge", "branch": "main", "choice": "Apply de-slopify and ASD-STE100 Issue 9 principles. Keep the public README free of internal implementation details, plans, and status.", "cost_if_wrong": "Documentation can become ambiguous, stale, or unsuitable for public readers.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d21", "kind": "decision", "legacy": {"raw": {"answer": "Apply de-slopify and ASD-STE100 Issue 9 principles. Keep the public README free of internal implementation details, plans, and status.", "author": "novusedge", "because": [], "branch": "main", "cost_if_wrong": "Documentation can become ambiguous, stale, or unsuitable for public readers.", "id": "d21", "question": "What writing standard must Docket documentation follow?", "session": "", "state": "settled", "ts": "2026-09-07T15:35:33+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d21"}, "pinned": true, "rationale": "Apply de-slopify and ASD-STE100 Issue 9 principles. Keep the public README free of internal implementation details, plans, and status.", "revisit": "", "schema": 2, "scope": ["docs/**", "README.md"], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "What writing standard must Docket documentation follow?", "ts": "2026-09-07T15:35:33+00:00"} {"alternatives": ["A stdlib-only install.py at the repo root. One script serves both 'curl | python3 -' and 'python3 install.py' from a checkout: it detects which by whether __file__ resolves next to bin/docket."], "answers": [], "author": "claude-code", "branch": "main", "choice": "A stdlib-only install.py at the repo root. One script serves both 'curl | python3 -' and 'python3 install.py' from a checkout: it detects which by whether __file__ resolves next to bin/docket.", "cost_if_wrong": "Changing the entry point invalidates the README one-liner and any URL people have bookmarked.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d22", "kind": "decision", "legacy": {"raw": {"answer": "A stdlib-only install.py at the repo root. One script serves both 'curl | python3 -' and 'python3 install.py' from a checkout: it detects which by whether __file__ resolves next to bin/docket.", "author": "claude-code", "because": [], "branch": "main", "cost_if_wrong": "Changing the entry point invalidates the README one-liner and any URL people have bookmarked.", "id": "d22", "question": "How do users install docket?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": [], "ts": "2026-09-11T17:36:51+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d22"}, "pinned": false, "rationale": "A stdlib-only install.py at the repo root. One script serves both 'curl | python3 -' and 'python3 install.py' from a checkout: it detects which by whether __file__ resolves next to bin/docket.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": [], "supports": [], "text": "How do users install docket?", "ts": "2026-09-11T17:36:51+00:00"} {"alternatives": ["It merges a SessionStart hook into ~/.claude/settings.json and installs the skill, rather than printing the two /plugin slash commands. It skips the hook when the marketplace plugin is already present under ~/.claude/plugins/, because both would inject the ledger twice."], "answers": [], "author": "claude-code", "branch": "main", "choice": "It merges a SessionStart hook into ~/.claude/settings.json and installs the skill, rather than printing the two /plugin slash commands. It skips the hook when the marketplace plugin is already present under ~/.claude/plugins/, because both would inject the ledger twice.", "cost_if_wrong": "Both paths injecting at once doubles the ledger in context; the guard is the only thing preventing it.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d23", "kind": "decision", "legacy": {"raw": {"answer": "It merges a SessionStart hook into ~/.claude/settings.json and installs the skill, rather than printing the two /plugin slash commands. It skips the hook when the marketplace plugin is already present under ~/.claude/plugins/, because both would inject the ledger twice.", "author": "claude-code", "because": [["d22"]], "branch": "main", "cost_if_wrong": "Both paths injecting at once doubles the ledger in context; the guard is the only thing preventing it.", "id": "d23", "question": "How does the installer wire Claude Code?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": [], "ts": "2026-09-11T17:36:59+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d22"]], "overrides": [], "source_answers": [], "source_because": [["d22"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d23"}, "pinned": false, "rationale": "It merges a SessionStart hook into ~/.claude/settings.json and installs the skill, rather than printing the two /plugin slash commands. It skips the hook when the marketplace plugin is already present under ~/.claude/plugins/, because both would inject the ledger twice.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": [], "supports": [["d22"]], "text": "How does the installer wire Claude Code?", "ts": "2026-09-11T17:36:59+00:00"} @@ -33,49 +33,52 @@ {"alternatives": ["By superseding the entry with a restatement carrying the right support. A dedicated 'amend' event that folds field-level corrections over the log waits for stage 3 retraction: nothing walks because edges yet, so a wrong edge today is a wrong picture, not a wrong retraction. Stage 3 must define j over some view of the log anyway, so the fold gets designed once."], "answers": [], "author": "claude-code", "branch": "feat/installer", "choice": "By superseding the entry with a restatement carrying the right support. A dedicated 'amend' event that folds field-level corrections over the log waits for stage 3 retraction: nothing walks because edges yet, so a wrong edge today is a wrong picture, not a wrong retraction. Stage 3 must define j over some view of the log anyway, so the fold gets designed once.", "cost_if_wrong": "If amend ships before retraction, every reader must honour a fold invariant before the code that needs it exists, and the on-disk schema is expensive to reverse.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d33", "kind": "decision", "legacy": {"raw": {"answer": "By superseding the entry with a restatement carrying the right support. A dedicated 'amend' event that folds field-level corrections over the log waits for stage 3 retraction: nothing walks because edges yet, so a wrong edge today is a wrong picture, not a wrong retraction. Stage 3 must define j over some view of the log anyway, so the fold gets designed once.", "author": "claude-code", "because": [["d2"]], "branch": "feat/installer", "cost_if_wrong": "If amend ships before retraction, every reader must honour a fold invariant before the code that needs it exists, and the on-disk schema is expensive to reverse.", "id": "d33", "question": "How is a wrong justification corrected?", "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "settled", "supersedes": [], "ts": "2026-09-11T20:35:26+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d2"]], "overrides": [], "source_answers": [], "source_because": [["d2"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d33"}, "pinned": false, "rationale": "By superseding the entry with a restatement carrying the right support. A dedicated 'amend' event that folds field-level corrections over the log waits for stage 3 retraction: nothing walks because edges yet, so a wrong edge today is a wrong picture, not a wrong retraction. Stage 3 must define j over some view of the log anyway, so the fold gets designed once.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "session_01Cm4oiiCVbBc4twdpKgydQm", "state": "adopted", "supersedes": [], "supports": [["d2"]], "text": "How is a wrong justification corrected?", "ts": "2026-09-11T20:35:26+00:00"} {"alternatives": ["Port the whole installer engine and inline UI to Go under installer/, including its own Go module, tests, and release tooling. Use Bubble Tea v2, Bubbles, and Lip Gloss with Stoat at ~/vms/stoat as the interaction reference. Keep the Docket ledger CLI in Python and retain install.py only as a compatibility launcher."], "answers": [], "author": "codex", "branch": "feat/textual-installer", "choice": "Port the whole installer engine and inline UI to Go under installer/, including its own Go module, tests, and release tooling. Use Bubble Tea v2, Bubbles, and Lip Gloss with Stoat at ~/vms/stoat as the interaction reference. Keep the Docket ledger CLI in Python and retain install.py only as a compatibility launcher.", "cost_if_wrong": "Moving installer code outside installer or retaining a Python installation engine contradicts the approved port scope.", "decided_by": "user", "depends_on": [], "evidence": [], "id": "d34", "kind": "decision", "legacy": {"raw": {"answer": "Port the whole installer engine and inline UI to Go under installer/, including its own Go module, tests, and release tooling. Use Bubble Tea v2, Bubbles, and Lip Gloss with Stoat at ~/vms/stoat as the interaction reference. Keep the Docket ledger CLI in Python and retain install.py only as a compatibility launcher.", "author": "codex", "because": [], "branch": "feat/textual-installer", "cost_if_wrong": "Moving installer code outside installer or retaining a Python installation engine contradicts the approved port scope.", "id": "d34", "question": "Where should the native installer implementation live?", "session": "", "state": "settled", "supersedes": [], "ts": "2026-09-12T01:12:03+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d34"}, "pinned": true, "rationale": "Port the whole installer engine and inline UI to Go under installer/, including its own Go module, tests, and release tooling. Use Bubble Tea v2, Bubbles, and Lip Gloss with Stoat at ~/vms/stoat as the interaction reference. Keep the Docket ledger CLI in Python and retain install.py only as a compatibility launcher.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "Where should the native installer implementation live?", "ts": "2026-09-12T01:12:03+00:00"} {"alternatives": ["Use Go golang.org/x/sys/windows/registry for HKCU Environment Path, preserve REG_SZ or REG_EXPAND_SZ without expanding percent variables, then broadcast WM_SETTINGCHANGE. Continue using docket.cmd rather than a PowerShell shim. This replaces the Python winreg and ctypes implementation in d29 while preserving its registry semantics."], "answers": [], "author": "codex", "branch": "feat/textual-installer", "choice": "Use Go golang.org/x/sys/windows/registry for HKCU Environment Path, preserve REG_SZ or REG_EXPAND_SZ without expanding percent variables, then broadcast WM_SETTINGCHANGE. Continue using docket.cmd rather than a PowerShell shim. This replaces the Python winreg and ctypes implementation in d29 while preserving its registry semantics.", "cost_if_wrong": "Changing the registry value type or expanding variables corrupts PATH.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d35", "kind": "decision", "legacy": {"raw": {"answer": "Use Go golang.org/x/sys/windows/registry for HKCU Environment Path, preserve REG_SZ or REG_EXPAND_SZ without expanding percent variables, then broadcast WM_SETTINGCHANGE. Continue using docket.cmd rather than a PowerShell shim. This replaces the Python winreg and ctypes implementation in d29 while preserving its registry semantics.", "author": "codex", "because": [["d22"]], "branch": "feat/textual-installer", "cost_if_wrong": "Changing the registry value type or expanding variables corrupts PATH.", "id": "d35", "question": "How does the native installer edit PATH on Windows?", "session": "", "state": "settled", "supersedes": ["d29"], "ts": "2026-09-12T01:12:04+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d29"], "mapped_supports": [["d22"]], "overrides": [], "source_answers": [], "source_because": [["d22"]], "source_depends_on": [], "source_supersedes": ["d29"]}, "source_id": "d35"}, "pinned": false, "rationale": "Use Go golang.org/x/sys/windows/registry for HKCU Environment Path, preserve REG_SZ or REG_EXPAND_SZ without expanding percent variables, then broadcast WM_SETTINGCHANGE. Continue using docket.cmd rather than a PowerShell shim. This replaces the Python winreg and ctypes implementation in d29 while preserving its registry semantics.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d29"], "supports": [["d22"]], "text": "How does the native installer edit PATH on Windows?", "ts": "2026-09-12T01:12:04+00:00"} -{"alternatives": ["Use a native Bubble Tea and Bubbles graph viewer by default when docket graph has terminal input and output. Show compact selectable and collapsible decision rows with a separate detail pane and search. Preserve static output for pipes and explicit static flags. Keep ledger reading and filtering in the Python CLI, put viewer code in graph/, and let the installer deliver its native binary without requiring Go for normal users."], "answers": [], "author": "novusedge", "branch": "feat/textual-installer", "choice": "Use a native Bubble Tea and Bubbles graph viewer by default when docket graph has terminal input and output. Show compact selectable and collapsible decision rows with a separate detail pane and search. Preserve static output for pipes and explicit static flags. Keep ledger reading and filtering in the Python CLI, put viewer code in graph/, and let the installer deliver its native binary without requiring Go for normal users.", "cost_if_wrong": "A pager alone leaves the wall of inline prose and width problems unresolved; making the whole ledger CLI native would expand the approved scope.", "decided_by": "user", "depends_on": [], "evidence": [], "id": "d36", "kind": "decision", "legacy": {"raw": {"answer": "Use a native Bubble Tea and Bubbles graph viewer by default when docket graph has terminal input and output. Show compact selectable and collapsible decision rows with a separate detail pane and search. Preserve static output for pipes and explicit static flags. Keep ledger reading and filtering in the Python CLI, put viewer code in graph/, and let the installer deliver its native binary without requiring Go for normal users.", "author": "novusedge", "because": [], "branch": "feat/textual-installer", "cost_if_wrong": "A pager alone leaves the wall of inline prose and width problems unresolved; making the whole ledger CLI native would expand the approved scope.", "id": "d36", "question": "How should people browse the decision graph by default?", "session": "", "state": "settled", "ts": "2026-09-12T01:38:20+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d36"}, "pinned": true, "rationale": "Use a native Bubble Tea and Bubbles graph viewer by default when docket graph has terminal input and output. Show compact selectable and collapsible decision rows with a separate detail pane and search. Preserve static output for pipes and explicit static flags. Keep ledger reading and filtering in the Python CLI, put viewer code in graph/, and let the installer deliver its native binary without requiring Go for normal users.", "revisit": "", "schema": 2, "scope": ["graph/**", "bin/docket"], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "How should people browse the decision graph by default?", "ts": "2026-09-12T01:38:20+00:00"} +{"alternatives":["Use a native Bubble Tea and Bubbles graph viewer by default when docket graph has terminal input and output. Show compact selectable and collapsible decision rows with a separate detail pane and search. Preserve static output for pipes and explicit static flags. Keep ledger reading and filtering in the Python CLI, put viewer code in graph/, and let the installer deliver its native binary without requiring Go for normal users."],"answers":[],"author":"novusedge","branch":"feat/textual-installer","choice":"Use a native Bubble Tea and Bubbles graph viewer by default when docket graph has terminal input and output. Show compact selectable and collapsible decision rows with a separate detail pane and search. Preserve static output for pipes and explicit static flags. Keep ledger reading and filtering in the Python CLI, put viewer code in graph/, and let the installer deliver its native binary without requiring Go for normal users.","cost_if_wrong":"A pager alone leaves the wall of inline prose and width problems unresolved; making the whole ledger CLI native would expand the approved scope.","decided_by":"user","depends_on":[],"evidence":[],"id":"d36","kind":"decision","legacy":{"raw":{"answer":"Use a native Bubble Tea and Bubbles graph viewer by default when docket graph has terminal input and output. Show compact selectable and collapsible decision rows with a separate detail pane and search. Preserve static output for pipes and explicit static flags. Keep ledger reading and filtering in the Python CLI, put viewer code in graph/, and let the installer deliver its native binary without requiring Go for normal users.","author":"novusedge","because":[],"branch":"feat/textual-installer","cost_if_wrong":"A pager alone leaves the wall of inline prose and width problems unresolved; making the whole ledger CLI native would expand the approved scope.","id":"d36","question":"How should people browse the decision graph by default?","session":"","state":"settled","ts":"2026-09-12T01:38:20+00:00"},"relation_map":{"mapped_answers":[],"mapped_depends_on":[],"mapped_supersedes":[],"mapped_supports":[],"overrides":[],"source_answers":[],"source_because":[],"source_depends_on":[],"source_supersedes":[]},"source_id":"d36"},"pinned":true,"rationale":"Use a native Bubble Tea and Bubbles graph viewer by default when docket graph has terminal input and output. Show compact selectable and collapsible decision rows with a separate detail pane and search. Preserve static output for pipes and explicit static flags. Keep ledger reading and filtering in the Python CLI, put viewer code in graph/, and let the installer deliver its native binary without requiring Go for normal users.","revisit":"","schema":2,"scope":["graph/**","docket/cli/graph.py"],"session":"","state":"adopted","supersedes":[],"supports":[],"text":"How should people browse the decision graph by default?","ts":"2026-09-12T01:38:20+00:00"} {"alternatives": ["Docket ships installer integrations for Claude Code, Codex, Gemini CLI, Cursor, GitHub Copilot CLI, and OpenCode. The installer detects available harnesses and configures the selected set."], "answers": [], "author": "novusedge", "branch": "codex/native-installer-graph", "choice": "Docket ships installer integrations for Claude Code, Codex, Gemini CLI, Cursor, GitHub Copilot CLI, and OpenCode. The installer detects available harnesses and configures the selected set.", "cost_if_wrong": "An incorrect integration can make sessions start without the ledger context.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d37", "kind": "decision", "legacy": {"raw": {"answer": "Docket ships installer integrations for Claude Code, Codex, Gemini CLI, Cursor, GitHub Copilot CLI, and OpenCode. The installer detects available harnesses and configures the selected set.", "author": "novusedge", "because": [], "branch": "codex/native-installer-graph", "cost_if_wrong": "An incorrect integration can make sessions start without the ledger context.", "id": "d37", "question": "Which harness integrations does Docket ship?", "session": "", "state": "settled", "supersedes": ["d14"], "ts": "2026-09-12T02:09:30+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d14"], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": ["d14"]}, "source_id": "d37"}, "pinned": false, "rationale": "Docket ships installer integrations for Claude Code, Codex, Gemini CLI, Cursor, GitHub Copilot CLI, and OpenCode. The installer detects available harnesses and configures the selected set.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d14"], "supports": [], "text": "Which harness integrations does Docket ship?", "ts": "2026-09-12T02:09:30+00:00"} {"alternatives": ["Keep installer/install.py as the stable user entry point. Outside a source checkout, it verifies and runs the platform-specific released Go installer. Inside a source checkout, it builds and runs the Go installer under installer/ and requires Go. The installed ledger CLI remains Python 3.10+."], "answers": [], "author": "novusedge", "branch": "codex/native-installer-graph", "choice": "Keep installer/install.py as the stable user entry point. Outside a source checkout, it verifies and runs the platform-specific released Go installer. Inside a source checkout, it builds and runs the Go installer under installer/ and requires Go. The installed ledger CLI remains Python 3.10+.", "cost_if_wrong": "Changing the entry point invalidates the documented download URL, release assets, and existing installation instructions.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d38", "kind": "decision", "legacy": {"raw": {"answer": "Keep installer/install.py as the stable user entry point. Outside a source checkout, it verifies and runs the platform-specific released Go installer. Inside a source checkout, it builds and runs the Go installer under installer/ and requires Go. The installed ledger CLI remains Python 3.10+.", "author": "novusedge", "because": [["d34"]], "branch": "codex/native-installer-graph", "cost_if_wrong": "Changing the entry point invalidates the documented download URL, release assets, and existing installation instructions.", "id": "d38", "question": "What is Docket's installation entry point?", "session": "", "state": "settled", "supersedes": ["d22"], "ts": "2026-09-12T02:09:31+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d22"], "mapped_supports": [["d34"]], "overrides": [], "source_answers": [], "source_because": [["d34"]], "source_depends_on": [], "source_supersedes": ["d22"]}, "source_id": "d38"}, "pinned": true, "rationale": "Keep installer/install.py as the stable user entry point. Outside a source checkout, it verifies and runs the platform-specific released Go installer. Inside a source checkout, it builds and runs the Go installer under installer/ and requires Go. The installed ledger CLI remains Python 3.10+.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d22"], "supports": [["d34"]], "text": "What is Docket's installation entry point?", "ts": "2026-09-12T02:09:31+00:00"} {"alternatives": ["Harness integration is user-global by default. --project places the Claude skill and Cursor rule in the current repository; hook configuration remains user-global."], "answers": [], "author": "novusedge", "branch": "codex/native-installer-graph", "choice": "Harness integration is user-global by default. --project places the Claude skill and Cursor rule in the current repository; hook configuration remains user-global.", "cost_if_wrong": "Treating --project as fully project-local can cause users to modify or remove global hook configuration unintentionally.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d39", "kind": "decision", "legacy": {"raw": {"answer": "Harness integration is user-global by default. --project places the Claude skill and Cursor rule in the current repository; hook configuration remains user-global.", "author": "novusedge", "because": [["d38"]], "branch": "codex/native-installer-graph", "cost_if_wrong": "Treating --project as fully project-local can cause users to modify or remove global hook configuration unintentionally.", "id": "d39", "question": "Where does the installer write harness configuration?", "session": "", "state": "settled", "supersedes": ["d24"], "ts": "2026-09-12T02:09:31+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d24"], "mapped_supports": [["d38"]], "overrides": [], "source_answers": [], "source_because": [["d38"]], "source_depends_on": [], "source_supersedes": ["d24"]}, "source_id": "d39"}, "pinned": false, "rationale": "Harness integration is user-global by default. --project places the Claude skill and Cursor rule in the current repository; hook configuration remains user-global.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d24"], "supports": [["d38"]], "text": "Where does the installer write harness configuration?", "ts": "2026-09-12T02:09:31+00:00"} {"alternatives": ["Use the installer --update option to refresh an existing installation while preserving harness and PATH configuration. A downloaded or native installer updates its managed checkout with fetch and a fast-forward merge, then refreshes the graph viewer. From a source checkout, just update or installer/install.py --update rebuilds the current source without Git changes; update that checkout separately. There is no docket update subcommand or session-start network check."], "answers": [], "author": "novusedge", "branch": "codex/native-installer-graph", "choice": "Use the installer --update option to refresh an existing installation while preserving harness and PATH configuration. A downloaded or native installer updates its managed checkout with fetch and a fast-forward merge, then refreshes the graph viewer. From a source checkout, just update or installer/install.py --update rebuilds the current source without Git changes; update that checkout separately. There is no docket update subcommand or session-start network check.", "cost_if_wrong": "Conflating source builds, managed updates, and full installation can unexpectedly rewrite files or leave users on old source.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d40", "kind": "decision", "legacy": {"raw": {"answer": "Use the installer --update option to refresh an existing installation while preserving harness and PATH configuration. A downloaded or native installer updates its managed checkout with fetch and a fast-forward merge, then refreshes the graph viewer. From a source checkout, just update or installer/install.py --update rebuilds the current source without Git changes; update that checkout separately. There is no docket update subcommand or session-start network check.", "author": "novusedge", "because": [["d38", "d36"]], "branch": "codex/native-installer-graph", "cost_if_wrong": "Conflating source builds, managed updates, and full installation can unexpectedly rewrite files or leave users on old source.", "id": "d40", "question": "How do users update Docket?", "session": "", "state": "settled", "supersedes": ["d25"], "ts": "2026-09-12T02:09:31+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d25"], "mapped_supports": [["d38", "d36"]], "overrides": [], "source_answers": [], "source_because": [["d38", "d36"]], "source_depends_on": [], "source_supersedes": ["d25"]}, "source_id": "d40"}, "pinned": true, "rationale": "Use the installer --update option to refresh an existing installation while preserving harness and PATH configuration. A downloaded or native installer updates its managed checkout with fetch and a fast-forward merge, then refreshes the graph viewer. From a source checkout, just update or installer/install.py --update rebuilds the current source without Git changes; update that checkout separately. There is no docket update subcommand or session-start network check.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d25"], "supports": [["d38", "d36"]], "text": "How do users update Docket?", "ts": "2026-09-12T02:09:31+00:00"} {"alternatives": ["The Python installer is only a bootstrap launcher. The Go installer separates BuildPlan and PreparePlan from ExecutePlan, represents persistent operations as Action values, and skips ExecutePlan for --dry-run. Tests inject an Environment or use temporary homes so platform-specific planning can be checked without modifying the user's installation."], "answers": [], "author": "novusedge", "branch": "codex/native-installer-graph", "choice": "The Python installer is only a bootstrap launcher. The Go installer separates BuildPlan and PreparePlan from ExecutePlan, represents persistent operations as Action values, and skips ExecutePlan for --dry-run. Tests inject an Environment or use temporary homes so platform-specific planning can be checked without modifying the user's installation.", "cost_if_wrong": "Mixing persistent writes into planning would make dry-run output unreliable and remove isolated cross-platform testing.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d41", "kind": "decision", "legacy": {"raw": {"answer": "The Python installer is only a bootstrap launcher. The Go installer separates BuildPlan and PreparePlan from ExecutePlan, represents persistent operations as Action values, and skips ExecutePlan for --dry-run. Tests inject an Environment or use temporary homes so platform-specific planning can be checked without modifying the user's installation.", "author": "novusedge", "because": [["d34", "d38"]], "branch": "codex/native-installer-graph", "cost_if_wrong": "Mixing persistent writes into planning would make dry-run output unreliable and remove isolated cross-platform testing.", "id": "d41", "question": "How is the native installer structured for testing and dry runs?", "session": "", "state": "settled", "supersedes": ["d26"], "ts": "2026-09-12T02:09:32+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d26"], "mapped_supports": [["d34", "d38"]], "overrides": [], "source_answers": [], "source_because": [["d34", "d38"]], "source_depends_on": [], "source_supersedes": ["d26"]}, "source_id": "d41"}, "pinned": false, "rationale": "The Python installer is only a bootstrap launcher. The Go installer separates BuildPlan and PreparePlan from ExecutePlan, represents persistent operations as Action values, and skips ExecutePlan for --dry-run. Tests inject an Environment or use temporary homes so platform-specific planning can be checked without modifying the user's installation.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d26"], "supports": [["d34", "d38"]], "text": "How is the native installer structured for testing and dry runs?", "ts": "2026-09-12T02:09:32+00:00"} {"alternatives": ["Let the Codex CLI own plugin registration: run codex plugin marketplace add , then codex plugin add docket@NovusEdge. Keep the repository marketplace named NovusEdge. Do not hand-edit Codex configuration. The end-to-end Codex installation evidence in d30 concerns the former 0.6.4 installer; it does not establish live native-installer integration coverage. Native planning is tested, but its real Codex registration still needs separate qualification."], "answers": [], "author": "codex", "branch": "main", "choice": "Let the Codex CLI own plugin registration: run codex plugin marketplace add , then codex plugin add docket@NovusEdge. Keep the repository marketplace named NovusEdge. Do not hand-edit Codex configuration. The end-to-end Codex installation evidence in d30 concerns the former 0.6.4 installer; it does not establish live native-installer integration coverage. Native planning is tested, but its real Codex registration still needs separate qualification.", "cost_if_wrong": "Changing marketplace identity breaks registration; treating historical tests as current coverage can hide harness integration failures.", "decided_by": "", "depends_on": [], "evidence": [], "id": "d42", "kind": "decision", "legacy": {"raw": {"answer": "Let the Codex CLI own plugin registration: run codex plugin marketplace add , then codex plugin add docket@NovusEdge. Keep the repository marketplace named NovusEdge. Do not hand-edit Codex configuration. The end-to-end Codex installation evidence in d30 concerns the former 0.6.4 installer; it does not establish live native-installer integration coverage. Native planning is tested, but its real Codex registration still needs separate qualification.", "author": "codex", "because": [["d34", "d38"]], "branch": "main", "cost_if_wrong": "Changing marketplace identity breaks registration; treating historical tests as current coverage can hide harness integration failures.", "id": "d42", "question": "How does the native installer configure Codex?", "session": "", "state": "settled", "supersedes": ["d30"], "ts": "2026-09-12T02:27:26+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": ["d30"], "mapped_supports": [["d34", "d38"]], "overrides": [], "source_answers": [], "source_because": [["d34", "d38"]], "source_depends_on": [], "source_supersedes": ["d30"]}, "source_id": "d42"}, "pinned": false, "rationale": "Let the Codex CLI own plugin registration: run codex plugin marketplace add , then codex plugin add docket@NovusEdge. Keep the repository marketplace named NovusEdge. Do not hand-edit Codex configuration. The end-to-end Codex installation evidence in d30 concerns the former 0.6.4 installer; it does not establish live native-installer integration coverage. Native planning is tested, but its real Codex registration still needs separate qualification.", "revisit": "", "schema": 2, "scope": ["installer/**", "docs/installation.md"], "session": "", "state": "adopted", "supersedes": ["d30"], "supports": [["d34", "d38"]], "text": "How does the native installer configure Codex?", "ts": "2026-09-12T02:27:26+00:00"} -{"alternatives": ["Merge PR #5 and reconcile the project ledger first, then explore richer metadata and efficient context delivery. Implement the selected design later on a separate branch. Dependency semantics and the context architecture are not selected yet; preserve the existing alternative conjunctive because sets during this design work."], "answers": [], "author": "codex", "branch": "main", "choice": "Merge PR #5 and reconcile the project ledger first, then explore richer metadata and efficient context delivery. Implement the selected design later on a separate branch. Dependency semantics and the context architecture are not selected yet; preserve the existing alternative conjunctive because sets during this design work.", "cost_if_wrong": "Mixing an unapproved schema or context redesign into the installer and graph work makes compatibility and validation harder to assess.", "decided_by": "user", "depends_on": [], "evidence": [], "id": "d43", "kind": "decision", "legacy": {"raw": {"answer": "Merge PR #5 and reconcile the project ledger first, then explore richer metadata and efficient context delivery. Implement the selected design later on a separate branch. Dependency semantics and the context architecture are not selected yet; preserve the existing alternative conjunctive because sets during this design work.", "author": "codex", "because": [["d17"]], "branch": "main", "cost_if_wrong": "Mixing an unapproved schema or context redesign into the installer and graph work makes compatibility and validation harder to assess.", "id": "d43", "question": "When should richer decision metadata and agent context delivery be implemented?", "session": "", "state": "settled", "supersedes": [], "ts": "2026-09-12T02:27:26+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [["d17"]], "overrides": [], "source_answers": [], "source_because": [["d17"]], "source_depends_on": [], "source_supersedes": []}, "source_id": "d43"}, "pinned": false, "rationale": "Merge PR #5 and reconcile the project ledger first, then explore richer metadata and efficient context delivery. Implement the selected design later on a separate branch. Dependency semantics and the context architecture are not selected yet; preserve the existing alternative conjunctive because sets during this design work.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "", "state": "adopted", "supersedes": [], "supports": [["d17"]], "text": "When should richer decision metadata and agent context delivery be implemented?", "ts": "2026-09-12T02:27:26+00:00"} -{"alternatives": ["No. The user explicitly permits breaking changes because Docket is still new. Design claim, decision, and question records and their IDs without legacy-format compatibility requirements. This does not authorize discarding the existing project history; decide how to migrate our ledger explicitly when implementing the redesign."], "answers": [], "author": "codex", "branch": "main", "choice": "No. The user explicitly permits breaking changes because Docket is still new. Design claim, decision, and question records and their IDs without legacy-format compatibility requirements. This does not authorize discarding the existing project history; decide how to migrate our ledger explicitly when implementing the redesign.", "cost_if_wrong": "Existing ledgers and integrations may need an explicit migration or update when the new format ships.", "decided_by": "user", "depends_on": [], "evidence": [], "id": "d44", "kind": "decision", "legacy": {"raw": {"answer": "No. The user explicitly permits breaking changes because Docket is still new. Design claim, decision, and question records and their IDs without legacy-format compatibility requirements. This does not authorize discarding the existing project history; decide how to migrate our ledger explicitly when implementing the redesign.", "author": "codex", "because": [], "branch": "main", "cost_if_wrong": "Existing ledgers and integrations may need an explicit migration or update when the new format ships.", "id": "d44", "question": "Must the typed ledger redesign preserve compatibility with the current format?", "session": "", "state": "settled", "supersedes": [], "ts": "2026-09-12T02:35:52+00:00"}, "relation_map": {"mapped_answers": [], "mapped_depends_on": [], "mapped_supersedes": [], "mapped_supports": [], "overrides": [], "source_answers": [], "source_because": [], "source_depends_on": [], "source_supersedes": []}, "source_id": "d44"}, "pinned": true, "rationale": "No. The user explicitly permits breaking changes because Docket is still new. Design claim, decision, and question records and their IDs without legacy-format compatibility requirements. This does not authorize discarding the existing project history; decide how to migrate our ledger explicitly when implementing the redesign.", "revisit": "", "schema": 2, "scope": ["lib/**", "bin/docket"], "session": "", "state": "adopted", "supersedes": [], "supports": [], "text": "Must the typed ledger redesign preserve compatibility with the current format?", "ts": "2026-09-12T02:35:52+00:00"} -{"schema":2,"kind":"decision","id":"d45","text":"What record and context model should Docket 0.8.0 use?","state":"adopted","ts":"2026-09-12T02:49:48+00:00","author":"codex","session":"","branch":"codex/typed-ledger-context","scope":["lib/**","bin/docket"],"rationale":"The user approved typed records, waived backward compatibility, and authorized implementation and release. Keep historical claims distinct from commitments and preserve the original ledger during explicit migration.","supports":[["d17","d44"]],"depends_on":[],"answers":[],"supersedes":["d43"],"evidence":[],"revisit":"","cost_if_wrong":"Incorrect typing or lost relationship semantics could cause later agents to treat uncertain premises as commitments or silently forget constraints.","pinned":true,"choice":"Use explicit claims, decisions, and questions with type-specific states, scoped evidence, separate support and prerequisite relations, and bounded task context. Implement the breaking schema on its own branch, then verify, review, merge, and release it.","alternatives":["Use explicit claims, decisions, and questions with type-specific states, scoped evidence, separate support and prerequisite relations, and bounded task context. Implement the breaking schema on its own branch, then verify, review, merge, and release it."],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d46","text":"What Python version does Docket require?","state":"adopted","ts":"2026-09-12T15:04:41+00:00","author":"claude-code","session":"session_01EQrSFLFsHa2FVUYvbCXqRW","branch":"feat/briefing-core","scope":["installer/**","docs/installation.md","README.md","lib/**"],"rationale":"TOML reads better for hand-edited configuration than JSON, and vendoring tomli would add about a thousand lines of third-party code to own. Python 3.10 reaches end of life in October 2026.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Anyone running Python 3.10 must upgrade before installing or updating Docket.","pinned":false,"choice":"Python 3.11 or later. The floor rises from 3.10 so the ledger CLI can read tunable configuration from .docket/config.toml with stdlib tomllib, keeping the zero-dependency promise.","alternatives":["Python 3.11 or later. The floor rises from 3.10 so the ledger CLI can read tunable configuration from .docket/config.toml with stdlib tomllib, keeping the zero-dependency promise.","Keep 3.10 and use JSON configuration","Keep 3.10 and vendor a TOML parser"],"decided_by":""} -{"schema":2,"kind":"claim","id":"c47","text":"Grounded semantics is the only standard argumentation semantics that yields a unique extension in polynomial time.","state":"accepted","ts":"2026-09-12T20:50:30+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"Least fixpoint of the defended operator. Preferred and stable semantics admit many extensions and sit at Sigma-2-p for credulous acceptance.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://arxiv.org/pdf/1310.4986"}],"revisit":"","cost_if_wrong":"A non-unique resolver would make a briefing show several incompatible answers.","pinned":false} -{"schema":2,"kind":"claim","id":"c48","text":"AGM iterated belief revision is order-dependent in general, and no fully satisfactory iterated-revision theory exists.","state":"accepted","ts":"2026-09-12T20:50:30+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"Darwiche-Pearl repairs part of it by operating on epistemic states, then conflicts with Parikh relevance-sensitivity.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://www.ijcai.org/proceedings/2019/0209.pdf"}],"revisit":"","cost_if_wrong":"A sequential conflict resolver would give session-order-dependent ledgers.","pinned":false} -{"schema":2,"kind":"claim","id":"c49","text":"ATMS-style labels over assumption environments are exponential in the worst case.","state":"accepted","ts":"2026-09-12T20:50:31+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"A node label is the set of minimal environments supporting it. Practical systems prune with nogoods.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://dekleer.org/Publications/An%20Assumption-Based%20TMS.pdf"}],"revisit":"","cost_if_wrong":"Uncapped alternative justification sets make briefing cost unbounded.","pinned":false} -{"schema":2,"kind":"claim","id":"c50","text":"Information placed mid-context loses over 30 percent recall, and related items lose further recall as the distance between them grows.","state":"accepted","ts":"2026-09-12T20:50:43+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/docket_context.py"],"rationale":"Lost-in-the-middle, independently replicated, plus the lost-in-distance follow-up.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://arxiv.org/pdf/2410.01985"}],"revisit":"","cost_if_wrong":"An arrangement that buries high-priority records in the middle of the budget wastes them.","pinned":false} -{"schema":2,"kind":"claim","id":"c51","text":"Leiden and Louvain community detection is unstable on sparse knowledge graphs, because exponentially many partitions have near-equal modularity.","state":"accepted","ts":"2026-09-12T20:50:43+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/docket_context.py"],"rationale":"Reported against GraphRAG. Independent audit also finds its published retrieval gains overstated.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://arxiv.org/pdf/2506.06331"}],"revisit":"","cost_if_wrong":"A community-grouped briefing would reorder itself between runs and break the reproducibility the settings fingerprint promises.","pinned":false} -{"schema":2,"kind":"claim","id":"c52","text":"Bitemporal storage separates valid time from transaction time, so a correction appends a new record and destroys no history.","state":"accepted","ts":"2026-09-12T20:50:43+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/docket_ledger.py"],"rationale":"XTDB models both axes orthogonally. Datomic manages transaction time only.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://v1-docs.xtdb.com/concepts/bitemporality/"}],"revisit":"","cost_if_wrong":"Without valid time, a correction cannot restate when a decision actually held.","pinned":false} -{"schema":2,"kind":"decision","id":"d53","text":"How does Docket decide which records survive a conflict?","state":"adopted","ts":"2026-09-12T20:50:58+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"A fixpoint over the whole graph makes confluence definitional, so no per-topology confluence proof is needed.","supports":[],"depends_on":["c47","c48"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Switching to a non-unique semantics later would make a briefing ambiguous and invalidate the Lean obligations.","pinned":false,"choice":"Compute the grounded extension of a bipolar argumentation framework over the ledger. Conflicts enter as declared attack witnesses, never by pairwise discovery. The result is a least fixpoint, so it is unique and independent of the order conflicts were found.","alternatives":["Compute the grounded extension of a bipolar argumentation framework over the ledger. Conflicts enter as declared attack witnesses, never by pairwise discovery. The result is a least fixpoint, so it is unique and independent of the order conflicts were found.","Sequential revision with a proven-confluent cascade","Preferred or stable extensions"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d54","text":"Does the storage layout depend on the chosen topology?","state":"adopted","ts":"2026-09-12T20:50:58+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"Keeping storage topology-agnostic makes the topology a view, which is cheap to be wrong about.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Topology-specific storage would make every switch a migration and every experiment expensive.","pinned":false,"choice":"No. One canonical append-only record format holds every ledger. A topology is a pure function from stored records to an arrangement, so switching topologies is lossless, reversible, and runs no migration.","alternatives":["No. One canonical append-only record format holds every ledger. A topology is a pure function from stored records to an arrangement, so switching topologies is lossless, reversible, and runs no migration."],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d55","text":"How much of the resolver may a topology change?","state":"adopted","ts":"2026-09-12T20:50:59+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"Per-topology resolvers grow proof debt without bound and let a new topology silently revoke records.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Per-topology resolvers would need a termination and confluence proof each.","pinned":false,"choice":"None of the algorithm. One resolver implementation is parameterized by the cascade edge kinds and a winner ordering. A topology supplies those parameters and its arrangement function only. The proof is universally quantified over the parameter space, and each hypothesis becomes a load-time validation.","alternatives":["None of the algorithm. One resolver implementation is parameterized by the cascade edge kinds and a winner ordering. A topology supplies those parameters and its arrangement function only. The proof is universally quantified over the parameter space, and each hypothesis becomes a load-time validation.","Each topology carries its own conflict predicate and cascade","Resolver fixed with no parameters at all"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d56","text":"How does Docket represent support so retraction stops over-retracting?","state":"adopted","ts":"2026-09-12T20:51:18+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/docket_ledger.py"],"rationale":"A flat list cannot mean both joint requirement and independent alternative. The stress tests already proved it over-retracts.","supports":[],"depends_on":["c49"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Uncapped alternative sets reintroduce the exponential ATMS label blowup.","pinned":false,"choice":"Replace the flat supports list with a set of justification sets. A record survives while any one complete set survives. Today's flat list becomes a single set. Cap the number of alternative sets per record so the label space stays bounded.","alternatives":["Replace the flat supports list with a set of justification sets. A record survives while any one complete set survives. Today's flat list becomes a single set. Cap the number of alternative sets per record so the label space stays bounded."],"decided_by":""} -{"schema":2,"kind":"decision","id":"d57","text":"How does Docket keep briefings cheap when many agents share one project?","state":"adopted","ts":"2026-09-12T20:51:19+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"Grounded extension is O(n+e) with a worklist, so contention and repeated full scans cost more than the algorithm.","supports":[],"depends_on":["c47"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Recomputing the global layer per agent would multiply cost by the number of agents.","pinned":false,"choice":"Cache the grounded extension and the causal attribution under the ledger revision hash, because both are global and identical for every agent. Recompute scoring and arrangement per query, since they are local and need no coordination. Appends stay serialized behind the existing lock.","alternatives":["Cache the grounded extension and the causal attribution under the ledger revision hash, because both are global and identical for every agent. Recompute scoring and arrangement per query, since they are local and need no coordination. Appends stay serialized behind the existing lock."],"decided_by":""} -{"schema":2,"kind":"question","id":"q58","text":"Which topologies ship as built-ins, and what is the per-project default?","state":"open","ts":"2026-09-12T20:51:19+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/**"],"rationale":"Bus, star, tree, mesh and hybrid map onto real ledger structure. Ring has no mapping because support graphs are acyclic. Frontier, justification-closure and conflict-first are use-case shaped and have no network name.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"question","id":"q59","text":"At what record count does archival become required, and how is the archive addressed?","state":"open","ts":"2026-09-12T20:51:28+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["lib/docket_ledger.py"],"rationale":"Selection and compaction tiers already work. Archival must keep the pruned set closed downward under every relation field, or the retraction closure computes the wrong survivors.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} -{"schema":2,"kind":"claim","id":"c60","text":"cost_if_wrong is free text and cannot order records: it is unscored, and 11 of 47 current records leave it empty.","state":"accepted","ts":"2026-09-12T22:17:33+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_context.py"],"rationale":"Validated as any string in docket_ledger.py. No weight in docket_config.py reads it.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"An arrangement that sorts by it would sort English prose lexicographically.","pinned":false} -{"schema":2,"kind":"claim","id":"c61","text":"A retired record never reaches a briefing on its own account, because docket_context.py excludes it from the scored set before selection.","state":"accepted","ts":"2026-09-12T22:17:33+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_context.py"],"rationale":"It enters only when a live record cites it through supports, depends_on, answers or resolved_by. supersedes is not an expansion edge.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Any feature that collapses or reorders superseded records addresses a case that does not occur.","pinned":false} -{"schema":2,"kind":"claim","id":"c62","text":"The lost-in-the-middle finding governs position in the whole context window, and the briefing is injected whole at session start.","state":"disputed","ts":"2026-09-12T22:17:33+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_context.py"],"rationale":"Placing high scorers at the edges of an 8000-character briefing is therefore unverified. The end of a briefing is its index block and footer.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Reordering records for recall could spend effort on an effect that does not apply at document scale.","pinned":false} -{"schema":2,"kind":"decision","id":"d63","text":"What does the first topology-related change actually build?","state":"adopted","ts":"2026-09-12T22:17:45+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_context.py"],"rationale":"Supersession is the only structure this ledger grows: 11 superseding records over 12 edges in 59 records. The three parts of the frontier proposal each rest on something the code or the data refutes.","supports":[],"depends_on":["c60","c61","c62"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Building the arrangement layer first would ship configuration for an ordering nobody has asked for.","pinned":false,"choice":"Render the full transitive supersede chain on the record that heads it. A record prints its direct supersedes targets and stops, so d27 -> d29 -> d35 reads as a two-record commitment. This reads edges the ledger already holds, changes no schema, and adds no configuration. The arrangement layer, ordering rules and additional topologies are deferred until a second ordering has a reason to exist.","alternatives":["Render the full transitive supersede chain on the record that heads it. A record prints its direct supersedes targets and stops, so d27 -> d29 -> d35 reads as a two-record commitment. This reads edges the ledger already holds, changes no schema, and adds no configuration. The arrangement layer, ordering rules and additional topologies are deferred until a second ordering has a reason to exist.","Frontier arrangement: collapse superseded records, order by cost_if_wrong, place high scorers at the budget edges","Build the resolver and attack witnesses first"],"decided_by":"user"} +{"alternatives":["Merge PR #5 and reconcile the project ledger first, then explore richer metadata and efficient context delivery. Implement the selected design later on a separate branch. Dependency semantics and the context architecture are not selected yet; preserve the existing alternative conjunctive because sets during this design work."],"answers":[],"author":"codex","branch":"main","choice":"Merge PR #5 and reconcile the project ledger first, then explore richer metadata and efficient context delivery. Implement the selected design later on a separate branch. Dependency semantics and the context architecture are not selected yet; preserve the existing alternative conjunctive because sets during this design work.","cost_if_wrong":"Mixing an unapproved schema or context redesign into the installer and graph work makes compatibility and validation harder to assess.","decided_by":"user","depends_on":[],"evidence":[],"id":"d43","kind":"decision","legacy":{"raw":{"answer":"Merge PR #5 and reconcile the project ledger first, then explore richer metadata and efficient context delivery. Implement the selected design later on a separate branch. Dependency semantics and the context architecture are not selected yet; preserve the existing alternative conjunctive because sets during this design work.","author":"codex","because":[["d17"]],"branch":"main","cost_if_wrong":"Mixing an unapproved schema or context redesign into the installer and graph work makes compatibility and validation harder to assess.","id":"d43","question":"When should richer decision metadata and agent context delivery be implemented?","session":"","state":"settled","supersedes":[],"ts":"2026-09-12T02:27:26+00:00"},"relation_map":{"mapped_answers":[],"mapped_depends_on":[],"mapped_supersedes":[],"mapped_supports":[["d17"]],"overrides":[],"source_answers":[],"source_because":[["d17"]],"source_depends_on":[],"source_supersedes":[]},"source_id":"d43"},"pinned":false,"rationale":"Merge PR #5 and reconcile the project ledger first, then explore richer metadata and efficient context delivery. Implement the selected design later on a separate branch. Dependency semantics and the context architecture are not selected yet; preserve the existing alternative conjunctive because sets during this design work.","revisit":"","schema":2,"scope":["docket/**"],"session":"","state":"adopted","supersedes":[],"supports":[["d17"]],"text":"When should richer decision metadata and agent context delivery be implemented?","ts":"2026-09-12T02:27:26+00:00"} +{"alternatives":["No. The user explicitly permits breaking changes because Docket is still new. Design claim, decision, and question records and their IDs without legacy-format compatibility requirements. This does not authorize discarding the existing project history; decide how to migrate our ledger explicitly when implementing the redesign."],"answers":[],"author":"codex","branch":"main","choice":"No. The user explicitly permits breaking changes because Docket is still new. Design claim, decision, and question records and their IDs without legacy-format compatibility requirements. This does not authorize discarding the existing project history; decide how to migrate our ledger explicitly when implementing the redesign.","cost_if_wrong":"Existing ledgers and integrations may need an explicit migration or update when the new format ships.","decided_by":"user","depends_on":[],"evidence":[],"id":"d44","kind":"decision","legacy":{"raw":{"answer":"No. The user explicitly permits breaking changes because Docket is still new. Design claim, decision, and question records and their IDs without legacy-format compatibility requirements. This does not authorize discarding the existing project history; decide how to migrate our ledger explicitly when implementing the redesign.","author":"codex","because":[],"branch":"main","cost_if_wrong":"Existing ledgers and integrations may need an explicit migration or update when the new format ships.","id":"d44","question":"Must the typed ledger redesign preserve compatibility with the current format?","session":"","state":"settled","supersedes":[],"ts":"2026-09-12T02:35:52+00:00"},"relation_map":{"mapped_answers":[],"mapped_depends_on":[],"mapped_supersedes":[],"mapped_supports":[],"overrides":[],"source_answers":[],"source_because":[],"source_depends_on":[],"source_supersedes":[]},"source_id":"d44"},"pinned":true,"rationale":"No. The user explicitly permits breaking changes because Docket is still new. Design claim, decision, and question records and their IDs without legacy-format compatibility requirements. This does not authorize discarding the existing project history; decide how to migrate our ledger explicitly when implementing the redesign.","revisit":"","schema":2,"scope":["docket/**"],"session":"","state":"adopted","supersedes":[],"supports":[],"text":"Must the typed ledger redesign preserve compatibility with the current format?","ts":"2026-09-12T02:35:52+00:00"} +{"schema":2,"kind":"decision","id":"d45","text":"What record and context model should Docket 0.8.0 use?","state":"adopted","ts":"2026-09-12T02:49:48+00:00","author":"codex","session":"","branch":"codex/typed-ledger-context","scope":["docket/**"],"rationale":"The user approved typed records, waived backward compatibility, and authorized implementation and release. Keep historical claims distinct from commitments and preserve the original ledger during explicit migration.","supports":[["d17","d44"]],"depends_on":[],"answers":[],"supersedes":["d43"],"evidence":[],"revisit":"","cost_if_wrong":"Incorrect typing or lost relationship semantics could cause later agents to treat uncertain premises as commitments or silently forget constraints.","pinned":true,"choice":"Use explicit claims, decisions, and questions with type-specific states, scoped evidence, separate support and prerequisite relations, and bounded task context. Implement the breaking schema on its own branch, then verify, review, merge, and release it.","alternatives":["Use explicit claims, decisions, and questions with type-specific states, scoped evidence, separate support and prerequisite relations, and bounded task context. Implement the breaking schema on its own branch, then verify, review, merge, and release it."],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d46","text":"What Python version does Docket require?","state":"adopted","ts":"2026-09-12T15:04:41+00:00","author":"claude-code","session":"session_01EQrSFLFsHa2FVUYvbCXqRW","branch":"feat/briefing-core","scope":["installer/**","docs/installation.md","README.md","docket/**"],"rationale":"TOML reads better for hand-edited configuration than JSON, and vendoring tomli would add about a thousand lines of third-party code to own. Python 3.10 reaches end of life in October 2026.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Anyone running Python 3.10 must upgrade before installing or updating Docket.","pinned":false,"choice":"Python 3.11 or later. The floor rises from 3.10 so the ledger CLI can read tunable configuration from .docket/config.toml with stdlib tomllib, keeping the zero-dependency promise.","alternatives":["Python 3.11 or later. The floor rises from 3.10 so the ledger CLI can read tunable configuration from .docket/config.toml with stdlib tomllib, keeping the zero-dependency promise.","Keep 3.10 and use JSON configuration","Keep 3.10 and vendor a TOML parser"],"decided_by":""} +{"schema":2,"kind":"claim","id":"c47","text":"Grounded semantics is the only standard argumentation semantics that yields a unique extension in polynomial time.","state":"accepted","ts":"2026-09-12T20:50:30+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/**"],"rationale":"Least fixpoint of the defended operator. Preferred and stable semantics admit many extensions and sit at Sigma-2-p for credulous acceptance.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://arxiv.org/pdf/1310.4986"}],"revisit":"","cost_if_wrong":"A non-unique resolver would make a briefing show several incompatible answers.","pinned":false} +{"schema":2,"kind":"claim","id":"c48","text":"AGM iterated belief revision is order-dependent in general, and no fully satisfactory iterated-revision theory exists.","state":"accepted","ts":"2026-09-12T20:50:30+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/**"],"rationale":"Darwiche-Pearl repairs part of it by operating on epistemic states, then conflicts with Parikh relevance-sensitivity.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://www.ijcai.org/proceedings/2019/0209.pdf"}],"revisit":"","cost_if_wrong":"A sequential conflict resolver would give session-order-dependent ledgers.","pinned":false} +{"schema":2,"kind":"claim","id":"c49","text":"ATMS-style labels over assumption environments are exponential in the worst case.","state":"accepted","ts":"2026-09-12T20:50:31+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/**"],"rationale":"A node label is the set of minimal environments supporting it. Practical systems prune with nogoods.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://dekleer.org/Publications/An%20Assumption-Based%20TMS.pdf"}],"revisit":"","cost_if_wrong":"Uncapped alternative justification sets make briefing cost unbounded.","pinned":false} +{"schema":2,"kind":"claim","id":"c50","text":"Information placed mid-context loses over 30 percent recall, and related items lose further recall as the distance between them grows.","state":"accepted","ts":"2026-09-12T20:50:43+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/context.py"],"rationale":"Lost-in-the-middle, independently replicated, plus the lost-in-distance follow-up.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://arxiv.org/pdf/2410.01985"}],"revisit":"","cost_if_wrong":"An arrangement that buries high-priority records in the middle of the budget wastes them.","pinned":false} +{"schema":2,"kind":"claim","id":"c51","text":"Leiden and Louvain community detection is unstable on sparse knowledge graphs, because exponentially many partitions have near-equal modularity.","state":"accepted","ts":"2026-09-12T20:50:43+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/context.py"],"rationale":"Reported against GraphRAG. Independent audit also finds its published retrieval gains overstated.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://arxiv.org/pdf/2506.06331"}],"revisit":"","cost_if_wrong":"A community-grouped briefing would reorder itself between runs and break the reproducibility the settings fingerprint promises.","pinned":false} +{"schema":2,"kind":"claim","id":"c52","text":"Bitemporal storage separates valid time from transaction time, so a correction appends a new record and destroys no history.","state":"accepted","ts":"2026-09-12T20:50:43+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/ledger.py"],"rationale":"XTDB models both axes orthogonally. Datomic manages transaction time only.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://v1-docs.xtdb.com/concepts/bitemporality/"}],"revisit":"","cost_if_wrong":"Without valid time, a correction cannot restate when a decision actually held.","pinned":false} +{"schema":2,"kind":"decision","id":"d53","text":"How does Docket decide which records survive a conflict?","state":"adopted","ts":"2026-09-12T20:50:58+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/**"],"rationale":"A fixpoint over the whole graph makes confluence definitional, so no per-topology confluence proof is needed.","supports":[],"depends_on":["c47","c48"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Switching to a non-unique semantics later would make a briefing ambiguous and invalidate the Lean obligations.","pinned":false,"choice":"Compute the grounded extension of a bipolar argumentation framework over the ledger. Conflicts enter as declared attack witnesses, never by pairwise discovery. The result is a least fixpoint, so it is unique and independent of the order conflicts were found.","alternatives":["Compute the grounded extension of a bipolar argumentation framework over the ledger. Conflicts enter as declared attack witnesses, never by pairwise discovery. The result is a least fixpoint, so it is unique and independent of the order conflicts were found.","Sequential revision with a proven-confluent cascade","Preferred or stable extensions"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d54","text":"Does the storage layout depend on the chosen topology?","state":"adopted","ts":"2026-09-12T20:50:58+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/**"],"rationale":"Keeping storage topology-agnostic makes the topology a view, which is cheap to be wrong about.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Topology-specific storage would make every switch a migration and every experiment expensive.","pinned":false,"choice":"No. One canonical append-only record format holds every ledger. A topology is a pure function from stored records to an arrangement, so switching topologies is lossless, reversible, and runs no migration.","alternatives":["No. One canonical append-only record format holds every ledger. A topology is a pure function from stored records to an arrangement, so switching topologies is lossless, reversible, and runs no migration."],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d55","text":"How much of the resolver may a topology change?","state":"adopted","ts":"2026-09-12T20:50:59+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/**"],"rationale":"Per-topology resolvers grow proof debt without bound and let a new topology silently revoke records.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Per-topology resolvers would need a termination and confluence proof each.","pinned":false,"choice":"None of the algorithm. One resolver implementation is parameterized by the cascade edge kinds and a winner ordering. A topology supplies those parameters and its arrangement function only. The proof is universally quantified over the parameter space, and each hypothesis becomes a load-time validation.","alternatives":["None of the algorithm. One resolver implementation is parameterized by the cascade edge kinds and a winner ordering. A topology supplies those parameters and its arrangement function only. The proof is universally quantified over the parameter space, and each hypothesis becomes a load-time validation.","Each topology carries its own conflict predicate and cascade","Resolver fixed with no parameters at all"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d56","text":"How does Docket represent support so retraction stops over-retracting?","state":"adopted","ts":"2026-09-12T20:51:18+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/ledger.py"],"rationale":"A flat list cannot mean both joint requirement and independent alternative. The stress tests already proved it over-retracts.","supports":[],"depends_on":["c49"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Uncapped alternative sets reintroduce the exponential ATMS label blowup.","pinned":false,"choice":"Replace the flat supports list with a set of justification sets. A record survives while any one complete set survives. Today's flat list becomes a single set. Cap the number of alternative sets per record so the label space stays bounded.","alternatives":["Replace the flat supports list with a set of justification sets. A record survives while any one complete set survives. Today's flat list becomes a single set. Cap the number of alternative sets per record so the label space stays bounded."],"decided_by":""} +{"schema":2,"kind":"decision","id":"d57","text":"How does Docket keep briefings cheap when many agents share one project?","state":"adopted","ts":"2026-09-12T20:51:19+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/**"],"rationale":"Grounded extension is O(n+e) with a worklist, so contention and repeated full scans cost more than the algorithm.","supports":[],"depends_on":["c47"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Recomputing the global layer per agent would multiply cost by the number of agents.","pinned":false,"choice":"Cache the grounded extension and the causal attribution under the ledger revision hash, because both are global and identical for every agent. Recompute scoring and arrangement per query, since they are local and need no coordination. Appends stay serialized behind the existing lock.","alternatives":["Cache the grounded extension and the causal attribution under the ledger revision hash, because both are global and identical for every agent. Recompute scoring and arrangement per query, since they are local and need no coordination. Appends stay serialized behind the existing lock."],"decided_by":""} +{"schema":2,"kind":"question","id":"q58","text":"Which topologies ship as built-ins, and what is the per-project default?","state":"open","ts":"2026-09-12T20:51:19+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/**"],"rationale":"Bus, star, tree, mesh and hybrid map onto real ledger structure. Ring has no mapping because support graphs are acyclic. Frontier, justification-closure and conflict-first are use-case shaped and have no network name.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} +{"schema":2,"kind":"question","id":"q59","text":"At what record count does archival become required, and how is the archive addressed?","state":"open","ts":"2026-09-12T20:51:28+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["docket/ledger.py"],"rationale":"Selection and compaction tiers already work. Archival must keep the pruned set closed downward under every relation field, or the retraction closure computes the wrong survivors.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} +{"schema":2,"kind":"claim","id":"c60","text":"cost_if_wrong is free text and cannot order records: it is unscored, and 11 of 47 current records leave it empty.","state":"accepted","ts":"2026-09-12T22:17:33+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["docket/context.py"],"rationale":"Validated as any string in docket_ledger.py. No weight in docket_config.py reads it.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"An arrangement that sorts by it would sort English prose lexicographically.","pinned":false} +{"schema":2,"kind":"claim","id":"c61","text":"A retired record never reaches a briefing on its own account, because docket_context.py excludes it from the scored set before selection.","state":"accepted","ts":"2026-09-12T22:17:33+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["docket/context.py"],"rationale":"It enters only when a live record cites it through supports, depends_on, answers or resolved_by. supersedes is not an expansion edge.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Any feature that collapses or reorders superseded records addresses a case that does not occur.","pinned":false} +{"schema":2,"kind":"claim","id":"c62","text":"The lost-in-the-middle finding governs position in the whole context window, and the briefing is injected whole at session start.","state":"disputed","ts":"2026-09-12T22:17:33+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["docket/context.py"],"rationale":"Placing high scorers at the edges of an 8000-character briefing is therefore unverified. The end of a briefing is its index block and footer.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Reordering records for recall could spend effort on an effect that does not apply at document scale.","pinned":false} +{"schema":2,"kind":"decision","id":"d63","text":"What does the first topology-related change actually build?","state":"adopted","ts":"2026-09-12T22:17:45+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["docket/context.py"],"rationale":"Supersession is the only structure this ledger grows: 11 superseding records over 12 edges in 59 records. The three parts of the frontier proposal each rest on something the code or the data refutes.","supports":[],"depends_on":["c60","c61","c62"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Building the arrangement layer first would ship configuration for an ordering nobody has asked for.","pinned":false,"choice":"Render the full transitive supersede chain on the record that heads it. A record prints its direct supersedes targets and stops, so d27 -> d29 -> d35 reads as a two-record commitment. This reads edges the ledger already holds, changes no schema, and adds no configuration. The arrangement layer, ordering rules and additional topologies are deferred until a second ordering has a reason to exist.","alternatives":["Render the full transitive supersede chain on the record that heads it. A record prints its direct supersedes targets and stops, so d27 -> d29 -> d35 reads as a two-record commitment. This reads edges the ledger already holds, changes no schema, and adds no configuration. The arrangement layer, ordering rules and additional topologies are deferred until a second ordering has a reason to exist.","Frontier arrangement: collapse superseded records, order by cost_if_wrong, place high scorers at the budget edges","Build the resolver and attack witnesses first"],"decided_by":"user"} {"schema":2,"kind":"decision","id":"d64","text":"Where do design specs live?","state":"adopted","ts":"2026-09-12T22:17:45+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["docs/**"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Tracking specs would duplicate the ledger's job and leave two places to check for a decision.","pinned":false,"choice":"Keep specs untracked. docs/superpowers/ is gitignored, and decisions belong in the ledger where they carry provenance, supersession and scope. A spec is a working document; the ledger is the record.","alternatives":["Keep specs untracked. docs/superpowers/ is gitignored, and decisions belong in the ledger where they carry provenance, supersession and scope. A spec is a working document; the ledger is the record."],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d65","text":"How does Docket represent support, and what bounds the label space?","state":"adopted","ts":"2026-09-12T22:17:53+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_ledger.py"],"rationale":"d56 proposed replacing a flat supports list. That replacement had already shipped; docket_ledger.py validates supports as a list of conjunctive ID lists.","supports":[],"depends_on":["c49"],"answers":[],"supersedes":["d56"],"evidence":[],"revisit":"","cost_if_wrong":"Recording shipped work as pending sends a later agent to build it twice.","pinned":false,"choice":"Support is already a list of conjunctive ID lists, so alternative justification sets exist in the schema today and the c10 over-retraction is fixed. No record uses more than one set. What remains open is the cardinality cap that bounds the ATMS label space; its number waits for the first record that needs a second set.","alternatives":["Support is already a list of conjunctive ID lists, so alternative justification sets exist in the schema today and the c10 over-retraction is fixed. No record uses more than one set. What remains open is the cardinality cap that bounds the ATMS label space; its number waits for the first record that needs a second set."],"decided_by":""} -{"schema":2,"kind":"decision","id":"d66","text":"When does Docket tell someone that archival is due?","state":"adopted","ts":"2026-09-12T22:21:01+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_ledger.py"],"rationale":"Measured on synthetic ledgers after the briefing became linear in ledger size. 100000 records render in about 21 seconds.","supports":[],"depends_on":[],"answers":["q59"],"supersedes":[],"evidence":[{"ref":"fix/briefing-scaling benchmark, 2026-09-13"}],"revisit":"","cost_if_wrong":"A threshold set far from where latency actually bites would train people to ignore the warning.","pinned":false,"choice":"Warn at 10000 records. The briefing renders 1000 records in 0.41s and 10000 in 1.84s, so a session start stays under a second below the threshold and becomes noticeable above it. The warning names the count and the command; it never archives on its own. Archival itself stays unbuilt.","alternatives":["Warn at 10000 records. The briefing renders 1000 records in 0.41s and 10000 in 1.84s, so a session start stays under a second below the threshold and becomes noticeable above it. The warning names the count and the command; it never archives on its own. Archival itself stays unbuilt.","No threshold: selection and compaction suffice indefinitely","A hard limit that refuses to render"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d67","text":"Which topologies ship, and what is the per-project default?","state":"adopted","ts":"2026-09-12T22:21:22+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/**"],"rationale":"Every candidate dissolved against the ledger data. A scope tree is nearly flat at 12 overlapping scopes. Mesh is the absence of arrangement. Closure has no audit to serve. Building a seam for zero implementations gets the seam wrong.","supports":[],"depends_on":["c60","c61","c62","d63"],"answers":["q58"],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Config surface and a validation path, once shipped, are carried whether or not anyone uses them. Deferring costs only the work of building later.","pinned":false,"choice":"None ship, and there is no default. The arrangement layer stays unbuilt and the config surface stays at zero. The trigger to revisit is a second ordering someone actually wants after supersede chains become legible through d63.","alternatives":["None ship, and there is no default. The arrangement layer stays unbuilt and the config surface stays at zero. The trigger to revisit is a second ordering someone actually wants after supersede chains become legible through d63.","Ship frontier as behaviour with no config","Ship frontier and a scope tree, selectable per project"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d68","text":"What does the first topology-related change actually build?","state":"adopted","ts":"2026-09-12T22:22:12+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_context.py"],"rationale":"Supersession is the only structure this ledger grows: 11 superseding records over 12 edges in 59 records. The three parts of the frontier proposal each rest on something the code or the data refutes. Recorded against d63, which used depends_on for the same claims and so read them as prerequisites rather than as justification.","supports":[["c60","c61","c62"]],"depends_on":[],"answers":[],"supersedes":["d63"],"evidence":[],"revisit":"","cost_if_wrong":"Building the arrangement layer first would ship configuration for an ordering nobody has asked for.","pinned":false,"choice":"Render the full transitive supersede chain on the record that heads it. A record prints its direct supersedes targets and stops, so d27 -> d29 -> d35 reads as a two-record commitment. This reads edges the ledger already holds, changes no schema, and adds no configuration. The arrangement layer, ordering rules and additional topologies are deferred until a second ordering has a reason to exist.","alternatives":["Render the full transitive supersede chain on the record that heads it. A record prints its direct supersedes targets and stops, so d27 -> d29 -> d35 reads as a two-record commitment. This reads edges the ledger already holds, changes no schema, and adds no configuration. The arrangement layer, ordering rules and additional topologies are deferred until a second ordering has a reason to exist.","Frontier arrangement: collapse superseded records, order by cost_if_wrong, place high scorers at the budget edges","Build the resolver and attack witnesses first"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d69","text":"Which topologies ship, and what is the per-project default?","state":"adopted","ts":"2026-09-12T22:22:21+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/**"],"rationale":"Every candidate dissolved against the ledger data. A scope tree is nearly flat at 12 overlapping scopes. Mesh is the absence of arrangement. Closure has no audit to serve. Building a seam for zero implementations gets the seam wrong.","supports":[["c60","c61","c62","d68"]],"depends_on":[],"answers":["q58"],"supersedes":["d67"],"evidence":[],"revisit":"","cost_if_wrong":"Config surface and a validation path, once shipped, are carried whether or not anyone uses them. Deferring costs only the work of building later.","pinned":false,"choice":"None ship, and there is no default. The arrangement layer stays unbuilt and the config surface stays at zero. The trigger to revisit is a second ordering someone actually wants after supersede chains become legible through d68.","alternatives":["None ship, and there is no default. The arrangement layer stays unbuilt and the config surface stays at zero. The trigger to revisit is a second ordering someone actually wants after supersede chains become legible through d68.","Ship frontier as behaviour with no config","Ship frontier and a scope tree, selectable per project"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d70","text":"What bounds label growth under alternative justifications?","state":"adopted","ts":"2026-09-12T22:23:37+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["lib/docket_ledger.py"],"rationale":"d65 named a cap as the mechanism. A cap refuses a valid justification to save space, which corrupts the record. Minimality is the standard ATMS answer and is lossless, though de Kleer shows minimal labels still grow exponentially in the worst case, so a backstop remains.","supports":[["c49"]],"depends_on":[],"answers":["q18"],"supersedes":["d65"],"evidence":[{"ref":"https://dekleer.org/Publications/An%20Assumption-Based%20TMS.pdf"}],"revisit":"","cost_if_wrong":"Capping without subsumption would drop justifications that subsumption would have shown redundant.","pinned":false,"choice":"Reconcile by subsumption, and keep a cardinality cap only as a backstop. When a record gains a justification set, drop any stored set that is a strict superset of another, which is how an ATMS keeps labels minimal. Subsumption never discards a justification the remaining sets do not already imply. A cap does discard one, so it fires last and should never fire. Neither is built: no record carries a second justification set.","alternatives":["Reconcile by subsumption, and keep a cardinality cap only as a backstop. When a record gains a justification set, drop any stored set that is a strict superset of another, which is how an ATMS keeps labels minimal. Subsumption never discards a justification the remaining sets do not already imply. A cap does discard one, so it fires last and should never fire. Neither is built: no record carries a second justification set.","A hard cardinality cap alone","No bound; accept exponential growth"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d65","text":"How does Docket represent support, and what bounds the label space?","state":"adopted","ts":"2026-09-12T22:17:53+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["docket/ledger.py"],"rationale":"d56 proposed replacing a flat supports list. That replacement had already shipped; docket_ledger.py validates supports as a list of conjunctive ID lists.","supports":[],"depends_on":["c49"],"answers":[],"supersedes":["d56"],"evidence":[],"revisit":"","cost_if_wrong":"Recording shipped work as pending sends a later agent to build it twice.","pinned":false,"choice":"Support is already a list of conjunctive ID lists, so alternative justification sets exist in the schema today and the c10 over-retraction is fixed. No record uses more than one set. What remains open is the cardinality cap that bounds the ATMS label space; its number waits for the first record that needs a second set.","alternatives":["Support is already a list of conjunctive ID lists, so alternative justification sets exist in the schema today and the c10 over-retraction is fixed. No record uses more than one set. What remains open is the cardinality cap that bounds the ATMS label space; its number waits for the first record that needs a second set."],"decided_by":""} +{"schema":2,"kind":"decision","id":"d66","text":"When does Docket tell someone that archival is due?","state":"adopted","ts":"2026-09-12T22:21:01+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["docket/ledger.py"],"rationale":"Measured on synthetic ledgers after the briefing became linear in ledger size. 100000 records render in about 21 seconds.","supports":[],"depends_on":[],"answers":["q59"],"supersedes":[],"evidence":[{"ref":"fix/briefing-scaling benchmark, 2026-09-13"}],"revisit":"","cost_if_wrong":"A threshold set far from where latency actually bites would train people to ignore the warning.","pinned":false,"choice":"Warn at 10000 records. The briefing renders 1000 records in 0.41s and 10000 in 1.84s, so a session start stays under a second below the threshold and becomes noticeable above it. The warning names the count and the command; it never archives on its own. Archival itself stays unbuilt.","alternatives":["Warn at 10000 records. The briefing renders 1000 records in 0.41s and 10000 in 1.84s, so a session start stays under a second below the threshold and becomes noticeable above it. The warning names the count and the command; it never archives on its own. Archival itself stays unbuilt.","No threshold: selection and compaction suffice indefinitely","A hard limit that refuses to render"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d67","text":"Which topologies ship, and what is the per-project default?","state":"adopted","ts":"2026-09-12T22:21:22+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["docket/**"],"rationale":"Every candidate dissolved against the ledger data. A scope tree is nearly flat at 12 overlapping scopes. Mesh is the absence of arrangement. Closure has no audit to serve. Building a seam for zero implementations gets the seam wrong.","supports":[],"depends_on":["c60","c61","c62","d63"],"answers":["q58"],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Config surface and a validation path, once shipped, are carried whether or not anyone uses them. Deferring costs only the work of building later.","pinned":false,"choice":"None ship, and there is no default. The arrangement layer stays unbuilt and the config surface stays at zero. The trigger to revisit is a second ordering someone actually wants after supersede chains become legible through d63.","alternatives":["None ship, and there is no default. The arrangement layer stays unbuilt and the config surface stays at zero. The trigger to revisit is a second ordering someone actually wants after supersede chains become legible through d63.","Ship frontier as behaviour with no config","Ship frontier and a scope tree, selectable per project"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d68","text":"What does the first topology-related change actually build?","state":"adopted","ts":"2026-09-12T22:22:12+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["docket/context.py"],"rationale":"Supersession is the only structure this ledger grows: 11 superseding records over 12 edges in 59 records. The three parts of the frontier proposal each rest on something the code or the data refutes. Recorded against d63, which used depends_on for the same claims and so read them as prerequisites rather than as justification.","supports":[["c60","c61","c62"]],"depends_on":[],"answers":[],"supersedes":["d63"],"evidence":[],"revisit":"","cost_if_wrong":"Building the arrangement layer first would ship configuration for an ordering nobody has asked for.","pinned":false,"choice":"Render the full transitive supersede chain on the record that heads it. A record prints its direct supersedes targets and stops, so d27 -> d29 -> d35 reads as a two-record commitment. This reads edges the ledger already holds, changes no schema, and adds no configuration. The arrangement layer, ordering rules and additional topologies are deferred until a second ordering has a reason to exist.","alternatives":["Render the full transitive supersede chain on the record that heads it. A record prints its direct supersedes targets and stops, so d27 -> d29 -> d35 reads as a two-record commitment. This reads edges the ledger already holds, changes no schema, and adds no configuration. The arrangement layer, ordering rules and additional topologies are deferred until a second ordering has a reason to exist.","Frontier arrangement: collapse superseded records, order by cost_if_wrong, place high scorers at the budget edges","Build the resolver and attack witnesses first"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d69","text":"Which topologies ship, and what is the per-project default?","state":"adopted","ts":"2026-09-12T22:22:21+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["docket/**"],"rationale":"Every candidate dissolved against the ledger data. A scope tree is nearly flat at 12 overlapping scopes. Mesh is the absence of arrangement. Closure has no audit to serve. Building a seam for zero implementations gets the seam wrong.","supports":[["c60","c61","c62","d68"]],"depends_on":[],"answers":["q58"],"supersedes":["d67"],"evidence":[],"revisit":"","cost_if_wrong":"Config surface and a validation path, once shipped, are carried whether or not anyone uses them. Deferring costs only the work of building later.","pinned":false,"choice":"None ship, and there is no default. The arrangement layer stays unbuilt and the config surface stays at zero. The trigger to revisit is a second ordering someone actually wants after supersede chains become legible through d68.","alternatives":["None ship, and there is no default. The arrangement layer stays unbuilt and the config surface stays at zero. The trigger to revisit is a second ordering someone actually wants after supersede chains become legible through d68.","Ship frontier as behaviour with no config","Ship frontier and a scope tree, selectable per project"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d70","text":"What bounds label growth under alternative justifications?","state":"adopted","ts":"2026-09-12T22:23:37+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"fix/briefing-scaling","scope":["docket/ledger.py"],"rationale":"d65 named a cap as the mechanism. A cap refuses a valid justification to save space, which corrupts the record. Minimality is the standard ATMS answer and is lossless, though de Kleer shows minimal labels still grow exponentially in the worst case, so a backstop remains.","supports":[["c49"]],"depends_on":[],"answers":["q18"],"supersedes":["d65"],"evidence":[{"ref":"https://dekleer.org/Publications/An%20Assumption-Based%20TMS.pdf"}],"revisit":"","cost_if_wrong":"Capping without subsumption would drop justifications that subsumption would have shown redundant.","pinned":false,"choice":"Reconcile by subsumption, and keep a cardinality cap only as a backstop. When a record gains a justification set, drop any stored set that is a strict superset of another, which is how an ATMS keeps labels minimal. Subsumption never discards a justification the remaining sets do not already imply. A cap does discard one, so it fires last and should never fire. Neither is built: no record carries a second justification set.","alternatives":["Reconcile by subsumption, and keep a cardinality cap only as a backstop. When a record gains a justification set, drop any stored set that is a strict superset of another, which is how an ATMS keeps labels minimal. Subsumption never discards a justification the remaining sets do not already imply. A cap does discard one, so it fires last and should never fire. Neither is built: no record carries a second justification set.","A hard cardinality cap alone","No bound; accept exponential growth"],"decided_by":"user"} {"schema":2,"kind":"decision","id":"d71","text":"When does the project ledger get committed?","state":"adopted","ts":"2026-09-12T23:27:53+00:00","author":"claude-code","session":"session_01V5YKaGJgzvYffRCPzEM4GA","branch":"main","scope":["justfile"],"rationale":"Only main ever commits the ledger, so two branches appending records never conflict and docket rebase is not needed here. A release snapshot also matches what a reader of that tag would want: the decisions in force at the time.","supports":[["d64"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A decision recorded and then lost before a release never reaches the repository. The working tree carries it in the meantime.","pinned":false,"choice":"Only at a release. The ledger is tracked, and just release stages .docket alongside the version bump, so each release ships a snapshot of the decisions that held when it went out. Between releases the ledger stays dirty in the working tree; the release recipe excludes .docket from its clean check and refuses any other uncommitted change.","alternatives":["Only at a release. The ledger is tracked, and just release stages .docket alongside the version bump, so each release ships a snapshot of the decisions that held when it went out. Between releases the ledger stays dirty in the working tree; the release recipe excludes .docket from its clean check and refuses any other uncommitted change.","Commit the ledger with every change that records a decision","Keep the ledger untracked"],"decided_by":"user"} {"schema":2,"kind":"decision","id":"d72","text":"Where do the published docs live, and how is the GitBook site structured?","state":"adopted","ts":"2026-09-13T15:03:56+00:00","author":"claude-code","session":"session_01Ba1BDHfYkW4jh4foQWhT1K","branch":"main","scope":["docs/**","gitbook-docs.yaml"],"rationale":"The flat directory already held every public page. SUMMARY.md gives sidebar grouping without moving a file or breaking an internal link.","supports":[["d21"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Moving pages into subdirectories later means new space keys, a changed site URL for every page, and rewritten internal links.","pinned":false,"choice":"Keep every published page flat in docs/ and map it onto a single GitBook space through gitbook-docs.yaml at the repository root. docs/SUMMARY.md groups the sidebar; GitBook sections would require subdirectories and rewritten links for no gain. docs/superpowers/ stays gitignored, so GitBook cannot see it. experiments/ and installer/PORT.md stay unpublished.","alternatives":["Keep every published page flat in docs/ and map it onto a single GitBook space through gitbook-docs.yaml at the repository root. docs/SUMMARY.md groups the sidebar; GitBook sections would require subdirectories and rewritten links for no gain. docs/superpowers/ stays gitignored, so GitBook cannot see it. experiments/ and installer/PORT.md stay unpublished."],"decided_by":"user"} {"schema":2,"kind":"decision","id":"d73","text":"How are the published docs split between plain guides and technical pages?","state":"adopted","ts":"2026-09-13T15:04:05+00:00","author":"claude-code","session":"session_01Ba1BDHfYkW4jh4foQWhT1K","branch":"main","scope":["docs/**"],"rationale":"The pre-existing docs were reference and design notes, so no page carried a new user from install to daily use.","supports":[["d21"]],"depends_on":["d72"],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Mixing reference detail back into the guides returns the docs to a state where a new user has no entry point.","pinned":false,"choice":"Lead with task guides written in plain declarative English: quickstart, recording, reading, agents, commands, maintenance. Keep the reference and design notes as separate pages that a guide links to when a reader wants depth. Avoid em dashes unless a sentence genuinely needs one, and prefer flowing sentences over comma-heavy ones.","alternatives":["Lead with task guides written in plain declarative English: quickstart, recording, reading, agents, commands, maintenance. Keep the reference and design notes as separate pages that a guide links to when a reader wants depth. Avoid em dashes unless a sentence genuinely needs one, and prefer flowing sentences over comma-heavy ones."],"decided_by":"user"} {"schema":2,"kind":"decision","id":"d74","text":"How should user-facing documentation be written and checked?","state":"adopted","ts":"2026-09-13T15:18:45+00:00","author":"novusedge","session":"","branch":"main","scope":["docs/**","README.md"],"rationale":"The user clarified the requested Stoat-style documentation treatment and explicitly requested a hallucination check.","supports":[["d73"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Strict reference-style prose can make the guides hard to use, while unsupported examples can send readers down a broken setup path.","pinned":false,"choice":"Use simple, flowing, declarative English and task-based guides. Keep technical depth on separate optional pages. Apply de-slopify lightly to these guides, avoid unnecessary em dashes, and check generated claims and examples against source, executable walkthroughs, or current primary references.","alternatives":["Use simple, flowing, declarative English and task-based guides. Keep technical depth on separate optional pages. Apply de-slopify lightly to these guides, avoid unnecessary em dashes, and check generated claims and examples against source, executable walkthroughs, or current primary references."],"decided_by":"novusedge"} {"schema":2,"kind":"question","id":"q75","text":"How should the installer place the OpenCode plugin so automatic discovery finds it?","state":"open","ts":"2026-09-13T15:19:14+00:00","author":"novusedge","session":"","branch":"main","scope":["installer/planner.go","docs/integrations.md"],"rationale":"planOpenCode writes plugins/docket/index.ts. The current upstream OpenCode ConfigPlugin loader scans {plugin,plugins}/*.{ts,js}. The docs now explain manual placement at plugins/docket.ts; the installer implementation still needs correction.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"installer/planner.go: planOpenCode"},{"ref":"https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/config/plugin.ts"}],"revisit":"","cost_if_wrong":"A completed install can leave OpenCode without a Docket briefing.","pinned":false} {"schema":2,"kind":"decision","id":"d76","text":"How should users start Docket setup?","state":"adopted","ts":"2026-09-13T15:31:23+00:00","author":"codex","session":"","branch":"feat/harness-update-refresh","scope":["docs/installation.md","docs/agent-setup.md","README.md"],"rationale":"The user asked for harness-based setup and one place an agent can follow from a pasted prompt, with explicit requirements. Source inspection shows that a temporary repository clone would leave the installation pointing into that clone; only the downloaded launcher should be temporary.","supports":[["d74","d38","d37"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Users may install unnecessary build tools, configure the wrong harness, or break a source installation by removing its temporary checkout.","pinned":false,"choice":"Lead the installation docs and README with a pasteable setup prompt that points agents to docs/agent-setup.md. Use one shared agent setup procedure for the terminal command, viewer, and current harness. Keep manual harness routes and a permanent source-clone option available, and state Python 3.11+, Git, and the source-build-only Go 1.26+ requirement by route.","alternatives":["Lead the installation docs and README with a pasteable setup prompt that points agents to docs/agent-setup.md. Use one shared agent setup procedure for the terminal command, viewer, and current harness. Keep manual harness routes and a permanent source-clone option available, and state Python 3.11+, Git, and the source-build-only Go 1.26+ requirement by route."],"decided_by":"novusedge"} -{"schema":2,"kind":"decision","id":"d77","text":"How do users trigger a Docket update?","state":"adopted","ts":"2026-09-13T15:32:58+00:00","author":"claude-code","session":"session_01Ba1BDHfYkW4jh4foQWhT1K","branch":"feat/harness-update-refresh","scope":["bin/docket","installer/**"],"rationale":"The notice must name one command the user can run, and the command differs per install shape. Mutating a running plugin mid-session is unsafe, so the plugin-only shape prints.","supports":[],"depends_on":[],"answers":[],"supersedes":["d40"],"evidence":[],"revisit":"","cost_if_wrong":"A subcommand that resolves the wrong shape can run a git update against a contributor's tree or fail without Go.","pinned":false,"choice":"Docket provides a docket update subcommand. It resolves the update path from the detected install shape. A managed checkout downloads the current launcher and runs it outside the checkout, because the bundled install.py takes the --checkout branch and skips the git update. A contributor source tree rebuilds in place. A plugin-only copy prints the harness command and changes nothing, because docket is not on that user's PATH.","alternatives":["Docket provides a docket update subcommand. It resolves the update path from the detected install shape. A managed checkout downloads the current launcher and runs it outside the checkout, because the bundled install.py takes the --checkout branch and skips the git update. A contributor source tree rebuilds in place. A plugin-only copy prints the harness command and changes nothing, because docket is not on that user's PATH.","Keep installer/install.py --update as the only entry point","Run the harness CLI on the user's behalf from a plugin-only shape"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d78","text":"How does a user learn that a Docket update is available?","state":"adopted","ts":"2026-09-13T15:33:20+00:00","author":"claude-code","session":"session_01Ba1BDHfYkW4jh4foQWhT1K","branch":"feat/harness-update-refresh","scope":["bin/docket","lib/**","hooks/**"],"rationale":"d40 prohibited a session-start network check and is retired by d77. The hook runs from the stale copy itself, so that copy's own VERSION reveals staleness and a separate drift check adds no information. An inline fetch would block a 5-second hook, and an inherited pipe would stall it and inject child output into the session context.","supports":[["d77"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A child that inherits stdout stalls every session to the hook timeout and corrupts the JSON envelope for gemini, copilot, and cursor.","pinned":false,"choice":"The session hook reads a cached upstream release tag and prints at most one notice line, naming the command for the running copy's install shape. It never performs a network request inline. When the cache is due it forks a detached child with no inherited pipes and prints the value it already holds. A single next_check_at field carries the 24-hour TTL, the failure backoff, and a 5-minute concurrency lease. DOCKET_NO_UPDATE_CHECK=1 disables the fetch and the notice.","alternatives":["The session hook reads a cached upstream release tag and prints at most one notice line, naming the command for the running copy's install shape. It never performs a network request inline. When the cache is due it forks a detached child with no inherited pipes and prints the value it already holds. A single next_check_at field carries the 24-hour TTL, the failure backoff, and a 5-minute concurrency lease. DOCKET_NO_UPDATE_CHECK=1 disables the fetch and the notice.","No notification; users run the update command when they choose to","Synchronous version check at session start","Compare the running copy against an installer-recorded version instead of upstream"],"decided_by":"user"} -{"schema":2,"kind":"decision","id":"d79","text":"How should Docket's Python modules be organised?","state":"adopted","ts":"2026-09-13T16:24:24+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/harness-update-refresh","scope":["lib/**","bin/docket"],"rationale":"Four sites pay for the dual import today: lib/docket_context.py:21-24, lib/docket_rebase.py:22-25, and lib/docket_migrate.py:444-455 and 529-536. A comment at lib/docket_migrate.py:531 states that no module-level import satisfies both forms. Relative imports inside a package resolve the same way however the package was found. Design reviewed over three adversarial passes; see docs/superpowers/specs/2026-09-13-docket-package-design.md.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Renaming lib/ invalidates scope paths on the project's own ledger records, so a briefing loses scope-based selection until scripts/rescope_ledger.py runs. Rewriting scopes also shifts inverse document frequency for every record, because _haystack folds scope into searchable text.","pinned":false,"choice":"Convert lib/ into a contained docket package holding the five existing modules plus the CLI. bin/docket becomes a ten-line launcher that inserts the repo root on sys.path and calls docket.cli.main(). Imports inside the package are relative, which removes the four dual-import hack sites. The CLI splits into cli/{__init__,term,record,query,graph,admin}.py, with env.py for environment and ledger-location resolution.","alternatives":["Convert lib/ into a contained docket package holding the five existing modules plus the CLI. bin/docket becomes a ten-line launcher that inserts the repo root on sys.path and calls docket.cli.main(). Imports inside the package are relative, which removes the four dual-import hack sites. The CLI splits into cli/{__init__,term,record,query,graph,admin}.py, with env.py for environment and ledger-location resolution.","Leave the flat lib/ modules and keep the try/except ImportError and runtime importlib hacks","Package the tool with zipapp into a single docket.pyz"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d77","text":"How do users trigger a Docket update?","state":"adopted","ts":"2026-09-13T15:32:58+00:00","author":"claude-code","session":"session_01Ba1BDHfYkW4jh4foQWhT1K","branch":"feat/harness-update-refresh","scope":["docket/cli/admin.py","installer/**"],"rationale":"The notice must name one command the user can run, and the command differs per install shape. Mutating a running plugin mid-session is unsafe, so the plugin-only shape prints.","supports":[],"depends_on":[],"answers":[],"supersedes":["d40"],"evidence":[],"revisit":"","cost_if_wrong":"A subcommand that resolves the wrong shape can run a git update against a contributor's tree or fail without Go.","pinned":false,"choice":"Docket provides a docket update subcommand. It resolves the update path from the detected install shape. A managed checkout downloads the current launcher and runs it outside the checkout, because the bundled install.py takes the --checkout branch and skips the git update. A contributor source tree rebuilds in place. A plugin-only copy prints the harness command and changes nothing, because docket is not on that user's PATH.","alternatives":["Docket provides a docket update subcommand. It resolves the update path from the detected install shape. A managed checkout downloads the current launcher and runs it outside the checkout, because the bundled install.py takes the --checkout branch and skips the git update. A contributor source tree rebuilds in place. A plugin-only copy prints the harness command and changes nothing, because docket is not on that user's PATH.","Keep installer/install.py --update as the only entry point","Run the harness CLI on the user's behalf from a plugin-only shape"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d78","text":"How does a user learn that a Docket update is available?","state":"adopted","ts":"2026-09-13T15:33:20+00:00","author":"claude-code","session":"session_01Ba1BDHfYkW4jh4foQWhT1K","branch":"feat/harness-update-refresh","scope":["docket/cli/query.py","docket/**","hooks/**"],"rationale":"d40 prohibited a session-start network check and is retired by d77. The hook runs from the stale copy itself, so that copy's own VERSION reveals staleness and a separate drift check adds no information. An inline fetch would block a 5-second hook, and an inherited pipe would stall it and inject child output into the session context.","supports":[["d77"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A child that inherits stdout stalls every session to the hook timeout and corrupts the JSON envelope for gemini, copilot, and cursor.","pinned":false,"choice":"The session hook reads a cached upstream release tag and prints at most one notice line, naming the command for the running copy's install shape. It never performs a network request inline. When the cache is due it forks a detached child with no inherited pipes and prints the value it already holds. A single next_check_at field carries the 24-hour TTL, the failure backoff, and a 5-minute concurrency lease. DOCKET_NO_UPDATE_CHECK=1 disables the fetch and the notice.","alternatives":["The session hook reads a cached upstream release tag and prints at most one notice line, naming the command for the running copy's install shape. It never performs a network request inline. When the cache is due it forks a detached child with no inherited pipes and prints the value it already holds. A single next_check_at field carries the 24-hour TTL, the failure backoff, and a 5-minute concurrency lease. DOCKET_NO_UPDATE_CHECK=1 disables the fetch and the notice.","No notification; users run the update command when they choose to","Synchronous version check at session start","Compare the running copy against an installer-recorded version instead of upstream"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d79","text":"How should Docket's Python modules be organised?","state":"adopted","ts":"2026-09-13T16:24:24+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/harness-update-refresh","scope":["docket/**","bin/docket"],"rationale":"Four sites pay for the dual import today: lib/docket_context.py:21-24, lib/docket_rebase.py:22-25, and lib/docket_migrate.py:444-455 and 529-536. A comment at lib/docket_migrate.py:531 states that no module-level import satisfies both forms. Relative imports inside a package resolve the same way however the package was found. Design reviewed over three adversarial passes; see docs/superpowers/specs/2026-09-13-docket-package-design.md.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Renaming lib/ invalidates scope paths on the project's own ledger records, so a briefing loses scope-based selection until scripts/rescope_ledger.py runs. Rewriting scopes also shifts inverse document frequency for every record, because _haystack folds scope into searchable text.","pinned":false,"choice":"Convert lib/ into a contained docket package holding the five existing modules plus the CLI. bin/docket becomes a ten-line launcher that inserts the repo root on sys.path and calls docket.cli.main(). Imports inside the package are relative, which removes the four dual-import hack sites. The CLI splits into cli/{__init__,term,record,query,graph,admin}.py, with env.py for environment and ledger-location resolution.","alternatives":["Convert lib/ into a contained docket package holding the five existing modules plus the CLI. bin/docket becomes a ten-line launcher that inserts the repo root on sys.path and calls docket.cli.main(). Imports inside the package are relative, which removes the four dual-import hack sites. The CLI splits into cli/{__init__,term,record,query,graph,admin}.py, with env.py for environment and ledger-location resolution.","Leave the flat lib/ modules and keep the try/except ImportError and runtime importlib hacks","Package the tool with zipapp into a single docket.pyz"],"decided_by":"user"} {"schema":2,"kind":"decision","id":"d80","text":"Should a Go launcher binary replace the bin/docket shebang?","state":"adopted","ts":"2026-09-13T16:24:31+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/harness-update-refresh","scope":["installer/**","bin/docket"],"rationale":"A launcher cannot remove the Python dependency, because d38 keeps the ledger CLI in Python. It would add six release builds to match the viewer matrix and duplicate what the cmd shim already does.","supports":[["d38"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"env.Python is captured at install time, so a user who removes that interpreter breaks the shim until reinstall. A launcher resolving Python at each run would fix that specific case; it stays open as a separate question.","pinned":false,"choice":"No. Keep bin/docket as a Python script with a shebang. The installer already writes a docket.cmd shim on Windows naming the interpreter absolutely (installer/planner.go:144) and symlinks the script on Unix (installer/planner.go:150-160), so the shebang is never consulted where it would fail.","alternatives":["No. Keep bin/docket as a Python script with a shebang. The installer already writes a docket.cmd shim on Windows naming the interpreter absolutely (installer/planner.go:144) and symlinks the script on Unix (installer/planner.go:150-160), so the shebang is never consulted where it would fail.","Ship a per-platform Go launcher binary that locates Python and execs bin/docket"],"decided_by":"user"} {"schema":2,"kind":"decision","id":"d81","text":"How should the installer place the OpenCode plugin so automatic discovery finds it?","state":"adopted","ts":"2026-09-13T19:17:56+00:00","author":"claude-code","session":"session_01Ba1BDHfYkW4jh4foQWhT1K","branch":"feat/harness-update-refresh","scope":["installer/planner.go","docs/integrations.md"],"rationale":"The installed opencode binary globs {plugin,plugins}/*.{ts,js} with include:\"file\". One star and a file-only filter mean a nested docket/index.ts never loads, so every OpenCode install before this shipped a plugin OpenCode ignored.","supports":[],"depends_on":[],"answers":["q75"],"supersedes":[],"evidence":[{"ref":"grep of ~/.opencode/bin/opencode: glob(\"{plugin,plugins}/*.{ts,js}\", {include:\"file\", symlink:true})"}],"revisit":"","cost_if_wrong":"Placing the file one level down leaves OpenCode with no Docket briefing and no error.","pinned":false,"choice":"Write the plugin as a single file at ~/.config/opencode/plugins/docket.ts. Remove the pre-0.11.0 plugins/docket/index.ts on install, update, and uninstall.","alternatives":["Write the plugin as a single file at ~/.config/opencode/plugins/docket.ts. Remove the pre-0.11.0 plugins/docket/index.ts on install, update, and uninstall.","Keep plugins/docket/index.ts and document manual placement"],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d82","text":"How should Docket's Python modules be organised?","state":"adopted","ts":"2026-09-13T19:51:29+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/docket-package","scope":["docket/**","bin/docket"],"rationale":"The user requires absolute imports throughout. One module carries one canonical name, which resolves identically from every entry point once the repo root is on sys.path. d79 said relative. The module count is six, not five: docket_update.py shipped in 0.11.0 after the spec was written.","supports":[],"depends_on":[],"answers":[],"supersedes":["d79"],"evidence":[],"revisit":"","cost_if_wrong":"Absolute imports depend on the repo root being importable. No conftest.py exists, so the test suite resolves the package only because unittest discover runs with the repo root as cwd.","pinned":false,"choice":"Convert lib/ into a contained docket package holding the six existing modules plus the CLI. bin/docket becomes a launcher that inserts the repo root on sys.path and calls docket.cli.main(). Every import is absolute, inside the package included: from docket.config import DEFAULTS, never from .config import DEFAULTS. The CLI splits into cli/{__init__,term,record,query,graph,admin}.py, with env.py for environment and ledger-location resolution.","alternatives":["Convert lib/ into a contained docket package holding the six existing modules plus the CLI. bin/docket becomes a launcher that inserts the repo root on sys.path and calls docket.cli.main(). Every import is absolute, inside the package included: from docket.config import DEFAULTS, never from .config import DEFAULTS. The CLI splits into cli/{__init__,term,record,query,graph,admin}.py, with env.py for environment and ledger-location resolution.","Relative imports inside the package, as d79 recorded","Leave the flat lib/ modules and keep the try/except ImportError and runtime importlib hacks","Package the tool with zipapp into a single docket.pyz"],"decided_by":"user"} +{"schema":2,"kind":"claim","id":"c83","text":"OpenRouter enforces a JSON schema per endpoint, not per model, and some upstream providers silently fall back to json_object.","state":"accepted","ts":"2026-09-13T20:56:40+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/docket-package","scope":["docket/construct/**"],"rationale":"The same model reached through different upstream providers may or may not honour json_schema. OpenRouter's own guidance flags the silent fallback as a bug class. provider.require_parameters=true routes only to providers that support it.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://openrouter.ai/docs/guides/features/structured-outputs"}],"revisit":"","cost_if_wrong":"Trusting response_format without local validation would let a silently unstructured response through as a record.","pinned":false} +{"schema":2,"kind":"decision","id":"d84","text":"Which LLM client does docket construct depend on?","state":"adopted","ts":"2026-09-13T20:56:51+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/docket-package","scope":["docket/construct/**"],"rationale":"litellm shipped a credential stealer on PyPI on 2026-03-24 as releases 1.82.7 and 1.82.8, delivered through a .pth file that executes at interpreter startup. A SessionStart hook runs docket every session, so that payload shape is the worst possible fit. litellm also pulls about 28MB including boto3, tiktoken and uvloop. OpenRouter collapses four providers into one endpoint and one auth scheme, which is what the openai SDK already serves; instructor, pydantic-ai and mirascope add agent-orchestration weight for a job with two flat call shapes. The openai SDK publishes through PyPI trusted publishing with Sigstore attestations and has no incident history.","supports":[["c83"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"The openai SDK requires pydantic, so construct cannot stay standard-library-only. A hand-rolled urllib client would avoid that at the price of owning retry, refusal and error-shape handling.","pinned":false,"choice":"The official openai SDK, pointed at OpenRouter's OpenAI-compatible endpoint, imported lazily inside the construct command body. Set provider.require_parameters=true so routing reaches only providers that honour json_schema, and validate every response locally regardless. Users with their own credentials get separate lightweight code paths per provider SDK, each lazily imported when selected.","alternatives":["The official openai SDK, pointed at OpenRouter's OpenAI-compatible endpoint, imported lazily inside the construct command body. Set provider.require_parameters=true so routing reaches only providers that honour json_schema, and validate every response locally regardless. Users with their own credentials get separate lightweight code paths per provider SDK, each lazily imported when selected.","litellm, a universal multi-provider gateway","instructor, pydantic-ai or mirascope layered over a client","A hand-rolled OpenAI-compatible client over urllib, about 50 lines"],"decided_by":"user"} diff --git a/bin/docket b/bin/docket index 1b66883..a4d21ac 100755 --- a/bin/docket +++ b/bin/docket @@ -2,1540 +2,16 @@ """Append-only decision ledger. Standard library only, because a SessionStart hook runs this on every session and cannot wait on a dependency install.""" -from __future__ import annotations - -import argparse -import json -import os -import shutil -import subprocess import sys -import tempfile -import textwrap -import time from pathlib import Path -_LIB_DIR = Path(__file__).resolve().parent.parent / "lib" -if str(_LIB_DIR) not in sys.path: - sys.path.insert(0, str(_LIB_DIR)) - -from docket_ledger import ( # noqa: E402 - ID_RE, - KINDS, - STATES, - LedgerError, - append, - graph_payload, - make_record, - project, - read as ledger_read, - retired_by, - validate_record, - _Prefix, -) - -LEDGER = Path(".docket/ledger.jsonl") - -# Honours XDG so the global store can be relocated, and CLAUDE_CONFIG_DIR so an -# isolated Claude profile gets its own ledgers. -def global_root() -> Path: - if env := os.environ.get("DOCKET_HOME"): - return Path(env).expanduser() - if env := os.environ.get("CLAUDE_CONFIG_DIR"): - return Path(env).expanduser() / "docket" - return Path.home() / ".claude" / "docket" - - -def version() -> str: - """The release, from the VERSION file beside the checkout. - - Read only when a version or top-level help request asks for it. The - SessionStart hook runs `context` on every session and must not pay for a - file read it never uses. - """ - try: - return (Path(__file__).resolve().parent.parent / "VERSION").read_text().strip() - except OSError: - return "unknown" - - -class VersionAction(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): - print(f"docket {version()}") - parser.exit() - - -class HelpAction(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): - print(f"docket {version()}") - print(parser.format_help(), end="") - parser.exit() - - -def session_id() -> str: - """Claude Code exposes this under more than one name across versions.""" - for var in ("CLAUDE_SESSION_ID", "CLAUDE_CODE_BRIDGE_SESSION_ID", "SESSION_ID"): - if v := os.environ.get(var): - return v - return "" - - -def author() -> str: - """Which agent or person recorded the entry. - - Two agents sharing one ledger is the normal case, so an entry that does not - say who wrote it cannot be weighed. - """ - if v := os.environ.get("DOCKET_AUTHOR"): - return v - if v := os.environ.get("AI_AGENT"): - return v.split("_")[0] - if os.environ.get("CODEX_SANDBOX") or os.environ.get("CODEX_HOME"): - return "codex" - return os.environ.get("USER", "") - - -def resolved_author() -> str: - """author(), falling back to a visible "unknown" instead of silence. - - A blank string is indistinguishable from an unset field once serialized, so - a caller reading the ledger cannot tell "detection failed" from "this entry - predates the author field". Writing "unknown" makes the failure visible; - the missing key on old entries stays the only true absence. - """ - a = author() - if a: - return a - print( - "docket: could not detect an author; recording \"unknown\". " - "Set DOCKET_AUTHOR to identify this harness.", - file=sys.stderr, - ) - return "unknown" - - -def branch(root: Path) -> str: - """Current branch, or empty outside a repository. - - Uses `branch --show-current` because `rev-parse --abbrev-ref HEAD` fails on - an unborn branch, which is every repository before its first commit. - """ - try: - r = subprocess.run( - ["git", "-C", str(root), "branch", "--show-current"], - capture_output=True, text=True, timeout=2, - ) - return r.stdout.strip() if r.returncode == 0 else "" - except (OSError, subprocess.SubprocessError): - return "" - - -def project_root(start: Path | None = None) -> Path: - """The directory a project-local ledger belongs to. - - Prefers the git root so a subdirectory shares the project's ledger. Falls - back to the working directory outside a repository. - """ - here = (start or Path.cwd()).resolve() - for d in (here, *here.parents): - if (d / ".git").exists(): - return d - return here - - -def slug(path: Path) -> str: - """Mangle an absolute path into one filename component.""" - return str(path).replace("/", "-").replace("\\", "-").strip("-") or "root" - - -def ledger_path(start: Path | None = None) -> Path: - """Resolve the ledger for the current project. - - A .docket directory in the project wins, which is how a team opts into - committing decisions. Otherwise the ledger lives under the global root, - keyed by project path, so a new project needs no setup and no gitignore - entry. - """ - here = (start or Path.cwd()).resolve() - for d in (here, *here.parents): - candidate = d / LEDGER - if candidate.exists(): - return candidate - return global_root() / slug(project_root(here)) / "ledger.jsonl" - - -def read(path: Path, lock: bool = True) -> list[dict]: - return ledger_read(path, lock=lock) - - -def next_id(entries: list[dict]) -> str: - from docket_ledger import next_id as ledger_next_id - return ledger_next_id(entries) - - -def justification_sets(e: dict) -> list[list[str]]: - return e.get("supports", []) - - -def retired_by(entries: list[dict]) -> dict[str, str]: - """Map each superseded ID to its replacement.""" - return {target: e["id"] for e in entries for target in e.get("supersedes", [])} - - -def _match(e: dict, term: str) -> bool: - term = term.lower() - return term in e.get("text", "").lower() or term in e.get("choice", "").lower() - - -_LIST_ID_W, _LIST_STATE_W = 5, 9 -_LIST_HEAD_W = _LIST_ID_W + 1 + _LIST_STATE_W + 1 # clears the id+state columns - - -def _list_dim_tail(line: str, marker: str, use_color: bool) -> str: - """Dim a line from `marker` onward, if the line carries it at all. - - Wrapping can split the marker onto a line of its own or leave it whole; - either way the dim colour must start where the marker starts, not at - column 0. - """ - idx = line.find(marker) - if idx == -1 or not use_color: - return line - return line[:idx] + _c(_DIM, line[idx:], use_color) - - -def _shared_fields(args: argparse.Namespace) -> dict: - from docket_ledger import parse_evidence - return { - "scope": args.scope or [], - "rationale": args.rationale or "", - "supports": [[ref.strip() for ref in group.split(",") if ref.strip()] for group in (args.supports or [])], - "depends_on": [ref.strip() for ref in (args.depends_on or "").split(",") if ref.strip()], - "answers": [ref.strip() for ref in (args.answers or "").split(",") if ref.strip()], - "supersedes": [ref.strip() for ref in (args.supersedes or "").split(",") if ref.strip()], - "evidence": [parse_evidence(item) for item in (args.evidence or [])], - "revisit": args.revisit or "", - "cost_if_wrong": args.cost or "", - "pinned": bool(args.pin), - "author": resolved_author(), - "session": session_id(), - "branch": branch(project_root()), - } - - -def _append_cli(kind: str, args: argparse.Namespace) -> int: - try: - fields = _shared_fields(args) - if kind == "decision": - fields.update({"state": args.state, "choice": args.choice, "alternatives": args.alternative or [], "decided_by": args.decided_by}) - elif kind == "claim": - fields["state"] = args.state - entry = make_record(kind, args.text, **fields) - entry["id"] = "" - entry = append(ledger_path(), entry) - except (LedgerError, OSError, json.JSONDecodeError) as exc: - print(str(exc), file=sys.stderr) - return 1 - print(f"{entry['id']} {entry['state']} {entry['text']}") - return 0 - - -def cmd_claim(args: argparse.Namespace) -> int: - return _append_cli("claim", args) - - -def cmd_decision(args: argparse.Namespace) -> int: - return _append_cli("decision", args) - - -def cmd_question(args: argparse.Namespace) -> int: - return _append_cli("question", args) - - -def cmd_list(args: argparse.Namespace) -> int: - entries = project(read(ledger_path()), validated=True) - retired = retired_by(entries) - if not args.superseded: - entries = [e for e in entries if e.get("id") not in retired] - if args.state: - entries = [e for e in entries if e.get("state") == args.state] - if getattr(args, "kind", None): - entries = [e for e in entries if e.get("kind") == args.kind] - if args.find: - entries = [e for e in entries if _match(e, args.find)] - if not entries: - if getattr(args, "json", False): - print("[]") - return 0 - print("docket: nothing recorded") - return 0 - if getattr(args, "json", False): - print(json.dumps(entries, ensure_ascii=False, indent=2)) - return 0 - - use_color = False if args.plain else True if args.pretty else _use_color() - width = shutil.get_terminal_size().columns - - if args.oneline: - for e in entries: - id_str = _c(_STATE_COLOR.get(e["state"], _DIM), f"{e['id']:<{_LIST_ID_W}}", use_color) - # Truncated, never wrapped: one entry stays one line, which is what - # makes the mode scannable and pipeable into grep. - room = max(width - _LIST_ID_W - _LIST_STATE_W - 2, 20) - question = textwrap.shorten(e["text"], width=room, placeholder="...") - print(f"{id_str} {e['state']:<{_LIST_STATE_W}} {question}") - return 0 - - for e in entries: - sets = justification_sets(e) - # " | " separates alternatives; each alternative's own ids stay comma-joined. - dep = f" <- {' | '.join(','.join(s) for s in sets)}" if sets else "" - gone = f" (superseded by {retired[e['id']]})" if e.get("id") in retired else "" - - id_str = _c(_STATE_COLOR.get(e["state"], _DIM), f"{e['id']:<{_LIST_ID_W}}", use_color) - state_str = _c(_STATE_COLOR.get(e["state"], _DIM), f"{e['state']:<{_LIST_STATE_W}}", use_color) - - avail = max(width - _LIST_HEAD_W, 20) - wrapped = textwrap.wrap(e["text"] + dep + gone, width=avail) or [""] - print(f"{id_str} {state_str} " + _list_dim_tail(_list_dim_tail(wrapped[0], "<-", use_color), "(superseded by", use_color)) - for line in wrapped[1:]: - line = _list_dim_tail(_list_dim_tail(line, "<-", use_color), "(superseded by", use_color) - print(" " * _LIST_HEAD_W + line) - - detail = e.get("choice", e.get("rationale", "")) - if detail: - for line in textwrap.wrap(detail, width=max(width - 6, 20)) or [""]: - print(" " + line) - return 0 - - -# Duplicated from install.py:30-61 rather than imported: bin/docket ships and -# runs standalone, so it cannot assume install.py sits beside it. -# -# An agent env var (the same names author() checks) forces colour off even on -# a real tty. Cursor, Copilot in VS Code, and Windsurf run agent shell -# commands over node-pty, so isatty() alone reads as "human" there. Wrongly -# guessing pretty puts escape codes in a model's context window; wrongly -# guessing plain costs a person one flag, so the asymmetry decides it. -_AGENT_ENV_VARS = ( - "CLAUDE_SESSION_ID", "CLAUDE_CODE_BRIDGE_SESSION_ID", "SESSION_ID", - "AI_AGENT", "CODEX_SANDBOX", "CODEX_HOME", -) - - -def _use_color() -> bool: - if os.environ.get("NO_COLOR"): - return False - if not sys.stdout.isatty(): - return False - if os.name == "nt" and not os.environ.get("WT_SESSION"): - return False - if any(os.environ.get(v) for v in _AGENT_ENV_VARS): - return False - return True - - -def _use_glyphs() -> bool: - enc = getattr(sys.stdout, "encoding", None) or "" - try: - "●".encode(enc) - return True - except (LookupError, UnicodeEncodeError): - return False - - -_STATE_COLOR = { - "accepted": "2", "adopted": "2", "resolved": "2", - "rejected": "1", "revoked": "1", "disputed": "1", - "unassessed": "3", "open": "3", -} # green, red, yellow -_DIM = "8" - -_GRAPH_GLYPHS = { - "bullet": "●", "open": "○", "retired": "⊘", "vert": "│", "tee": "├─ ", "elbow": "└─ ", - "hbar": "─", "ltee": "├", "join": "┴", "cross": "┼", "corner": "╯", -} -_GRAPH_GLYPHS_ASCII = { - "bullet": "*", "open": "o", "retired": "x", "vert": "|", "tee": "+- ", "elbow": "`- ", - "hbar": "-", "ltee": "+", "join": "+", "cross": "+", "corner": "'", -} - - -def _c(code: str, text: str, use_color: bool) -> str: - return "\033[3%sm%s\033[0m" % (code, text) if use_color else text - - -def _node_info(e: dict, retired: dict[str, str]) -> dict: - """Precompute what every graph renderer needs from one entry. - - supports is the union of ids across every alternative set, in first-seen - order, so the forest can hang a node under its first support and name the - rest as extras without walking justification_sets() again per renderer. - """ - sets = justification_sets(e) - supports: list[str] = [] - for s in sets: - for i in s: - if i not in supports: - supports.append(i) - return { - "id": e["id"], - "kind": e.get("kind", ""), - "state": e.get("state", ""), - "recorded_state": e.get("recorded_state", e.get("state", "")), - "question": e.get("text", ""), - "answer": e.get("choice", e.get("rationale", "")), - "cost": e.get("cost_if_wrong", ""), - "sets": sets, - "supports": supports, - "retired_by": retired.get(e["id"], ""), - "supersedes": e.get("supersedes", []), - "depends_on": e.get("depends_on", []), - "answers": e.get("answers", []), - "resolved_by": e.get("resolved_by", []), - "scope": e.get("scope", []), - "rationale": e.get("rationale", ""), - "alternatives": e.get("alternatives", []), - "evidence": e.get("evidence", []), - "revisit": e.get("revisit", ""), - "author": e.get("author", ""), - "ts": e.get("ts", ""), - "branch": e.get("branch", ""), - "session": e.get("session", ""), - "pinned": e.get("pinned", False), - "applicable": e.get("applicable"), - "blocked_by": e.get("blocked_by", []), - "decided_by": e.get("decided_by", ""), - } - - -def _formula(sets: list[list[str]]) -> str: - return " | ".join(",".join(s) for s in sets) - - -def _blocked_text(info: dict) -> str: - if info.get("kind") == "decision" and info.get("applicable") is False: - return "! blocked by " + (", ".join(info.get("blocked_by", [])) or "prerequisites") - return "" - - -def _graph_entries(args: argparse.Namespace) -> tuple[list[dict], dict[str, str]]: - """Entries for `graph`, including retired ones. - - Unlike cmd_list, retired entries stay in, or the edges a later entry drew - to them would dangle. - """ - entries = project(read(ledger_path()), validated=True) - retired = retired_by(entries) - if args.state: - entries = [e for e in entries if e.get("state") == args.state] - if args.find: - entries = [e for e in entries if _match(e, args.find)] - if getattr(args, "kind", None): - entries = [e for e in entries if e.get("kind") == args.kind] - return entries, retired - - -def _graph_viewer_path() -> Path: - """The viewer built by the installer in this checkout.""" - name = "docket-graph.exe" if os.name == "nt" else "docket-graph" - return Path(__file__).resolve().parent.parent / "graph" / name - - -def _graph_is_tty() -> bool: - """Whether graph owns both terminal handles needed by Bubble Tea.""" - return all( - bool(getattr(stream, "isatty", lambda: False)()) - for stream in (sys.stdin, sys.stdout) - ) - - -def _graph_viewer_error(viewer: Path) -> None: - print( - f"docket: interactive viewer not found at {viewer}; " - "build it with `cd graph && go build -o docket-graph .`", - file=sys.stderr, - ) - - -def _graph_payload(entries: list[dict], retired: dict[str, str]) -> dict: - """Serialize only the normalized graph model consumed by the Go viewer.""" - return graph_payload(entries) - - -def _run_graph_viewer(entries: list[dict], retired: dict[str, str], pretty: bool = False) -> int: - """Run the native viewer with an inherited terminal and private input.""" - viewer = _graph_viewer_path() - temp_path: Path | None = None - try: - fd, raw_path = tempfile.mkstemp(prefix="docket-graph-", suffix=".json") - temp_path = Path(raw_path) - with os.fdopen(fd, "w", encoding="utf-8") as data_file: - json.dump(_graph_payload(entries, retired), data_file) - data_file.write("\n") - - try: - command = [str(viewer), "--data", str(temp_path)] - if pretty: - command.append("--pretty") - result = subprocess.run(command) - except KeyboardInterrupt: - return 130 - except OSError as exc: - print(f"docket: could not start interactive viewer: {exc}", file=sys.stderr) - return 1 - return result.returncode - finally: - if temp_path is not None: - try: - temp_path.unlink() - except OSError: - pass - - -def _id_num(eid: str) -> int: - try: - return int(eid[1:]) - except (ValueError, IndexError): - return 0 - - -def _state_glyph(state: str, glyphs: dict, use_color: bool) -> str: - g = glyphs["open"] if state == "open" else glyphs["bullet"] - return _c(_STATE_COLOR.get(state, _DIM), g, use_color) - - -def _label(info: dict, glyphs: dict, use_color: bool) -> tuple[str, int]: - """The `glyph id state ` prefix shared by forest and compact rows, plus - its printable width (ANSI codes have zero display width, so the width is - computed from an uncoloured copy rather than len() on the coloured one).""" - rest = f"{info['id']:<4}{info['state']:<11}" - plain_glyph = glyphs["open"] if info["state"] == "open" else glyphs["bullet"] - plain = f"{plain_glyph} {rest}" - colored = f"{_state_glyph(info['state'], glyphs, use_color)} {rest}" - return colored, len(plain) - - -def _id_state(info: dict) -> str: - """id + state with no glyph, for the rail: the glyph already sits in the - lane at the node's own column, so repeating it in the label would double it.""" - return f"{info['id']:<4}{info['state']:<11}" - - -def _forest_roots(entries: list[dict], nodes: dict[str, dict]) -> list[str]: - """Entries nothing supports. A support the graph filtered out (--state, - --find) also makes its dependent a root: there is nothing to hang it under.""" - visible = set(nodes) - roots = [ - e["id"] for e in entries - if not nodes[e["id"]]["supports"] or not (set(nodes[e["id"]]["supports"]) & visible) - ] - return sorted(roots, key=_id_num, reverse=True) - - -def _forest_children(entries: list[dict], nodes: dict[str, dict], roots: list[str]) -> dict[str, list[str]]: - """Map each support id to the children hung under it (its primary support - only; extras are named in text, never drawn, so each node has one parent).""" - children: dict[str, list[str]] = {} - roots_set = set(roots) - for e in sorted(entries, key=lambda e: _id_num(e["id"])): - eid = e["id"] - if eid in roots_set: - continue - supports = [s for s in nodes[eid]["supports"] if s in nodes] - if not supports: - continue - children.setdefault(supports[0], []).append(eid) - return children - - -def _forest_lines(nodes: dict[str, dict], roots: list[str], children: dict[str, list[str]], - glyphs: dict, use_color: bool, width: int) -> list[str]: - lines: list[str] = [] - - def walk(eid: str, ancestor_last: list[bool]) -> None: - info = nodes[eid] - prefix = "".join((" " if last else glyphs["vert"] + " ") for last in ancestor_last[:-1]) - if ancestor_last: - prefix += glyphs["elbow"] if ancestor_last[-1] else glyphs["tee"] - label, label_width = _label(info, glyphs, use_color) - - extras = info["supports"][1:] - text = info["question"] - if extras: - text += " (also <- %s)" % ", ".join(extras) - if len(info["sets"]) > 1: - text += "\n" + _formula(info["sets"]) - if info["retired_by"]: - text += "\n" + _c(_DIM, f"{glyphs['retired']} retired by {info['retired_by']}", use_color) - # The answer is what separates forest from compact; compact is the - # one-line view. - text += "\n" + info["answer"] - if info["cost"]: - text += "\n" + "! cost: " + info["cost"] - if _blocked_text(info): - text += "\n" + _blocked_text(info) - - cont_prefix = "".join((" " if last else glyphs["vert"] + " ") for last in ancestor_last) - avail = max(width - len(prefix) - label_width, 20) - wrapped_lines: list[str] = [] - for para in text.split("\n"): - wrapped_lines.extend(textwrap.wrap(para, width=avail) or [""]) - - lines.append(prefix + label + " " + wrapped_lines[0]) - for extra_line in wrapped_lines[1:]: - lines.append(cont_prefix + " " * label_width + " " + extra_line) - - kids = sorted(children.get(eid, []), key=_id_num) - for i, kid in enumerate(kids): - walk(kid, ancestor_last + [i == len(kids) - 1]) - - # A root has no parent, so it carries no connector. Passing a depth here - # drew every root as a child of something invisible. - for r in roots: - walk(r, []) - return lines - - -def _compact_lines(nodes: dict[str, dict], roots: list[str], children: dict[str, list[str]], - glyphs: dict, use_color: bool) -> list[str]: - lines: list[str] = [] - - def walk(eid: str, ancestor_last: list[bool]) -> None: - info = nodes[eid] - prefix = "".join((" " if last else glyphs["vert"] + " ") for last in ancestor_last[:-1]) - if ancestor_last: - prefix += glyphs["elbow"] if ancestor_last[-1] else glyphs["tee"] - label, _ = _label(info, glyphs, use_color) - retired = f" {_c(_DIM, glyphs['retired'] + ' retired by ' + info['retired_by'], use_color)}" if info["retired_by"] else "" - blocked = f" {_blocked_text(info)}" if _blocked_text(info) else "" - lines.append(f"{prefix}{label} {info['question']}{retired}{blocked}") - kids = sorted(children.get(eid, []), key=_id_num) - for i, kid in enumerate(kids): - walk(kid, ancestor_last + [i == len(kids) - 1]) - - for r in roots: - walk(r, []) - return lines - - -def _find_or_alloc(columns: list[str | None], label: str) -> int: - """A column labelled `label`, reusing the leftmost freed one if none - exists. The ledger's ids are monotonic and append-only, so processing - newest-first means every column we need has either already been opened by - a dependent, or never existed; there is no need for git's full graph - algorithm to find it.""" - if label in columns: - return columns.index(label) - for i, c in enumerate(columns): - if c is None: - columns[i] = label - return i - columns.append(label) - return len(columns) - 1 - - -def _rail_lines(entries_desc: list[dict], nodes: dict[str, dict], glyphs: dict, use_color: bool, - width: int) -> list[str]: - columns: list[str | None] = [] - rows: list[tuple[int, list[str | None], dict]] = [] - - for e in entries_desc: - eid = e["id"] - c = _find_or_alloc(columns, eid) - # Every other column waiting on this id merges into c. The join has to - # be drawn before the node row, or the lanes vanish and the picture - # claims those dependents led nowhere. - dupes = [i for i in range(len(columns)) if i != c and columns[i] == eid] - if dupes: - rows.append((c, list(columns), None, dupes)) - for i in dupes: - columns[i] = None - snapshot = list(columns) - - info = nodes[eid] - supports = [s for s in info["supports"] if s in nodes] - if not supports: - columns[c] = None # a root drops its column - else: - columns[c] = supports[0] - for extra in supports[1:]: - _find_or_alloc(columns, extra) - # The node's column stays open only while it still awaits a support; - # continuation rows read this to know whether to draw a lane under it. - rows.append((c, snapshot, info, columns[c] is not None)) - - lines: list[str] = [] - for c, snapshot, info, state in rows: - if info is None: - lines.append(_rail_join(snapshot, c, state, glyphs, use_color)) - continue - - plain_cells, cells = [], [] - for i in range(len(snapshot)): - if i == c: - plain_cells.append(glyphs["open"] if info["state"] == "open" else glyphs["bullet"]) - cells.append(_state_glyph(info["state"], glyphs, use_color)) - elif snapshot[i] is not None: - plain_cells.append(glyphs["vert"]) - cells.append(_c(_DIM, glyphs["vert"], use_color)) - else: - plain_cells.append(" ") - cells.append(" ") - lane, plain_lane = " ".join(cells), " ".join(plain_cells) - - # A continuation row shows lanes only. Reusing plain_lane here drew the - # node's own glyph again on every wrapped line. - cont_cells = list(plain_cells) - cont_cells[c] = glyphs["vert"] if state else " " - label = _id_state(info) - avail = max(width - len(plain_lane) - 1 - len(label) - 1, 20) - text = info["question"] - extras = info["supports"][1:] - if extras: - text += " (also <- %s)" % ", ".join(extras) - if len(info["sets"]) > 1: - text += "\n" + _formula(info["sets"]) - if info["retired_by"]: - text += "\n" + f"{glyphs['retired']} retired by {info['retired_by']}" - if _blocked_text(info): - text += "\n" + _blocked_text(info) - wrapped: list[str] = [] - for para in text.split("\n"): - wrapped.extend(textwrap.wrap(para, width=avail) or [""]) - cont = " ".join(cont_cells) + " " * (len(label) + 2) - lines.append(f"{lane} {label} {wrapped[0]}") - for extra_line in wrapped[1:]: - lines.append(f"{cont}{extra_line}") - return lines - - -def _rail_join(columns: list[str | None], c: int, dupes: list[int], - glyphs: dict, use_color: bool) -> str: - """The row that merges every lane awaiting one id back into column c. - - c is always the leftmost such column, because _find_or_alloc returns the - first match, so the join only ever runs rightwards. - """ - last = max(dupes) - cells = [] - for i in range(len(columns)): - if i == c: - cells.append(glyphs["ltee"]) - elif i == last: - cells.append(glyphs["corner"]) - elif i in dupes: - cells.append(glyphs["join"]) - elif columns[i] is not None: - # An unrelated lane the join has to cross. - cells.append(glyphs["cross"] if c < i < last else glyphs["vert"]) - else: - cells.append(glyphs["hbar"] if c < i < last else " ") - row = "" - for i, cell in enumerate(cells): - row += cell - if i < len(cells) - 1: - row += glyphs["hbar"] if c <= i < last else " " - return _c(_DIM, row, use_color) - - -def _render_graph(entries: list[dict], retired: dict[str, str], args: argparse.Namespace, - style: str) -> int: - use_color = False if args.plain else True if args.pretty else _use_color() - glyphs = _GRAPH_GLYPHS if _use_glyphs() else _GRAPH_GLYPHS_ASCII - width = shutil.get_terminal_size().columns - nodes = {e["id"]: _node_info(e, retired) for e in entries} - entries_desc = sorted(entries, key=lambda e: _id_num(e["id"]), reverse=True) - - if style == "rail": - lines = _rail_lines(entries_desc, nodes, glyphs, use_color, width) - else: - roots = _forest_roots(entries, nodes) - children = _forest_children(entries, nodes, roots) - if style == "compact": - lines = _compact_lines(nodes, roots, children, glyphs, use_color) - else: - lines = _forest_lines(nodes, roots, children, glyphs, use_color, width) - - print("\n".join(lines)) - return 0 - - -def cmd_graph(args: argparse.Namespace) -> int: - style = getattr(args, "style", None) - interactive = bool(getattr(args, "interactive", False)) - no_interactive = bool(getattr(args, "no_interactive", False)) - plain = bool(getattr(args, "plain", False)) - - if interactive and no_interactive: - print("docket: --interactive conflicts with --no-interactive", file=sys.stderr) - return 2 - if interactive and plain: - print("docket: --interactive conflicts with --plain", file=sys.stderr) - return 2 - if interactive and style is not None: - print("docket: --interactive conflicts with --style", file=sys.stderr) - return 2 - if interactive and not _graph_is_tty(): - print("docket: --interactive requires terminal stdin and stdout", file=sys.stderr) - return 1 - - entries, retired = _graph_entries(args) - if not entries: - print("docket: nothing recorded") - return 0 - - viewer = _graph_viewer_path() - auto = _graph_is_tty() and not no_interactive and not plain and style is None - if interactive or auto: - if not viewer.is_file(): - _graph_viewer_error(viewer) - if interactive: - return 1 - return _render_graph(entries, retired, args, "compact") - return _run_graph_viewer(entries, retired, pretty=bool(getattr(args, "pretty", False))) - - return _render_graph(entries, retired, args, style or "forest") - - -def cmd_show(args: argparse.Namespace) -> int: - raw = read(ledger_path()) - if args.at: - if not any(item.get("id") == args.at for item in raw): - print(f"docket: unknown record {args.at}", file=sys.stderr) - return 1 - cutoff = int(args.at[1:]) - raw = [item for item in raw if int(item["id"][1:]) <= cutoff] - entries = project(raw, validated=True) - by_id = {e.get("id"): e for e in entries} - e = by_id.get(args.id) - if not e: - print(f"docket: no entry {args.id}", file=sys.stderr) - return 1 - - if args.json: - print(json.dumps(e, indent=2)) - return 0 - - def cite(i: str) -> str: - q = by_id.get(i, {}).get("text") - return f"{i} ({q})" if q else i - - width = shutil.get_terminal_size().columns - - def field(label: str, value: str) -> None: - """One labelled field, wrapped under a hanging indent past the label.""" - indent = " " * (len(label) + 4) - for i, line in enumerate(textwrap.wrap(f" {label}: {value}", width=width, - subsequent_indent=indent) or [f" {label}:"]): - print(line) - - for line in textwrap.wrap(f"{e['id']} {e.get('kind', '')} {e.get('state', '')} {e.get('text', '')}", - width=width, subsequent_indent=" " * 15): - print(line) - field("Choice", e.get("choice", "")) - field("Rationale", e.get("rationale", "")) - sets = justification_sets(e) - if sets: - field("Because", " | ".join(", ".join(cite(i) for i in s) for s in sets)) - supersedes = e.get("supersedes") or [] - if supersedes: - field("Supersedes", ", ".join(cite(i) for i in supersedes)) - retired = retired_by(entries) - if e["id"] in retired: - field("Superseded by", cite(retired[e["id"]])) - if e.get("depends_on"): - field("Depends on", ", ".join(cite(i) for i in e["depends_on"])) - if e.get("answers"): - field("Answers", ", ".join(cite(i) for i in e["answers"])) - if e.get("resolved_by"): - field("Resolved by", ", ".join(cite(i) for i in e["resolved_by"])) - if e.get("decided_by"): - field("Decided by", e["decided_by"]) - if e.get("cost_if_wrong"): - field("Cost if wrong", e["cost_if_wrong"]) - field("Recorded state", e.get("recorded_state", e.get("state", ""))) - print(f" Author: {e.get('author', '')} Session: {e.get('session', '')} Branch: {e.get('branch', '')}") - return 0 - - -def cmd_where(args: argparse.Namespace) -> int: - path = ledger_path() - kind = "project" if LEDGER.name in str(path) and ".claude" not in str(path) else "global" - print(f"{path} ({kind}, {'exists' if path.exists() else 'not created yet'})") - return 0 - - -def cmd_rebase(args: argparse.Namespace) -> int: - """Append another branch's divergent tail under fresh IDs.""" - from docket_rebase import RebaseError, renumber - path = ledger_path() - other = Path(args.other) - if not other.is_file(): - print(f"docket: no ledger at {other}", file=sys.stderr) - return 1 - try: - mine = read(path) - theirs = read(other) - tail, mapping = renumber(mine, theirs) - except (LedgerError, RebaseError, OSError) as exc: - print(f"docket: {exc}", file=sys.stderr) - return 1 - if not tail: - print("docket: nothing to rebase; the histories already agree") - return 0 - for old, new in mapping.items(): - print(f"{old} -> {new}") - if args.dry_run: - print(f"\ndocket: {len(tail)} record(s) would be appended to {path}") - return 0 - # append validates each record against everything already written, so a - # tail that would break the ledger stops partway and leaves it readable. - written = 0 - try: - for record in tail: - append(path, record) - written += 1 - except LedgerError as exc: - print(f"docket: {exc}", file=sys.stderr) - print(f"docket: appended {written} of {len(tail)} record(s); " - "run docket check", file=sys.stderr) - return 1 - print(f"\ndocket: appended {written} record(s) to {path}") - return 0 - - -def cmd_migrate(args: argparse.Namespace) -> int: - """Convert this project's ledger to the current schema.""" - from docket_migrate import ( - MigrationError, derive_mapping, detect_version, migrate_in_place, read_source, - ) - - path = ledger_path() - if not path.exists(): - print(f"docket: no ledger at {path}") - return 0 - try: - if args.emit_map: - source = read_source(path) - if detect_version(source) == 2: - print("docket: already schema 2") - return 0 - mapping = derive_mapping(source) - Path(args.emit_map).write_text( - json.dumps(mapping, indent=2, sort_keys=True) + "\n", encoding="utf-8") - print(f"docket: wrote {len(mapping)} mapping entr" - f"{'y' if len(mapping) == 1 else 'ies'} to {args.emit_map}") - return 0 - count, report, notes = migrate_in_place(path, mapping_path=args.map, dry_run=args.dry_run) - except MigrationError as exc: - print(f"docket: {exc}", file=sys.stderr) - print("docket: to classify records by hand, run 'docket migrate --emit-map FILE', " - "edit FILE, then 'docket migrate --map FILE'", file=sys.stderr) - return 2 - except OSError as exc: - print(f"docket: {exc}", file=sys.stderr) - return 1 - if not count: - print("docket: already schema 2") - return 0 - for note in notes: - print(f"docket: warning: {note}", file=sys.stderr) - for line in report: - print(line) - if args.dry_run: - print(f"docket: would convert {count} record{'' if count == 1 else 's'}") - return 0 - print(f"docket: converted {count} record{'' if count == 1 else 's'}; " - f"original kept at {path}.schema1") - return 0 - - -def cmd_check(args: argparse.Namespace) -> int: - """Report every fault in the ledger, rather than the first one. - - read() raises on the first bad line. After a merge a person needs the whole - picture before deciding how to repair it. - """ - path = ledger_path() - if not path.exists(): - print(f"docket: no ledger at {path}") - return 0 - try: - lines = path.read_text(encoding="utf-8").splitlines() - except (OSError, UnicodeError) as exc: - print(f"docket: cannot read {path}: {exc}", file=sys.stderr) - return 1 - - faults: list[str] = [] - good: list[dict] = [] - # One index threaded through the loop. Rebuilding it per record made doctor - # quadratic in ledger size, which is the cost read() and validate_entries - # already shed. - prefix = _Prefix([]) - seen: dict[str, int] = {} - highest = 0 - count = 0 - for number, line in enumerate(lines, 1): - if not line.strip(): - continue - try: - record = json.loads(line) - except json.JSONDecodeError as exc: - faults.append(f"line {number}: invalid JSON: {exc.msg}") - continue - count += 1 - ident = str(record.get("id", "")) if isinstance(record, dict) else "" - match = ID_RE.fullmatch(ident) - if not match: - faults.append(f"line {number}: malformed id {ident!r}") - continue - if ident in seen: - faults.append(f"line {number}: duplicate id {ident}, first seen on line {seen[ident]}") - continue - seen[ident] = number - sequence = int(match.group(2)) - if sequence <= highest: - faults.append(f"line {number}: id {ident} does not increase past {highest}") - continue - highest = sequence - # The ID checks alone let a hand-resolved merge pass: a record pointing - # at a same-numbered record from the other branch has a target that - # exists and has the right kind. validate_record is what catches a - # dangling or wrong-kind reference. - try: - checked = validate_record(record, prefix=prefix) - except LedgerError as exc: - faults.append(f"line {number}: {str(exc).removeprefix('docket: ')}") - else: - good.append(checked) - prefix.add(checked) - - if not faults: - print(f"docket: {path} reads cleanly, {count} record{'s' if count != 1 else ''}") - return 0 - print(f"docket: {path} has {len(faults)} fault{'s' if len(faults) != 1 else ''}") - for fault in faults: - print(f" {fault}") - print("\nDuplicate or out-of-order IDs usually mean two branches recorded " - "separately. Recover the other branch's ledger and run:") - print(" docket rebase OTHER_LEDGER") - return 1 - - -def cmd_init(args: argparse.Namespace) -> int: - """Move this project's ledger into the repository so it can be committed.""" - root = project_root() - target = root / LEDGER - target.parent.mkdir(parents=True, exist_ok=True) - ignore = target.parent / ".gitignore" - if not ignore.exists(): - # The append lock is local state. A team that commits .docket/ would - # otherwise commit it. - ignore.write_text("*.lock\n", encoding="utf-8") - if target.exists(): - print(f"docket: already project-local at {target}") - return 0 - - existing = read(global_root() / slug(root) / "ledger.jsonl") - with target.open("w") as f: - for e in existing: - f.write(json.dumps(e) + "\n") - moved = f", moved {len(existing)} entr{'y' if len(existing) == 1 else 'ies'}" if existing else "" - print(f"docket: created {target}{moved}") - return 0 - - -def run_prefix(os_name: str, python: str, me: str) -> str: - """The command an agent should run, quoted for the interpreter it needs. - - A no-extension, shebangless file runs verbatim on POSIX but not on - Windows, so cmd.exe/PowerShell needs the interpreter spelled out. - """ - if os_name == "nt": - return f'"{python}" "{me}"' - return me - - -# Harnesses that want context as their own hook envelope instead of plain -# text, keyed by the --for value. docs/installation.md is the source for -# these shapes; codex is absent because no local hooks.json on this machine -# shows what it expects, and guessing would ship a shape nobody verified. -CONTEXT_ENVELOPES = { - "gemini": lambda text: { - "hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": text}, - }, - "copilot": lambda text: {"additionalContext": text}, - "cursor": lambda text: {"additional_context": text}, -} - - -_AUTO_SCOPE_LIMIT = 50 - - -def auto_scope_files(limit: int = _AUTO_SCOPE_LIMIT) -> tuple[str, ...]: - """Changed and untracked paths, as the working tree's proxy for a task. - - Both commands run from the repository root. git ls-files lists only what - sits under the current directory, and git diff prints root-relative paths, - so running from a subdirectory would drop files and mix two path bases. - -z output, because a path may contain a newline. - """ - - try: - top = subprocess.run(["git", "rev-parse", "--show-toplevel"], - capture_output=True, text=True, timeout=3) - except (OSError, subprocess.SubprocessError): - return () - if top.returncode != 0: - return () - root = top.stdout.strip() - - groups: list[list[str]] = [] - for command in ( - ["git", "diff", "--name-only", "-z", "HEAD"], - ["git", "ls-files", "--others", "--exclude-standard", "-z"], - ): - try: - # Bytes, not text: the locale codec decodes strictly, and a path - # carrying an invalid byte would raise UnicodeDecodeError, which is - # neither OSError nor SubprocessError and would kill every - # docket context in that repository. - done = subprocess.run(command, cwd=root, capture_output=True, timeout=3) - except (OSError, subprocess.SubprocessError): - return () - # A repository with no commits has no HEAD, so the diff fails while - # ls-files still reports every untracked file. Skip the failed command - # and keep what the other one found. - if done.returncode != 0: - groups.append([]) - continue - text = done.stdout.decode("utf-8", errors="surrogateescape") - # git collapses an untracked nested repository to a directory entry - # with a trailing slash. A scope matches files, so such an entry can - # never match and would spend a slot in the cap. - groups.append([path for path in text.split("\0") - if path and not path.endswith("/")]) - - # Interleave the two sources. Taking the head of a concatenated list let a - # branch with more than `limit` modified files starve every untracked one, - # which is the work in progress. - paths: list[str] = [] - for index in range(max((len(group) for group in groups), default=0)): - for group in groups: - if index < len(group) and group[index] not in paths: - paths.append(group[index]) - - if not paths: - # A clean tree says nothing about the task. The last commit does, and it - # is the likeliest starting point for the next piece of work. --root - # keeps a first commit readable, and -m keeps a merge readable, because - # a combined diff prints nothing for either. - try: - done = subprocess.run( - ["git", "diff-tree", "-m", "--root", "--no-commit-id", - "--name-only", "-r", "-z", "HEAD"], - cwd=root, capture_output=True, timeout=3) - except (OSError, subprocess.SubprocessError): - return () - # A repository with no commits has no HEAD, so git exits non-zero and - # the briefing stays unscoped. - if done.returncode == 0: - text = done.stdout.decode("utf-8", errors="surrogateescape") - # -m prints one diff per parent, so a merge repeats a path. - paths = list(dict.fromkeys( - path for path in text.split("\0") if path and not path.endswith("/"))) - return tuple(paths[:limit]) - - -def update_line() -> str | None: - """One notice line, or None. Never performs a network request.""" - from docket_update import disabled, due, notice, read_state, spawn_fetch - - try: - if disabled(): - return None - root = Path(__file__).resolve().parent.parent - state = read_state() - if due(state, time.time()): - spawn_fetch(Path(__file__).resolve()) - return notice(version(), str(state.get("latest", "")), root) - except Exception: - return None - - -def _print_context(text: str, args: argparse.Namespace, - notice: str | None = None) -> int: - body = f"{notice}\n{text}" if notice else text - if not body: - return 0 - if args.for_harness: - print(json.dumps(CONTEXT_ENVELOPES[args.for_harness](body))) - else: - print(body, end="") - return 0 - - -def cmd_context(args: argparse.Namespace) -> int: - from docket_context import build_context as render_context - from docket_config import ConfigError, load as load_settings - try: - settings, settings_id = load_settings(ledger_path().parent) - except ConfigError as exc: - print(f"docket: {exc}", file=sys.stderr) - return 1 - line = update_line() - # Validate against the loaded minimum, not a literal. A config that raises - # budget.minimum would otherwise let a too-small value through and surface - # as an uncaught ValueError from the renderer. - minimum = settings["budget"]["minimum"] - if args.max_chars is not None and args.max_chars < minimum: - print(f"docket: --max-chars must be at least {minimum}", file=sys.stderr) - return 2 - if args.since: - from docket_context import build_delta - try: - raw = read(ledger_path()) - except (LedgerError, OSError) as exc: - print(str(exc), file=sys.stderr) - return 1 - # Project twice. The second projection is the history as it stood at the - # baseline, and the renderer needs it to tell a new loss from an old one. - ident = args.since.partition("@")[0] - cutoff = int(ident[1:]) if ID_RE.fullmatch(ident) else -1 - prefix = [item for item in raw if int(item["id"][1:]) <= cutoff] - delta = build_delta(project(raw), since=args.since, - baseline=project(prefix), - max_chars=args.max_chars, ledger=str(ledger_path()), - settings=settings) - if delta is not None: - return _print_context(delta, args, line) - print(f"docket: baseline {args.since} is unknown or stale; " - "printing a full briefing", file=sys.stderr) - - files = tuple(args.file or ()) - forced = args.auto_scope is True - default_on = (args.auto_scope is None and not args.query - and not files and not args.all_records) - if forced or default_on: - files = tuple(dict.fromkeys(files + auto_scope_files(settings["auto_scope"]["limit"]))) - try: - text = render_context( - project(read(ledger_path()), validated=True), - query=args.query or "", - files=files, - max_chars=args.max_chars, - ledger=str(ledger_path()), - all_records=args.all_records, - settings=settings, - settings_id=settings_id, - ) - except (LedgerError, OSError) as exc: - print(str(exc), file=sys.stderr) - return 1 - return _print_context(text, args, line) - - -_COMPLETION_FLAGS = ( - "--state", "--choice", "--alternative", "--scope", "--rationale", - "--supports", "--depends-on", "--answers", "--supersedes", "--evidence", - "--revisit", "--cost", "--pin", "--kind", "--query", "--file", "--max-chars", - "--auto-scope", "--no-auto-scope", "--since", "--at", - "--all", "--find", "--superseded", "--oneline", "--json", "--plain", "--pretty", - "--style", "--interactive", "--no-interactive", "--for", "--version", - "--dry-run", "--check", -) -_COMPLETION_CMDS = ("claim", "decision", "question", "list", "show", "graph", "context", "where", "check", "rebase", "migrate", "init", "completion", "update") - -_BASH_COMPLETION = f"""\ -_docket() {{ - local cur prev - cur="${{COMP_WORDS[COMP_CWORD]}}" - prev="${{COMP_WORDS[COMP_CWORD-1]}}" - case "$prev" in - --state) COMPREPLY=($(compgen -W "unassessed accepted disputed rejected adopted revoked open resolved" -- "$cur")); return ;; - --supports|--depends-on|--answers|--supersedes|show) - COMPREPLY=($(compgen -W "$(docket list --oneline 2>/dev/null | awk '{{print $1}}')" -- "$cur")); return ;; - completion) COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur")); return ;; - esac - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "{' '.join(_COMPLETION_FLAGS)}" -- "$cur")); return - fi - COMPREPLY=($(compgen -W "{' '.join(_COMPLETION_CMDS)}" -- "$cur")) -}} -complete -F _docket docket -""" - -_ZSH_COMPLETION = f"""\ -#compdef docket - -_docket_ids() {{ - local -a ids - ids=(${{(f)"$(docket list --oneline 2>/dev/null | awk '{{print $1}}')"}}) - _describe 'id' ids -}} - -_arguments -C \\ - '1: :({' '.join(_COMPLETION_CMDS)})' \\ - '*::arg:->args' - -case $words[1] in - show) _docket_ids ;; - completion) _values 'shell' bash zsh fish ;; - claim|decision|question) - _arguments \\ - '--state[state]:state:(unassessed accepted disputed rejected adopted revoked open resolved)' \\ - '--choice[decision choice]:choice:' \\ - '--alternative[decision alternative]:alternative:' \\ - '--supports[supporting ids]:id:_docket_ids' \\ - '--depends-on[decision prerequisites]:id:_docket_ids' \\ - '--answers[question ids]:id:_docket_ids' \\ - '--supersedes[retired ids]:id:_docket_ids' \\ - '--cost[cost if wrong]:cost:' - ;; -esac -""" - -_FISH_COMPLETION = f"""\ -set -l docket_cmds {' '.join(_COMPLETION_CMDS)} -complete -c docket -n "not __fish_seen_subcommand_from $docket_cmds" -a "$docket_cmds" -complete -c docket -n "__fish_seen_subcommand_from show" -a "(docket list --oneline 2>/dev/null | awk '{{print \\$1}}')" -complete -c docket -n "__fish_seen_subcommand_from claim decision question" -l supports -a "(docket list --oneline 2>/dev/null | awk '{{print \\$1}}')" -complete -c docket -n "__fish_seen_subcommand_from claim decision question" -l supersedes -a "(docket list --oneline 2>/dev/null | awk '{{print \\$1}}')" -complete -c docket -n "__fish_seen_subcommand_from claim decision" -l state -a "unassessed accepted disputed rejected adopted revoked" -complete -c docket -n "__fish_seen_subcommand_from completion" -a "bash zsh fish" -""" - -_COMPLETIONS = {"bash": _BASH_COMPLETION, "zsh": _ZSH_COMPLETION, "fish": _FISH_COMPLETION} - - -def cmd_completion(args: argparse.Namespace) -> int: - print(_COMPLETIONS[args.shell], end="") - return 0 - - -LAUNCHER_URL_TEMPLATE = ( - "https://raw.githubusercontent.com/NovusEdge/docket/refs/tags/{tag}/" - "installer/install.py" -) -MAIN_LAUNCHER_URL = ( - "https://raw.githubusercontent.com/NovusEdge/docket/main/installer/install.py" -) - - -def cmd_update(args: argparse.Namespace, root: Path | None = None) -> int: - from docket_update import is_newer, parse_version, read_state, shape, update_command - - root = root or Path(__file__).resolve().parent.parent - running = version() - latest = str(read_state().get("latest", "")) - if args.check: - if not latest or parse_version(latest) is None: - print("docket: no cached release information yet") - return 2 - if is_newer(latest, running): - print(f"docket {latest.lstrip('v')} is available (running {running})") - return 1 - print(f"docket {running} is up to date") - return 0 - - kind = shape(root) - if kind == "plugin": - print(f"docket: this copy is managed by your harness. " - f"Run: {update_command(root)}") - return 0 - if kind == "unknown": - print(f"docket: this copy has no installer and no repository. " - f"Run: {update_command(root)}") - return 0 - if kind == "source": - command = [sys.executable, str(root / "installer" / "install.py"), - "--checkout", str(root), "--update"] - print(" ".join(command)) - return subprocess.call(command) - tag = latest if latest and parse_version(latest) is not None else None - return _run_downloaded_update(tag) - - -def _run_downloaded_update(tag: str | None) -> int: - """Fetch the launcher and run it outside the checkout. - - The bundled launcher takes its own checkout branch, which needs Go and - passes --checkout, and --checkout makes the planner skip the git update. - Without a cached release tag, fall back to the main branch so a fresh - install (no cache populated yet) can still update. - """ - from urllib.request import urlopen - - url = LAUNCHER_URL_TEMPLATE.format(tag=tag) if tag else MAIN_LAUNCHER_URL - with tempfile.TemporaryDirectory() as work: - launcher = Path(work) / "install.py" - try: - with urlopen(url, timeout=30) as response: - launcher.write_bytes(response.read()) - except OSError as exc: - print(f"docket: could not download the installer: {exc}", file=sys.stderr) - return 1 - command = [sys.executable, str(launcher), "--update"] - print(" ".join(command)) - return subprocess.call(command, cwd=work) - - -def cmd_update_fetch(args: argparse.Namespace) -> int: - from docket_update import run_fetch - - return run_fetch(time.time()) - - -def _add_shared_args(p: argparse.ArgumentParser) -> None: - p.add_argument("--scope", action="append", default=[]) - p.add_argument("--rationale", default="") - p.add_argument("--supports", action="append", default=[], metavar="CSV") - p.add_argument("--depends-on", default="", metavar="CSV") - p.add_argument("--answers", default="", metavar="CSV") - p.add_argument("--supersedes", default="", metavar="CSV") - p.add_argument("--evidence", action="append", default=[]) - p.add_argument("--revisit", default="") - p.add_argument("--cost", default="") - p.add_argument("--pin", action="store_true") - - -def main(argv: list[str] | None = None) -> int: - p = argparse.ArgumentParser( - prog="docket", - description="Record typed claims, decisions, and open questions.", - add_help=False, - ) - p.add_argument("-h", "--help", action=HelpAction, nargs=0, help="show this help and exit") - p.add_argument("--version", action=VersionAction, nargs=0, help="print the release and exit") - sub = p.add_subparsers(dest="cmd", metavar=( - "{claim,decision,question,list,show,graph,context,where,check," - "rebase,migrate,init,completion,update}")) - - cl = sub.add_parser("claim", help="record a proposition") - cl.add_argument("text") - cl.add_argument("--state", choices=STATES["claim"], default="unassessed") - _add_shared_args(cl) - cl.set_defaults(func=cmd_claim) - - dec = sub.add_parser("decision", help="record a commitment") - dec.add_argument("text", metavar="QUESTION") - dec.add_argument("--choice", required=True) - dec.add_argument("--alternative", action="append", default=[]) - dec.add_argument("--state", choices=STATES["decision"], default="adopted") - dec.add_argument("--decided-by", default="") - _add_shared_args(dec) - dec.set_defaults(func=cmd_decision) - - qu = sub.add_parser("question", help="record an unresolved inquiry") - qu.add_argument("text") - _add_shared_args(qu) - qu.set_defaults(func=cmd_question) - - ls = sub.add_parser("list", help="list records") - ls.add_argument("--kind", choices=KINDS) - ls.add_argument("--state", choices=tuple(sorted({state for values in STATES.values() for state in values}))) - ls.add_argument("--find", help="match question or answer text") - ls.add_argument( - "--superseded", action="store_true", - help="include entries a later decision retired", - ) - ls.add_argument("--oneline", action="store_true", help="one line per entry, no answer") - ls.add_argument("--json", action="store_true", help="print projected records as JSON") - ls.add_argument("--plain", action="store_true", help="force colour off") - ls.add_argument("--pretty", action="store_true", help="force colour on, e.g. piping to less -R") - ls.set_defaults(func=cmd_list) - - gr = sub.add_parser("graph", help="browse decision support relationships") - gr.add_argument("--style", choices=("forest", "rail", "compact")) - gr.add_argument("--kind", choices=KINDS) - gr.add_argument("--state", choices=tuple(sorted({state for values in STATES.values() for state in values}))) - gr.add_argument("--find", help="match question or answer text") - gr.add_argument("--plain", action="store_true", help="force colour off") - gr.add_argument("--pretty", action="store_true", help="force colour on, e.g. piping to less -R") - mode = gr.add_mutually_exclusive_group() - mode.add_argument("--interactive", action="store_true", help="use the native interactive viewer") - mode.add_argument("--no-interactive", action="store_true", help="force the static text renderer") - gr.set_defaults(func=cmd_graph) - - sh = sub.add_parser("show", help="print one entry, human-readable") - sh.add_argument("id") - sh.add_argument("--json", action="store_true", help="print the entry as JSON") - sh.add_argument("--at", default="", - help="print the record as history stood at this record ID") - sh.set_defaults(func=cmd_show) - - ctx = sub.add_parser("context", help="print the ledger for session injection") - ctx.add_argument( - "--for", dest="for_harness", choices=sorted(CONTEXT_ENVELOPES), - help="wrap the ledger text in this harness's own hook envelope", - ) - ctx.add_argument("--query", default="") - ctx.add_argument("--file", action="append", default=[]) - # No default: the renderer treats None as a soft target that a - # task-matching record may exceed. A value here is a hard ceiling. - ctx.add_argument("--max-chars", type=int, default=None) - ctx.add_argument("--all", dest="all_records", action="store_true") - ctx.add_argument( - "--auto-scope", dest="auto_scope", action="store_true", default=None, - help="derive file scope from git, even alongside an explicit query or file", - ) - ctx.add_argument( - "--no-auto-scope", dest="auto_scope", action="store_false", - help="never derive file scope from git", - ) - ctx.add_argument("--since", default="", - help="report what changed after this record ID or ID@DIGEST") - ctx.set_defaults(func=cmd_context) - - wh = sub.add_parser("where", help="print which ledger file is in use") - wh.set_defaults(func=cmd_where) - - ck = sub.add_parser("check", help="report what makes the ledger unreadable") - ck.set_defaults(func=cmd_check) - - rb = sub.add_parser("rebase", help="renumber another ledger's tail onto this one") - rb.add_argument("other", help="path to the other branch's ledger") - rb.add_argument("--dry-run", action="store_true", help="print the ID map and write nothing") - rb.set_defaults(func=cmd_rebase) - - mg = sub.add_parser("migrate", help="convert a legacy ledger to the current schema") - source = mg.add_mutually_exclusive_group() - source.add_argument("--map", help="classification map to apply instead of the derived one") - source.add_argument("--emit-map", help="write the derived map to this path and stop") - mg.add_argument("--dry-run", action="store_true", help="report the conversion and stop") - mg.set_defaults(func=cmd_migrate) - - it = sub.add_parser("init", help="move this project's ledger into the repository") - it.set_defaults(func=cmd_init) - - co = sub.add_parser("completion", help="print a shell completion script") - co.add_argument("shell", choices=("bash", "zsh", "fish")) - co.set_defaults(func=cmd_completion) - - ud = sub.add_parser("update", help="update this Docket installation") - ud.add_argument("--check", action="store_true", - help="report whether an update is available; change nothing") - ud.set_defaults(func=cmd_update) - - sub.add_parser("_update-fetch").set_defaults(func=cmd_update_fetch) - - args = p.parse_args(argv) - if args.cmd is None: - print(f"docket {version()}") - print(p.format_help(), end="") - return 0 - if args.cmd == "graph" and args.interactive: - if args.plain: - p.error("graph --interactive conflicts with --plain") - if args.style is not None: - p.error("graph --interactive conflicts with --style") - try: - return args.func(args) - except (LedgerError, OSError) as exc: - print(str(exc), file=sys.stderr) - return 1 +# .resolve() because the installer symlinks this file onto PATH; without it the +# insert names the symlink's directory instead of the checkout. +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) +from docket.cli import main # noqa: E402 if __name__ == "__main__": sys.exit(main()) diff --git a/docket/__init__.py b/docket/__init__.py new file mode 100644 index 0000000..0d00e0e --- /dev/null +++ b/docket/__init__.py @@ -0,0 +1,24 @@ +"""The docket package: ledger, context, config, migrate, rebase, update. + +Imports no submodule, so importing one never pulls the rest. A SessionStart +hook runs this on every session. +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def version() -> str: + """The release, from the VERSION file beside the checkout. + + Read only when a version or top-level help request asks for it. The + SessionStart hook runs `context` on every session and must not pay for a + file read it never uses. + """ + try: + return (ROOT / "VERSION").read_text().strip() + except OSError: + return "unknown" diff --git a/docket/cli/__init__.py b/docket/cli/__init__.py new file mode 100644 index 0000000..770ec26 --- /dev/null +++ b/docket/cli/__init__.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import argparse +import sys + +from docket import version +from docket.ledger import KINDS, STATES, LedgerError +from docket.cli.record import cmd_claim, cmd_decision, cmd_question +from docket.cli.query import CONTEXT_ENVELOPES, cmd_context, cmd_list, cmd_show, cmd_where +from docket.cli.graph import cmd_graph +from docket.cli.admin import ( + cmd_check, + cmd_completion, + cmd_init, + cmd_migrate, + cmd_rebase, + cmd_update, + cmd_update_fetch, +) + + +class VersionAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + print(f"docket {version()}") + parser.exit() + + +class HelpAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + print(f"docket {version()}") + print(parser.format_help(), end="") + parser.exit() + + +def _add_shared_args(p: argparse.ArgumentParser) -> None: + p.add_argument("--scope", action="append", default=[]) + p.add_argument("--rationale", default="") + p.add_argument("--supports", action="append", default=[], metavar="CSV") + p.add_argument("--depends-on", default="", metavar="CSV") + p.add_argument("--answers", default="", metavar="CSV") + p.add_argument("--supersedes", default="", metavar="CSV") + p.add_argument("--evidence", action="append", default=[]) + p.add_argument("--revisit", default="") + p.add_argument("--cost", default="") + p.add_argument("--pin", action="store_true") + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + prog="docket", + description="Record typed claims, decisions, and open questions.", + add_help=False, + ) + p.add_argument("-h", "--help", action=HelpAction, nargs=0, help="show this help and exit") + p.add_argument("--version", action=VersionAction, nargs=0, help="print the release and exit") + sub = p.add_subparsers(dest="cmd", metavar=( + "{claim,decision,question,list,show,graph,context,where,check," + "rebase,migrate,init,completion,update}")) + + cl = sub.add_parser("claim", help="record a proposition") + cl.add_argument("text") + cl.add_argument("--state", choices=STATES["claim"], default="unassessed") + _add_shared_args(cl) + cl.set_defaults(func=cmd_claim) + + dec = sub.add_parser("decision", help="record a commitment") + dec.add_argument("text", metavar="QUESTION") + dec.add_argument("--choice", required=True) + dec.add_argument("--alternative", action="append", default=[]) + dec.add_argument("--state", choices=STATES["decision"], default="adopted") + dec.add_argument("--decided-by", default="") + _add_shared_args(dec) + dec.set_defaults(func=cmd_decision) + + qu = sub.add_parser("question", help="record an unresolved inquiry") + qu.add_argument("text") + _add_shared_args(qu) + qu.set_defaults(func=cmd_question) + + ls = sub.add_parser("list", help="list records") + ls.add_argument("--kind", choices=KINDS) + ls.add_argument("--state", choices=tuple(sorted({state for values in STATES.values() for state in values}))) + ls.add_argument("--find", help="match question or answer text") + ls.add_argument( + "--superseded", action="store_true", + help="include entries a later decision retired", + ) + ls.add_argument("--oneline", action="store_true", help="one line per entry, no answer") + ls.add_argument("--json", action="store_true", help="print projected records as JSON") + ls.add_argument("--plain", action="store_true", help="force colour off") + ls.add_argument("--pretty", action="store_true", help="force colour on, e.g. piping to less -R") + ls.set_defaults(func=cmd_list) + + gr = sub.add_parser("graph", help="browse decision support relationships") + gr.add_argument("--style", choices=("forest", "rail", "compact")) + gr.add_argument("--kind", choices=KINDS) + gr.add_argument("--state", choices=tuple(sorted({state for values in STATES.values() for state in values}))) + gr.add_argument("--find", help="match question or answer text") + gr.add_argument("--plain", action="store_true", help="force colour off") + gr.add_argument("--pretty", action="store_true", help="force colour on, e.g. piping to less -R") + mode = gr.add_mutually_exclusive_group() + mode.add_argument("--interactive", action="store_true", help="use the native interactive viewer") + mode.add_argument("--no-interactive", action="store_true", help="force the static text renderer") + gr.set_defaults(func=cmd_graph) + + sh = sub.add_parser("show", help="print one entry, human-readable") + sh.add_argument("id") + sh.add_argument("--json", action="store_true", help="print the entry as JSON") + sh.add_argument("--at", default="", + help="print the record as history stood at this record ID") + sh.set_defaults(func=cmd_show) + + ctx = sub.add_parser("context", help="print the ledger for session injection") + ctx.add_argument( + "--for", dest="for_harness", choices=sorted(CONTEXT_ENVELOPES), + help="wrap the ledger text in this harness's own hook envelope", + ) + ctx.add_argument("--query", default="") + ctx.add_argument("--file", action="append", default=[]) + # No default: the renderer treats None as a soft target that a + # task-matching record may exceed. A value here is a hard ceiling. + ctx.add_argument("--max-chars", type=int, default=None) + ctx.add_argument("--all", dest="all_records", action="store_true") + ctx.add_argument( + "--auto-scope", dest="auto_scope", action="store_true", default=None, + help="derive file scope from git, even alongside an explicit query or file", + ) + ctx.add_argument( + "--no-auto-scope", dest="auto_scope", action="store_false", + help="never derive file scope from git", + ) + ctx.add_argument("--since", default="", + help="report what changed after this record ID or ID@DIGEST") + ctx.set_defaults(func=cmd_context) + + wh = sub.add_parser("where", help="print which ledger file is in use") + wh.set_defaults(func=cmd_where) + + ck = sub.add_parser("check", help="report what makes the ledger unreadable") + ck.set_defaults(func=cmd_check) + + rb = sub.add_parser("rebase", help="renumber another ledger's tail onto this one") + rb.add_argument("other", help="path to the other branch's ledger") + rb.add_argument("--dry-run", action="store_true", help="print the ID map and write nothing") + rb.set_defaults(func=cmd_rebase) + + mg = sub.add_parser("migrate", help="convert a legacy ledger to the current schema") + source = mg.add_mutually_exclusive_group() + source.add_argument("--map", help="classification map to apply instead of the derived one") + source.add_argument("--emit-map", help="write the derived map to this path and stop") + mg.add_argument("--dry-run", action="store_true", help="report the conversion and stop") + mg.set_defaults(func=cmd_migrate) + + it = sub.add_parser("init", help="move this project's ledger into the repository") + it.set_defaults(func=cmd_init) + + co = sub.add_parser("completion", help="print a shell completion script") + co.add_argument("shell", choices=("bash", "zsh", "fish")) + co.set_defaults(func=cmd_completion) + + ud = sub.add_parser("update", help="update this Docket installation") + ud.add_argument("--check", action="store_true", + help="report whether an update is available; change nothing") + ud.set_defaults(func=cmd_update) + + sub.add_parser("_update-fetch").set_defaults(func=cmd_update_fetch) + + args = p.parse_args(argv) + if args.cmd is None: + print(f"docket {version()}") + print(p.format_help(), end="") + return 0 + if args.cmd == "graph" and args.interactive: + if args.plain: + p.error("graph --interactive conflicts with --plain") + if args.style is not None: + p.error("graph --interactive conflicts with --style") + try: + return args.func(args) + except (LedgerError, OSError) as exc: + print(str(exc), file=sys.stderr) + return 1 diff --git a/docket/cli/admin.py b/docket/cli/admin.py new file mode 100644 index 0000000..c841983 --- /dev/null +++ b/docket/cli/admin.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from docket import ROOT, env, version +from docket.ledger import ID_RE, LedgerError, _Prefix, append, validate_record + + +def cmd_rebase(args: argparse.Namespace) -> int: + """Append another branch's divergent tail under fresh IDs.""" + from docket.rebase import RebaseError, renumber + path = env.ledger_path() + other = Path(args.other) + if not other.is_file(): + print(f"docket: no ledger at {other}", file=sys.stderr) + return 1 + try: + mine = env.read(path) + theirs = env.read(other) + tail, mapping = renumber(mine, theirs) + except (LedgerError, RebaseError, OSError) as exc: + print(f"docket: {exc}", file=sys.stderr) + return 1 + if not tail: + print("docket: nothing to rebase; the histories already agree") + return 0 + for old, new in mapping.items(): + print(f"{old} -> {new}") + if args.dry_run: + print(f"\ndocket: {len(tail)} record(s) would be appended to {path}") + return 0 + # append validates each record against everything already written, so a + # tail that would break the ledger stops partway and leaves it readable. + written = 0 + try: + for record in tail: + append(path, record) + written += 1 + except LedgerError as exc: + print(f"docket: {exc}", file=sys.stderr) + print(f"docket: appended {written} of {len(tail)} record(s); " + "run docket check", file=sys.stderr) + return 1 + print(f"\ndocket: appended {written} record(s) to {path}") + return 0 + + +def cmd_migrate(args: argparse.Namespace) -> int: + """Convert this project's ledger to the current schema.""" + from docket.migrate import ( + MigrationError, derive_mapping, detect_version, migrate_in_place, read_source, + ) + + path = env.ledger_path() + if not path.exists(): + print(f"docket: no ledger at {path}") + return 0 + try: + if args.emit_map: + source = read_source(path) + if detect_version(source) == 2: + print("docket: already schema 2") + return 0 + mapping = derive_mapping(source) + Path(args.emit_map).write_text( + json.dumps(mapping, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"docket: wrote {len(mapping)} mapping entr" + f"{'y' if len(mapping) == 1 else 'ies'} to {args.emit_map}") + return 0 + count, report, notes = migrate_in_place(path, mapping_path=args.map, dry_run=args.dry_run) + except MigrationError as exc: + print(f"docket: {exc}", file=sys.stderr) + print("docket: to classify records by hand, run 'docket migrate --emit-map FILE', " + "edit FILE, then 'docket migrate --map FILE'", file=sys.stderr) + return 2 + except OSError as exc: + print(f"docket: {exc}", file=sys.stderr) + return 1 + if not count: + print("docket: already schema 2") + return 0 + for note in notes: + print(f"docket: warning: {note}", file=sys.stderr) + for line in report: + print(line) + if args.dry_run: + print(f"docket: would convert {count} record{'' if count == 1 else 's'}") + return 0 + print(f"docket: converted {count} record{'' if count == 1 else 's'}; " + f"original kept at {path}.schema1") + return 0 + + +def cmd_check(args: argparse.Namespace) -> int: + """Report every fault in the ledger, rather than the first one. + + read() raises on the first bad line. After a merge a person needs the whole + picture before deciding how to repair it. + """ + path = env.ledger_path() + if not path.exists(): + print(f"docket: no ledger at {path}") + return 0 + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError) as exc: + print(f"docket: cannot read {path}: {exc}", file=sys.stderr) + return 1 + + faults: list[str] = [] + good: list[dict] = [] + # One index threaded through the loop. Rebuilding it per record made doctor + # quadratic in ledger size, which is the cost read() and validate_entries + # already shed. + prefix = _Prefix([]) + seen: dict[str, int] = {} + highest = 0 + count = 0 + for number, line in enumerate(lines, 1): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + faults.append(f"line {number}: invalid JSON: {exc.msg}") + continue + count += 1 + ident = str(record.get("id", "")) if isinstance(record, dict) else "" + match = ID_RE.fullmatch(ident) + if not match: + faults.append(f"line {number}: malformed id {ident!r}") + continue + if ident in seen: + faults.append(f"line {number}: duplicate id {ident}, first seen on line {seen[ident]}") + continue + seen[ident] = number + sequence = int(match.group(2)) + if sequence <= highest: + faults.append(f"line {number}: id {ident} does not increase past {highest}") + continue + highest = sequence + # The ID checks alone let a hand-resolved merge pass: a record pointing + # at a same-numbered record from the other branch has a target that + # exists and has the right kind. validate_record is what catches a + # dangling or wrong-kind reference. + try: + checked = validate_record(record, prefix=prefix) + except LedgerError as exc: + faults.append(f"line {number}: {str(exc).removeprefix('docket: ')}") + else: + good.append(checked) + prefix.add(checked) + + if not faults: + print(f"docket: {path} reads cleanly, {count} record{'s' if count != 1 else ''}") + return 0 + print(f"docket: {path} has {len(faults)} fault{'s' if len(faults) != 1 else ''}") + for fault in faults: + print(f" {fault}") + print("\nDuplicate or out-of-order IDs usually mean two branches recorded " + "separately. Recover the other branch's ledger and run:") + print(" docket rebase OTHER_LEDGER") + return 1 + + +def cmd_init(args: argparse.Namespace) -> int: + """Move this project's ledger into the repository so it can be committed.""" + root = env.project_root() + target = root / env.LEDGER + target.parent.mkdir(parents=True, exist_ok=True) + ignore = target.parent / ".gitignore" + if not ignore.exists(): + # The append lock is local state. A team that commits .docket/ would + # otherwise commit it. + ignore.write_text("*.lock\n", encoding="utf-8") + if target.exists(): + print(f"docket: already project-local at {target}") + return 0 + + existing = env.read(env.global_root() / env.slug(root) / "ledger.jsonl") + with target.open("w") as f: + for e in existing: + f.write(json.dumps(e) + "\n") + moved = f", moved {len(existing)} entr{'y' if len(existing) == 1 else 'ies'}" if existing else "" + print(f"docket: created {target}{moved}") + return 0 + + +_COMPLETION_FLAGS = ( + "--state", "--choice", "--alternative", "--scope", "--rationale", + "--supports", "--depends-on", "--answers", "--supersedes", "--evidence", + "--revisit", "--cost", "--pin", "--kind", "--query", "--file", "--max-chars", + "--auto-scope", "--no-auto-scope", "--since", "--at", + "--all", "--find", "--superseded", "--oneline", "--json", "--plain", "--pretty", + "--style", "--interactive", "--no-interactive", "--for", "--version", + "--dry-run", "--check", +) +_COMPLETION_CMDS = ("claim", "decision", "question", "list", "show", "graph", "context", "where", "check", "rebase", "migrate", "init", "completion", "update") + +_BASH_COMPLETION = f"""\ +_docket() {{ + local cur prev + cur="${{COMP_WORDS[COMP_CWORD]}}" + prev="${{COMP_WORDS[COMP_CWORD-1]}}" + case "$prev" in + --state) COMPREPLY=($(compgen -W "unassessed accepted disputed rejected adopted revoked open resolved" -- "$cur")); return ;; + --supports|--depends-on|--answers|--supersedes|show) + COMPREPLY=($(compgen -W "$(docket list --oneline 2>/dev/null | awk '{{print $1}}')" -- "$cur")); return ;; + completion) COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur")); return ;; + esac + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "{' '.join(_COMPLETION_FLAGS)}" -- "$cur")); return + fi + COMPREPLY=($(compgen -W "{' '.join(_COMPLETION_CMDS)}" -- "$cur")) +}} +complete -F _docket docket +""" + +_ZSH_COMPLETION = f"""\ +#compdef docket + +_docket_ids() {{ + local -a ids + ids=(${{(f)"$(docket list --oneline 2>/dev/null | awk '{{print $1}}')"}}) + _describe 'id' ids +}} + +_arguments -C \\ + '1: :({' '.join(_COMPLETION_CMDS)})' \\ + '*::arg:->args' + +case $words[1] in + show) _docket_ids ;; + completion) _values 'shell' bash zsh fish ;; + claim|decision|question) + _arguments \\ + '--state[state]:state:(unassessed accepted disputed rejected adopted revoked open resolved)' \\ + '--choice[decision choice]:choice:' \\ + '--alternative[decision alternative]:alternative:' \\ + '--supports[supporting ids]:id:_docket_ids' \\ + '--depends-on[decision prerequisites]:id:_docket_ids' \\ + '--answers[question ids]:id:_docket_ids' \\ + '--supersedes[retired ids]:id:_docket_ids' \\ + '--cost[cost if wrong]:cost:' + ;; +esac +""" + +_FISH_COMPLETION = f"""\ +set -l docket_cmds {' '.join(_COMPLETION_CMDS)} +complete -c docket -n "not __fish_seen_subcommand_from $docket_cmds" -a "$docket_cmds" +complete -c docket -n "__fish_seen_subcommand_from show" -a "(docket list --oneline 2>/dev/null | awk '{{print \\$1}}')" +complete -c docket -n "__fish_seen_subcommand_from claim decision question" -l supports -a "(docket list --oneline 2>/dev/null | awk '{{print \\$1}}')" +complete -c docket -n "__fish_seen_subcommand_from claim decision question" -l supersedes -a "(docket list --oneline 2>/dev/null | awk '{{print \\$1}}')" +complete -c docket -n "__fish_seen_subcommand_from claim decision" -l state -a "unassessed accepted disputed rejected adopted revoked" +complete -c docket -n "__fish_seen_subcommand_from completion" -a "bash zsh fish" +""" + +_COMPLETIONS = {"bash": _BASH_COMPLETION, "zsh": _ZSH_COMPLETION, "fish": _FISH_COMPLETION} + + +def cmd_completion(args: argparse.Namespace) -> int: + print(_COMPLETIONS[args.shell], end="") + return 0 + + +LAUNCHER_URL_TEMPLATE = ( + "https://raw.githubusercontent.com/NovusEdge/docket/refs/tags/{tag}/" + "installer/install.py" +) +MAIN_LAUNCHER_URL = ( + "https://raw.githubusercontent.com/NovusEdge/docket/main/installer/install.py" +) + + +def cmd_update(args: argparse.Namespace, root: Path | None = None) -> int: + from docket.update import is_newer, parse_version, read_state, shape, update_command + + # ROOT, never __file__: this module sits two levels below the checkout, so + # parent.parent would name docket/ and update_command would print a path + # that does not exist. + root = root or ROOT + running = version() + latest = str(read_state().get("latest", "")) + if args.check: + if not latest or parse_version(latest) is None: + print("docket: no cached release information yet") + return 2 + if is_newer(latest, running): + print(f"docket {latest.lstrip('v')} is available (running {running})") + return 1 + print(f"docket {running} is up to date") + return 0 + + kind = shape(root) + if kind == "plugin": + print(f"docket: this copy is managed by your harness. " + f"Run: {update_command(root)}") + return 0 + if kind == "unknown": + print(f"docket: this copy has no installer and no repository. " + f"Run: {update_command(root)}") + return 0 + if kind == "source": + command = [sys.executable, str(root / "installer" / "install.py"), + "--checkout", str(root), "--update"] + print(" ".join(command)) + return subprocess.call(command) + tag = latest if latest and parse_version(latest) is not None else None + return _run_downloaded_update(tag) + + +def _run_downloaded_update(tag: str | None) -> int: + """Fetch the launcher and run it outside the checkout. + + The bundled launcher takes its own checkout branch, which needs Go and + passes --checkout, and --checkout makes the planner skip the git update. + Without a cached release tag, fall back to the main branch so a fresh + install (no cache populated yet) can still update. + """ + from urllib.request import urlopen + + url = LAUNCHER_URL_TEMPLATE.format(tag=tag) if tag else MAIN_LAUNCHER_URL + with tempfile.TemporaryDirectory() as work: + launcher = Path(work) / "install.py" + try: + with urlopen(url, timeout=30) as response: + launcher.write_bytes(response.read()) + except OSError as exc: + print(f"docket: could not download the installer: {exc}", file=sys.stderr) + return 1 + command = [sys.executable, str(launcher), "--update"] + print(" ".join(command)) + return subprocess.call(command, cwd=work) + + +def cmd_update_fetch(args: argparse.Namespace) -> int: + from docket.update import run_fetch + + return run_fetch(time.time()) diff --git a/docket/cli/graph.py b/docket/cli/graph.py new file mode 100644 index 0000000..d77818d --- /dev/null +++ b/docket/cli/graph.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +import textwrap +from pathlib import Path + +from docket import ROOT +from docket import env +from docket.env import justification_sets, read, retired_by +from docket.ledger import graph_payload, project +from docket.cli.term import ( + _c, + _DIM, + _STATE_COLOR, + _use_color, + _use_glyphs, + _GRAPH_GLYPHS, + _GRAPH_GLYPHS_ASCII, + _match, +) + + +def _node_info(e: dict, retired: dict[str, str]) -> dict: + """Precompute what every graph renderer needs from one entry. + + supports is the union of ids across every alternative set, in first-seen + order, so the forest can hang a node under its first support and name the + rest as extras without walking justification_sets() again per renderer. + """ + sets = justification_sets(e) + supports: list[str] = [] + for s in sets: + for i in s: + if i not in supports: + supports.append(i) + return { + "id": e["id"], + "kind": e.get("kind", ""), + "state": e.get("state", ""), + "recorded_state": e.get("recorded_state", e.get("state", "")), + "question": e.get("text", ""), + "answer": e.get("choice", e.get("rationale", "")), + "cost": e.get("cost_if_wrong", ""), + "sets": sets, + "supports": supports, + "retired_by": retired.get(e["id"], ""), + "supersedes": e.get("supersedes", []), + "depends_on": e.get("depends_on", []), + "answers": e.get("answers", []), + "resolved_by": e.get("resolved_by", []), + "scope": e.get("scope", []), + "rationale": e.get("rationale", ""), + "alternatives": e.get("alternatives", []), + "evidence": e.get("evidence", []), + "revisit": e.get("revisit", ""), + "author": e.get("author", ""), + "ts": e.get("ts", ""), + "branch": e.get("branch", ""), + "session": e.get("session", ""), + "pinned": e.get("pinned", False), + "applicable": e.get("applicable"), + "blocked_by": e.get("blocked_by", []), + "decided_by": e.get("decided_by", ""), + } + + +def _formula(sets: list[list[str]]) -> str: + return " | ".join(",".join(s) for s in sets) + + +def _blocked_text(info: dict) -> str: + if info.get("kind") == "decision" and info.get("applicable") is False: + return "! blocked by " + (", ".join(info.get("blocked_by", [])) or "prerequisites") + return "" + + +def _graph_entries(args: argparse.Namespace) -> tuple[list[dict], dict[str, str]]: + """Entries for `graph`, including retired ones. + + Unlike cmd_list, retired entries stay in, or the edges a later entry drew + to them would dangle. + """ + entries = project(read(env.ledger_path()), validated=True) + retired = retired_by(entries) + if args.state: + entries = [e for e in entries if e.get("state") == args.state] + if args.find: + entries = [e for e in entries if _match(e, args.find)] + if getattr(args, "kind", None): + entries = [e for e in entries if e.get("kind") == args.kind] + return entries, retired + + +def _graph_viewer_path() -> Path: + """The viewer built by the installer in this checkout.""" + name = "docket-graph.exe" if os.name == "nt" else "docket-graph" + return ROOT / "graph" / name + + +def _graph_is_tty() -> bool: + """Whether graph owns both terminal handles needed by Bubble Tea.""" + return all( + bool(getattr(stream, "isatty", lambda: False)()) + for stream in (sys.stdin, sys.stdout) + ) + + +def _graph_viewer_error(viewer: Path) -> None: + print( + f"docket: interactive viewer not found at {viewer}; " + "build it with `cd graph && go build -o docket-graph .`", + file=sys.stderr, + ) + + +def _graph_payload(entries: list[dict], retired: dict[str, str]) -> dict: + """Serialize only the normalized graph model consumed by the Go viewer.""" + return graph_payload(entries) + + +def _run_graph_viewer(entries: list[dict], retired: dict[str, str], pretty: bool = False) -> int: + """Run the native viewer with an inherited terminal and private input.""" + viewer = _graph_viewer_path() + temp_path: Path | None = None + try: + fd, raw_path = tempfile.mkstemp(prefix="docket-graph-", suffix=".json") + temp_path = Path(raw_path) + with os.fdopen(fd, "w", encoding="utf-8") as data_file: + json.dump(_graph_payload(entries, retired), data_file) + data_file.write("\n") + + try: + command = [str(viewer), "--data", str(temp_path)] + if pretty: + command.append("--pretty") + result = subprocess.run(command) + except KeyboardInterrupt: + return 130 + except OSError as exc: + print(f"docket: could not start interactive viewer: {exc}", file=sys.stderr) + return 1 + return result.returncode + finally: + if temp_path is not None: + try: + temp_path.unlink() + except OSError: + pass + + +def _id_num(eid: str) -> int: + try: + return int(eid[1:]) + except (ValueError, IndexError): + return 0 + + +def _state_glyph(state: str, glyphs: dict, use_color: bool) -> str: + g = glyphs["open"] if state == "open" else glyphs["bullet"] + return _c(_STATE_COLOR.get(state, _DIM), g, use_color) + + +def _label(info: dict, glyphs: dict, use_color: bool) -> tuple[str, int]: + """The `glyph id state ` prefix shared by forest and compact rows, plus + its printable width (ANSI codes have zero display width, so the width is + computed from an uncoloured copy rather than len() on the coloured one).""" + rest = f"{info['id']:<4}{info['state']:<11}" + plain_glyph = glyphs["open"] if info["state"] == "open" else glyphs["bullet"] + plain = f"{plain_glyph} {rest}" + colored = f"{_state_glyph(info['state'], glyphs, use_color)} {rest}" + return colored, len(plain) + + +def _id_state(info: dict) -> str: + """id + state with no glyph, for the rail: the glyph already sits in the + lane at the node's own column, so repeating it in the label would double it.""" + return f"{info['id']:<4}{info['state']:<11}" + + +def _forest_roots(entries: list[dict], nodes: dict[str, dict]) -> list[str]: + """Entries nothing supports. A support the graph filtered out (--state, + --find) also makes its dependent a root: there is nothing to hang it under.""" + visible = set(nodes) + roots = [ + e["id"] for e in entries + if not nodes[e["id"]]["supports"] or not (set(nodes[e["id"]]["supports"]) & visible) + ] + return sorted(roots, key=_id_num, reverse=True) + + +def _forest_children(entries: list[dict], nodes: dict[str, dict], roots: list[str]) -> dict[str, list[str]]: + """Map each support id to the children hung under it (its primary support + only; extras are named in text, never drawn, so each node has one parent).""" + children: dict[str, list[str]] = {} + roots_set = set(roots) + for e in sorted(entries, key=lambda e: _id_num(e["id"])): + eid = e["id"] + if eid in roots_set: + continue + supports = [s for s in nodes[eid]["supports"] if s in nodes] + if not supports: + continue + children.setdefault(supports[0], []).append(eid) + return children + + +def _forest_lines(nodes: dict[str, dict], roots: list[str], children: dict[str, list[str]], + glyphs: dict, use_color: bool, width: int) -> list[str]: + lines: list[str] = [] + + def walk(eid: str, ancestor_last: list[bool]) -> None: + info = nodes[eid] + prefix = "".join((" " if last else glyphs["vert"] + " ") for last in ancestor_last[:-1]) + if ancestor_last: + prefix += glyphs["elbow"] if ancestor_last[-1] else glyphs["tee"] + label, label_width = _label(info, glyphs, use_color) + + extras = info["supports"][1:] + text = info["question"] + if extras: + text += " (also <- %s)" % ", ".join(extras) + if len(info["sets"]) > 1: + text += "\n" + _formula(info["sets"]) + if info["retired_by"]: + text += "\n" + _c(_DIM, f"{glyphs['retired']} retired by {info['retired_by']}", use_color) + # The answer is what separates forest from compact; compact is the + # one-line view. + text += "\n" + info["answer"] + if info["cost"]: + text += "\n" + "! cost: " + info["cost"] + if _blocked_text(info): + text += "\n" + _blocked_text(info) + + cont_prefix = "".join((" " if last else glyphs["vert"] + " ") for last in ancestor_last) + avail = max(width - len(prefix) - label_width, 20) + wrapped_lines: list[str] = [] + for para in text.split("\n"): + wrapped_lines.extend(textwrap.wrap(para, width=avail) or [""]) + + lines.append(prefix + label + " " + wrapped_lines[0]) + for extra_line in wrapped_lines[1:]: + lines.append(cont_prefix + " " * label_width + " " + extra_line) + + kids = sorted(children.get(eid, []), key=_id_num) + for i, kid in enumerate(kids): + walk(kid, ancestor_last + [i == len(kids) - 1]) + + # A root has no parent, so it carries no connector. Passing a depth here + # drew every root as a child of something invisible. + for r in roots: + walk(r, []) + return lines + + +def _compact_lines(nodes: dict[str, dict], roots: list[str], children: dict[str, list[str]], + glyphs: dict, use_color: bool) -> list[str]: + lines: list[str] = [] + + def walk(eid: str, ancestor_last: list[bool]) -> None: + info = nodes[eid] + prefix = "".join((" " if last else glyphs["vert"] + " ") for last in ancestor_last[:-1]) + if ancestor_last: + prefix += glyphs["elbow"] if ancestor_last[-1] else glyphs["tee"] + label, _ = _label(info, glyphs, use_color) + retired = f" {_c(_DIM, glyphs['retired'] + ' retired by ' + info['retired_by'], use_color)}" if info["retired_by"] else "" + blocked = f" {_blocked_text(info)}" if _blocked_text(info) else "" + lines.append(f"{prefix}{label} {info['question']}{retired}{blocked}") + kids = sorted(children.get(eid, []), key=_id_num) + for i, kid in enumerate(kids): + walk(kid, ancestor_last + [i == len(kids) - 1]) + + for r in roots: + walk(r, []) + return lines + + +def _find_or_alloc(columns: list[str | None], label: str) -> int: + """A column labelled `label`, reusing the leftmost freed one if none + exists. The ledger's ids are monotonic and append-only, so processing + newest-first means every column we need has either already been opened by + a dependent, or never existed; there is no need for git's full graph + algorithm to find it.""" + if label in columns: + return columns.index(label) + for i, c in enumerate(columns): + if c is None: + columns[i] = label + return i + columns.append(label) + return len(columns) - 1 + + +def _rail_lines(entries_desc: list[dict], nodes: dict[str, dict], glyphs: dict, use_color: bool, + width: int) -> list[str]: + columns: list[str | None] = [] + rows: list[tuple[int, list[str | None], dict]] = [] + + for e in entries_desc: + eid = e["id"] + c = _find_or_alloc(columns, eid) + # Every other column waiting on this id merges into c. The join has to + # be drawn before the node row, or the lanes vanish and the picture + # claims those dependents led nowhere. + dupes = [i for i in range(len(columns)) if i != c and columns[i] == eid] + if dupes: + rows.append((c, list(columns), None, dupes)) + for i in dupes: + columns[i] = None + snapshot = list(columns) + + info = nodes[eid] + supports = [s for s in info["supports"] if s in nodes] + if not supports: + columns[c] = None # a root drops its column + else: + columns[c] = supports[0] + for extra in supports[1:]: + _find_or_alloc(columns, extra) + # The node's column stays open only while it still awaits a support; + # continuation rows read this to know whether to draw a lane under it. + rows.append((c, snapshot, info, columns[c] is not None)) + + lines: list[str] = [] + for c, snapshot, info, state in rows: + if info is None: + lines.append(_rail_join(snapshot, c, state, glyphs, use_color)) + continue + + plain_cells, cells = [], [] + for i in range(len(snapshot)): + if i == c: + plain_cells.append(glyphs["open"] if info["state"] == "open" else glyphs["bullet"]) + cells.append(_state_glyph(info["state"], glyphs, use_color)) + elif snapshot[i] is not None: + plain_cells.append(glyphs["vert"]) + cells.append(_c(_DIM, glyphs["vert"], use_color)) + else: + plain_cells.append(" ") + cells.append(" ") + lane, plain_lane = " ".join(cells), " ".join(plain_cells) + + # A continuation row shows lanes only. Reusing plain_lane here drew the + # node's own glyph again on every wrapped line. + cont_cells = list(plain_cells) + cont_cells[c] = glyphs["vert"] if state else " " + label = _id_state(info) + avail = max(width - len(plain_lane) - 1 - len(label) - 1, 20) + text = info["question"] + extras = info["supports"][1:] + if extras: + text += " (also <- %s)" % ", ".join(extras) + if len(info["sets"]) > 1: + text += "\n" + _formula(info["sets"]) + if info["retired_by"]: + text += "\n" + f"{glyphs['retired']} retired by {info['retired_by']}" + if _blocked_text(info): + text += "\n" + _blocked_text(info) + wrapped: list[str] = [] + for para in text.split("\n"): + wrapped.extend(textwrap.wrap(para, width=avail) or [""]) + cont = " ".join(cont_cells) + " " * (len(label) + 2) + lines.append(f"{lane} {label} {wrapped[0]}") + for extra_line in wrapped[1:]: + lines.append(f"{cont}{extra_line}") + return lines + + +def _rail_join(columns: list[str | None], c: int, dupes: list[int], + glyphs: dict, use_color: bool) -> str: + """The row that merges every lane awaiting one id back into column c. + + c is always the leftmost such column, because _find_or_alloc returns the + first match, so the join only ever runs rightwards. + """ + last = max(dupes) + cells = [] + for i in range(len(columns)): + if i == c: + cells.append(glyphs["ltee"]) + elif i == last: + cells.append(glyphs["corner"]) + elif i in dupes: + cells.append(glyphs["join"]) + elif columns[i] is not None: + # An unrelated lane the join has to cross. + cells.append(glyphs["cross"] if c < i < last else glyphs["vert"]) + else: + cells.append(glyphs["hbar"] if c < i < last else " ") + row = "" + for i, cell in enumerate(cells): + row += cell + if i < len(cells) - 1: + row += glyphs["hbar"] if c <= i < last else " " + return _c(_DIM, row, use_color) + + +def _render_graph(entries: list[dict], retired: dict[str, str], args: argparse.Namespace, + style: str) -> int: + use_color = False if args.plain else True if args.pretty else _use_color() + glyphs = _GRAPH_GLYPHS if _use_glyphs() else _GRAPH_GLYPHS_ASCII + width = shutil.get_terminal_size().columns + nodes = {e["id"]: _node_info(e, retired) for e in entries} + entries_desc = sorted(entries, key=lambda e: _id_num(e["id"]), reverse=True) + + if style == "rail": + lines = _rail_lines(entries_desc, nodes, glyphs, use_color, width) + else: + roots = _forest_roots(entries, nodes) + children = _forest_children(entries, nodes, roots) + if style == "compact": + lines = _compact_lines(nodes, roots, children, glyphs, use_color) + else: + lines = _forest_lines(nodes, roots, children, glyphs, use_color, width) + + print("\n".join(lines)) + return 0 + + +def cmd_graph(args: argparse.Namespace) -> int: + style = getattr(args, "style", None) + interactive = bool(getattr(args, "interactive", False)) + no_interactive = bool(getattr(args, "no_interactive", False)) + plain = bool(getattr(args, "plain", False)) + + if interactive and no_interactive: + print("docket: --interactive conflicts with --no-interactive", file=sys.stderr) + return 2 + if interactive and plain: + print("docket: --interactive conflicts with --plain", file=sys.stderr) + return 2 + if interactive and style is not None: + print("docket: --interactive conflicts with --style", file=sys.stderr) + return 2 + if interactive and not _graph_is_tty(): + print("docket: --interactive requires terminal stdin and stdout", file=sys.stderr) + return 1 + + entries, retired = _graph_entries(args) + if not entries: + print("docket: nothing recorded") + return 0 + + viewer = _graph_viewer_path() + auto = _graph_is_tty() and not no_interactive and not plain and style is None + if interactive or auto: + if not viewer.is_file(): + _graph_viewer_error(viewer) + if interactive: + return 1 + return _render_graph(entries, retired, args, "compact") + return _run_graph_viewer(entries, retired, pretty=bool(getattr(args, "pretty", False))) + + return _render_graph(entries, retired, args, style or "forest") diff --git a/docket/cli/query.py b/docket/cli/query.py new file mode 100644 index 0000000..322b95a --- /dev/null +++ b/docket/cli/query.py @@ -0,0 +1,339 @@ +"""Commands that read the ledger: list, show, where, context.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import textwrap +import time + +from docket import ROOT, version +from docket.cli.term import _DIM, _STATE_COLOR, _c, _match, _use_color +from docket import env +from docket.env import LEDGER, justification_sets, read, retired_by +from docket.ledger import ID_RE, LedgerError, project + +_LIST_ID_W, _LIST_STATE_W = 5, 9 +_LIST_HEAD_W = _LIST_ID_W + 1 + _LIST_STATE_W + 1 # clears the id+state columns + + +def _list_dim_tail(line: str, marker: str, use_color: bool) -> str: + """Dim a line from `marker` onward, if the line carries it at all. + + Wrapping can split the marker onto a line of its own or leave it whole; + either way the dim colour must start where the marker starts, not at + column 0. + """ + idx = line.find(marker) + if idx == -1 or not use_color: + return line + return line[:idx] + _c(_DIM, line[idx:], use_color) + + +def cmd_list(args: argparse.Namespace) -> int: + entries = project(read(env.ledger_path()), validated=True) + retired = retired_by(entries) + if not args.superseded: + entries = [e for e in entries if e.get("id") not in retired] + if args.state: + entries = [e for e in entries if e.get("state") == args.state] + if getattr(args, "kind", None): + entries = [e for e in entries if e.get("kind") == args.kind] + if args.find: + entries = [e for e in entries if _match(e, args.find)] + if not entries: + if getattr(args, "json", False): + print("[]") + return 0 + print("docket: nothing recorded") + return 0 + if getattr(args, "json", False): + print(json.dumps(entries, ensure_ascii=False, indent=2)) + return 0 + + use_color = False if args.plain else True if args.pretty else _use_color() + width = shutil.get_terminal_size().columns + + if args.oneline: + for e in entries: + id_str = _c(_STATE_COLOR.get(e["state"], _DIM), f"{e['id']:<{_LIST_ID_W}}", use_color) + # Truncated, never wrapped: one entry stays one line, which is what + # makes the mode scannable and pipeable into grep. + room = max(width - _LIST_ID_W - _LIST_STATE_W - 2, 20) + question = textwrap.shorten(e["text"], width=room, placeholder="...") + print(f"{id_str} {e['state']:<{_LIST_STATE_W}} {question}") + return 0 + + for e in entries: + sets = justification_sets(e) + # " | " separates alternatives; each alternative's own ids stay comma-joined. + dep = f" <- {' | '.join(','.join(s) for s in sets)}" if sets else "" + gone = f" (superseded by {retired[e['id']]})" if e.get("id") in retired else "" + + id_str = _c(_STATE_COLOR.get(e["state"], _DIM), f"{e['id']:<{_LIST_ID_W}}", use_color) + state_str = _c(_STATE_COLOR.get(e["state"], _DIM), f"{e['state']:<{_LIST_STATE_W}}", use_color) + + avail = max(width - _LIST_HEAD_W, 20) + wrapped = textwrap.wrap(e["text"] + dep + gone, width=avail) or [""] + print(f"{id_str} {state_str} " + _list_dim_tail(_list_dim_tail(wrapped[0], "<-", use_color), "(superseded by", use_color)) + for line in wrapped[1:]: + line = _list_dim_tail(_list_dim_tail(line, "<-", use_color), "(superseded by", use_color) + print(" " * _LIST_HEAD_W + line) + + detail = e.get("choice", e.get("rationale", "")) + if detail: + for line in textwrap.wrap(detail, width=max(width - 6, 20)) or [""]: + print(" " + line) + return 0 + + +def cmd_show(args: argparse.Namespace) -> int: + raw = read(env.ledger_path()) + if args.at: + if not any(item.get("id") == args.at for item in raw): + print(f"docket: unknown record {args.at}", file=sys.stderr) + return 1 + cutoff = int(args.at[1:]) + raw = [item for item in raw if int(item["id"][1:]) <= cutoff] + entries = project(raw, validated=True) + by_id = {e.get("id"): e for e in entries} + e = by_id.get(args.id) + if not e: + print(f"docket: no entry {args.id}", file=sys.stderr) + return 1 + + if args.json: + print(json.dumps(e, indent=2)) + return 0 + + def cite(i: str) -> str: + q = by_id.get(i, {}).get("text") + return f"{i} ({q})" if q else i + + width = shutil.get_terminal_size().columns + + def field(label: str, value: str) -> None: + """One labelled field, wrapped under a hanging indent past the label.""" + indent = " " * (len(label) + 4) + for i, line in enumerate(textwrap.wrap(f" {label}: {value}", width=width, + subsequent_indent=indent) or [f" {label}:"]): + print(line) + + for line in textwrap.wrap(f"{e['id']} {e.get('kind', '')} {e.get('state', '')} {e.get('text', '')}", + width=width, subsequent_indent=" " * 15): + print(line) + field("Choice", e.get("choice", "")) + field("Rationale", e.get("rationale", "")) + sets = justification_sets(e) + if sets: + field("Because", " | ".join(", ".join(cite(i) for i in s) for s in sets)) + supersedes = e.get("supersedes") or [] + if supersedes: + field("Supersedes", ", ".join(cite(i) for i in supersedes)) + retired = retired_by(entries) + if e["id"] in retired: + field("Superseded by", cite(retired[e["id"]])) + if e.get("depends_on"): + field("Depends on", ", ".join(cite(i) for i in e["depends_on"])) + if e.get("answers"): + field("Answers", ", ".join(cite(i) for i in e["answers"])) + if e.get("resolved_by"): + field("Resolved by", ", ".join(cite(i) for i in e["resolved_by"])) + if e.get("decided_by"): + field("Decided by", e["decided_by"]) + if e.get("cost_if_wrong"): + field("Cost if wrong", e["cost_if_wrong"]) + field("Recorded state", e.get("recorded_state", e.get("state", ""))) + print(f" Author: {e.get('author', '')} Session: {e.get('session', '')} Branch: {e.get('branch', '')}") + return 0 + + +def cmd_where(args: argparse.Namespace) -> int: + path = env.ledger_path() + kind = "project" if LEDGER.name in str(path) and ".claude" not in str(path) else "global" + print(f"{path} ({kind}, {'exists' if path.exists() else 'not created yet'})") + return 0 + + +# Harnesses that want context as their own hook envelope instead of plain +# text, keyed by the --for value. docs/installation.md is the source for +# these shapes; codex is absent because no local hooks.json on this machine +# shows what it expects, and guessing would ship a shape nobody verified. +CONTEXT_ENVELOPES = { + "gemini": lambda text: { + "hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": text}, + }, + "copilot": lambda text: {"additionalContext": text}, + "cursor": lambda text: {"additional_context": text}, +} + + +_AUTO_SCOPE_LIMIT = 50 + + +def auto_scope_files(limit: int = _AUTO_SCOPE_LIMIT) -> tuple[str, ...]: + """Changed and untracked paths, as the working tree's proxy for a task. + + Both commands run from the repository root. git ls-files lists only what + sits under the current directory, and git diff prints root-relative paths, + so running from a subdirectory would drop files and mix two path bases. + -z output, because a path may contain a newline. + """ + + try: + top = subprocess.run(["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, timeout=3) + except (OSError, subprocess.SubprocessError): + return () + if top.returncode != 0: + return () + root = top.stdout.strip() + + groups: list[list[str]] = [] + for command in ( + ["git", "diff", "--name-only", "-z", "HEAD"], + ["git", "ls-files", "--others", "--exclude-standard", "-z"], + ): + try: + # Bytes, not text: the locale codec decodes strictly, and a path + # carrying an invalid byte would raise UnicodeDecodeError, which is + # neither OSError nor SubprocessError and would kill every + # docket context in that repository. + done = subprocess.run(command, cwd=root, capture_output=True, timeout=3) + except (OSError, subprocess.SubprocessError): + return () + # A repository with no commits has no HEAD, so the diff fails while + # ls-files still reports every untracked file. Skip the failed command + # and keep what the other one found. + if done.returncode != 0: + groups.append([]) + continue + text = done.stdout.decode("utf-8", errors="surrogateescape") + # git collapses an untracked nested repository to a directory entry + # with a trailing slash. A scope matches files, so such an entry can + # never match and would spend a slot in the cap. + groups.append([path for path in text.split("\0") + if path and not path.endswith("/")]) + + # Interleave the two sources. Taking the head of a concatenated list let a + # branch with more than `limit` modified files starve every untracked one, + # which is the work in progress. + paths: list[str] = [] + for index in range(max((len(group) for group in groups), default=0)): + for group in groups: + if index < len(group) and group[index] not in paths: + paths.append(group[index]) + + if not paths: + # A clean tree says nothing about the task. The last commit does, and it + # is the likeliest starting point for the next piece of work. --root + # keeps a first commit readable, and -m keeps a merge readable, because + # a combined diff prints nothing for either. + try: + done = subprocess.run( + ["git", "diff-tree", "-m", "--root", "--no-commit-id", + "--name-only", "-r", "-z", "HEAD"], + cwd=root, capture_output=True, timeout=3) + except (OSError, subprocess.SubprocessError): + return () + # A repository with no commits has no HEAD, so git exits non-zero and + # the briefing stays unscoped. + if done.returncode == 0: + text = done.stdout.decode("utf-8", errors="surrogateescape") + # -m prints one diff per parent, so a merge repeats a path. + paths = list(dict.fromkeys( + path for path in text.split("\0") if path and not path.endswith("/"))) + return tuple(paths[:limit]) + + +def update_line() -> str | None: + """One notice line, or None. Never performs a network request.""" + from docket.update import disabled, due, notice, read_state, spawn_fetch + + try: + if disabled(): + return None + # ROOT, never __file__: this module sits two levels below the checkout, + # so parent.parent would name docket/ and the refresh would respawn + # this file instead of the CLI. + state = read_state() + if due(state, time.time()): + spawn_fetch(ROOT / "bin" / "docket") + return notice(version(), str(state.get("latest", "")), ROOT) + except Exception: + return None + + +def _print_context(text: str, args: argparse.Namespace, + notice: str | None = None) -> int: + body = f"{notice}\n{text}" if notice else text + if not body: + return 0 + if args.for_harness: + print(json.dumps(CONTEXT_ENVELOPES[args.for_harness](body))) + else: + print(body, end="") + return 0 + + +def cmd_context(args: argparse.Namespace) -> int: + from docket.context import build_context as render_context + from docket.config import ConfigError, load as load_settings + try: + settings, settings_id = load_settings(env.ledger_path().parent) + except ConfigError as exc: + print(f"docket: {exc}", file=sys.stderr) + return 1 + line = update_line() + # Validate against the loaded minimum, not a literal. A config that raises + # budget.minimum would otherwise let a too-small value through and surface + # as an uncaught ValueError from the renderer. + minimum = settings["budget"]["minimum"] + if args.max_chars is not None and args.max_chars < minimum: + print(f"docket: --max-chars must be at least {minimum}", file=sys.stderr) + return 2 + if args.since: + from docket.context import build_delta + try: + raw = read(env.ledger_path()) + except (LedgerError, OSError) as exc: + print(str(exc), file=sys.stderr) + return 1 + # Project twice. The second projection is the history as it stood at the + # baseline, and the renderer needs it to tell a new loss from an old one. + ident = args.since.partition("@")[0] + cutoff = int(ident[1:]) if ID_RE.fullmatch(ident) else -1 + prefix = [item for item in raw if int(item["id"][1:]) <= cutoff] + delta = build_delta(project(raw), since=args.since, + baseline=project(prefix), + max_chars=args.max_chars, ledger=str(env.ledger_path()), + settings=settings) + if delta is not None: + return _print_context(delta, args, line) + print(f"docket: baseline {args.since} is unknown or stale; " + "printing a full briefing", file=sys.stderr) + + files = tuple(args.file or ()) + forced = args.auto_scope is True + default_on = (args.auto_scope is None and not args.query + and not files and not args.all_records) + if forced or default_on: + files = tuple(dict.fromkeys(files + auto_scope_files(settings["auto_scope"]["limit"]))) + try: + text = render_context( + project(read(env.ledger_path()), validated=True), + query=args.query or "", + files=files, + max_chars=args.max_chars, + ledger=str(env.ledger_path()), + all_records=args.all_records, + settings=settings, + settings_id=settings_id, + ) + except (LedgerError, OSError) as exc: + print(str(exc), file=sys.stderr) + return 1 + return _print_context(text, args, line) diff --git a/docket/cli/record.py b/docket/cli/record.py new file mode 100644 index 0000000..8b9c856 --- /dev/null +++ b/docket/cli/record.py @@ -0,0 +1,54 @@ +import argparse +import json +import sys + +from docket import env +from docket.ledger import LedgerError, append, make_record + + +def _shared_fields(args: argparse.Namespace) -> dict: + from docket.ledger import parse_evidence + return { + "scope": args.scope or [], + "rationale": args.rationale or "", + "supports": [[ref.strip() for ref in group.split(",") if ref.strip()] for group in (args.supports or [])], + "depends_on": [ref.strip() for ref in (args.depends_on or "").split(",") if ref.strip()], + "answers": [ref.strip() for ref in (args.answers or "").split(",") if ref.strip()], + "supersedes": [ref.strip() for ref in (args.supersedes or "").split(",") if ref.strip()], + "evidence": [parse_evidence(item) for item in (args.evidence or [])], + "revisit": args.revisit or "", + "cost_if_wrong": args.cost or "", + "pinned": bool(args.pin), + "author": env.resolved_author(), + "session": env.session_id(), + "branch": env.branch(env.project_root()), + } + + +def _append_cli(kind: str, args: argparse.Namespace) -> int: + try: + fields = _shared_fields(args) + if kind == "decision": + fields.update({"state": args.state, "choice": args.choice, "alternatives": args.alternative or [], "decided_by": args.decided_by}) + elif kind == "claim": + fields["state"] = args.state + entry = make_record(kind, args.text, **fields) + entry["id"] = "" + entry = append(env.ledger_path(), entry) + except (LedgerError, OSError, json.JSONDecodeError) as exc: + print(str(exc), file=sys.stderr) + return 1 + print(f"{entry['id']} {entry['state']} {entry['text']}") + return 0 + + +def cmd_claim(args: argparse.Namespace) -> int: + return _append_cli("claim", args) + + +def cmd_decision(args: argparse.Namespace) -> int: + return _append_cli("decision", args) + + +def cmd_question(args: argparse.Namespace) -> int: + return _append_cli("question", args) diff --git a/docket/cli/term.py b/docket/cli/term.py new file mode 100644 index 0000000..c8e2021 --- /dev/null +++ b/docket/cli/term.py @@ -0,0 +1,63 @@ +import os +import sys + + +def _match(e: dict, term: str) -> bool: + term = term.lower() + return term in e.get("text", "").lower() or term in e.get("choice", "").lower() + + +# Duplicated from install.py:30-61 rather than imported: bin/docket ships and +# runs standalone, so it cannot assume install.py sits beside it. +# +# An agent env var (the same names author() checks) forces colour off even on +# a real tty. Cursor, Copilot in VS Code, and Windsurf run agent shell +# commands over node-pty, so isatty() alone reads as "human" there. Wrongly +# guessing pretty puts escape codes in a model's context window; wrongly +# guessing plain costs a person one flag, so the asymmetry decides it. +_AGENT_ENV_VARS = ( + "CLAUDE_SESSION_ID", "CLAUDE_CODE_BRIDGE_SESSION_ID", "SESSION_ID", + "AI_AGENT", "CODEX_SANDBOX", "CODEX_HOME", +) + + +def _use_color() -> bool: + if os.environ.get("NO_COLOR"): + return False + if not sys.stdout.isatty(): + return False + if os.name == "nt" and not os.environ.get("WT_SESSION"): + return False + if any(os.environ.get(v) for v in _AGENT_ENV_VARS): + return False + return True + + +def _use_glyphs() -> bool: + enc = getattr(sys.stdout, "encoding", None) or "" + try: + "●".encode(enc) + return True + except (LookupError, UnicodeEncodeError): + return False + + +_STATE_COLOR = { + "accepted": "2", "adopted": "2", "resolved": "2", + "rejected": "1", "revoked": "1", "disputed": "1", + "unassessed": "3", "open": "3", +} # green, red, yellow +_DIM = "8" + +_GRAPH_GLYPHS = { + "bullet": "●", "open": "○", "retired": "⊘", "vert": "│", "tee": "├─ ", "elbow": "└─ ", + "hbar": "─", "ltee": "├", "join": "┴", "cross": "┼", "corner": "╯", +} +_GRAPH_GLYPHS_ASCII = { + "bullet": "*", "open": "o", "retired": "x", "vert": "|", "tee": "+- ", "elbow": "`- ", + "hbar": "-", "ltee": "+", "join": "+", "cross": "+", "corner": "'", +} + + +def _c(code: str, text: str, use_color: bool) -> str: + return "\033[3%sm%s\033[0m" % (code, text) if use_color else text diff --git a/lib/docket_config.py b/docket/config.py similarity index 100% rename from lib/docket_config.py rename to docket/config.py diff --git a/lib/docket_context.py b/docket/context.py similarity index 99% rename from lib/docket_context.py rename to docket/context.py index 5ca835f..4bfa6e6 100644 --- a/lib/docket_context.py +++ b/docket/context.py @@ -17,11 +17,7 @@ from typing import Any -try: - # bin/docket puts lib/ on sys.path; the tests import lib.docket_context. - from docket_config import DEFAULTS as _SETTINGS_DEFAULTS -except ImportError: - from .docket_config import DEFAULTS as _SETTINGS_DEFAULTS +from docket.config import DEFAULTS as _SETTINGS_DEFAULTS # The admission gate computes the length it once measured by rendering. Tests diff --git a/docket/env.py b/docket/env.py new file mode 100644 index 0000000..fec1ed4 --- /dev/null +++ b/docket/env.py @@ -0,0 +1,136 @@ +"""Where the ledger lives, and who is writing to it. + +Environment and path resolution, kept apart from the CLI so every command group +reaches it the same way. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +from docket.ledger import read as ledger_read + +LEDGER = Path(".docket/ledger.jsonl") + + +# Honours XDG so the global store can be relocated, and CLAUDE_CONFIG_DIR so an +# isolated Claude profile gets its own ledgers. +def global_root() -> Path: + if env := os.environ.get("DOCKET_HOME"): + return Path(env).expanduser() + if env := os.environ.get("CLAUDE_CONFIG_DIR"): + return Path(env).expanduser() / "docket" + return Path.home() / ".claude" / "docket" + + +def session_id() -> str: + """Claude Code exposes this under more than one name across versions.""" + for var in ("CLAUDE_SESSION_ID", "CLAUDE_CODE_BRIDGE_SESSION_ID", "SESSION_ID"): + if v := os.environ.get(var): + return v + return "" + + +def author() -> str: + """Which agent or person recorded the entry. + + Two agents sharing one ledger is the normal case, so an entry that does not + say who wrote it cannot be weighed. + """ + if v := os.environ.get("DOCKET_AUTHOR"): + return v + if v := os.environ.get("AI_AGENT"): + return v.split("_")[0] + if os.environ.get("CODEX_SANDBOX") or os.environ.get("CODEX_HOME"): + return "codex" + return os.environ.get("USER", "") + + +def resolved_author() -> str: + """author(), falling back to a visible "unknown" instead of silence. + + A blank string is indistinguishable from an unset field once serialized, so + a caller reading the ledger cannot tell "detection failed" from "this entry + predates the author field". Writing "unknown" makes the failure visible; + the missing key on old entries stays the only true absence. + """ + a = author() + if a: + return a + print( + "docket: could not detect an author; recording \"unknown\". " + "Set DOCKET_AUTHOR to identify this harness.", + file=sys.stderr, + ) + return "unknown" + + +def branch(root: Path) -> str: + """Current branch, or empty outside a repository. + + Uses `branch --show-current` because `rev-parse --abbrev-ref HEAD` fails on + an unborn branch, which is every repository before its first commit. + """ + try: + r = subprocess.run( + ["git", "-C", str(root), "branch", "--show-current"], + capture_output=True, text=True, timeout=2, + ) + return r.stdout.strip() if r.returncode == 0 else "" + except (OSError, subprocess.SubprocessError): + return "" + + +def project_root(start: Path | None = None) -> Path: + """The directory a project-local ledger belongs to. + + Prefers the git root so a subdirectory shares the project's ledger. Falls + back to the working directory outside a repository. + """ + here = (start or Path.cwd()).resolve() + for d in (here, *here.parents): + if (d / ".git").exists(): + return d + return here + + +def slug(path: Path) -> str: + """Mangle an absolute path into one filename component.""" + return str(path).replace("/", "-").replace("\\", "-").strip("-") or "root" + + +def ledger_path(start: Path | None = None) -> Path: + """Resolve the ledger for the current project. + + A .docket directory in the project wins, which is how a team opts into + committing decisions. Otherwise the ledger lives under the global root, + keyed by project path, so a new project needs no setup and no gitignore + entry. + """ + here = (start or Path.cwd()).resolve() + for d in (here, *here.parents): + candidate = d / LEDGER + if candidate.exists(): + return candidate + return global_root() / slug(project_root(here)) / "ledger.jsonl" + + +def read(path: Path, lock: bool = True) -> list[dict]: + return ledger_read(path, lock=lock) + + +def justification_sets(e: dict) -> list[list[str]]: + return e.get("supports", []) + + +def retired_by(entries: list[dict]) -> dict[str, str]: + """Map each superseded ID to its replacement. + + Tolerates a raw entry, unlike docket.ledger.retired_by, which subscripts + "supersedes" and raises KeyError on anything project() has not filled in. + Callers here pass read() output straight through. + """ + return {target: e["id"] for e in entries for target in e.get("supersedes", [])} diff --git a/lib/docket_ledger.py b/docket/ledger.py similarity index 100% rename from lib/docket_ledger.py rename to docket/ledger.py diff --git a/lib/docket_migrate.py b/docket/migrate.py similarity index 95% rename from lib/docket_migrate.py rename to docket/migrate.py index 3596af1..ce9c4aa 100755 --- a/lib/docket_migrate.py +++ b/docket/migrate.py @@ -22,20 +22,21 @@ This module deliberately has no prose classifier. It validates all source references and all mapped records before opening the destination with -exclusive-create semantics. The repository's ``lib.docket_ledger`` +exclusive-create semantics. The repository's ``docket.ledger`` ``validate_entries`` function is authoritative. """ from __future__ import annotations import argparse -import importlib import json import re import sys from pathlib import Path from typing import Any, Callable +from docket.ledger import validate_entries, ledger_lock + OLD_ID = re.compile(r"^d[1-9][0-9]*$") NEW_ID = re.compile(r"^[cdq][1-9][0-9]*$") @@ -133,7 +134,7 @@ def _rewrite_supersedes_into_answers( ) -> None: """Move a supersedes target that derives to a question onto answers. - A question cannot carry answers (lib.docket_ledger forbids it), so a + A question cannot carry answers (docket.ledger forbids it), so a question source is left on the default supersedes path; same-kind question-to-question supersession is already legal there. """ @@ -442,17 +443,7 @@ def build_records(source: list[dict[str, Any]], mapping: dict[str, dict[str, Any def core_validator() -> Callable[[list[dict[str, Any]]], Any]: - root = Path(__file__).resolve().parent.parent - if str(root) not in sys.path: - sys.path.insert(0, str(root)) - try: - core = importlib.import_module("lib.docket_ledger") - except (ImportError, ModuleNotFoundError) as exc: - raise MigrationError("cannot import lib.docket_ledger.validate_entries") from exc - candidate = getattr(core, "validate_entries", None) - if not callable(candidate): - raise MigrationError("lib.docket_ledger.validate_entries is unavailable") - return candidate + return validate_entries def migrate(source_path: Path | str, mapping_path: Path | str, output_path: Path | str, @@ -526,14 +517,6 @@ def migrate_in_place(path: Path | str, mapping_path: Path | str | None = None, if dry_run: return len(records), report, notes - # This module loads as top-level "docket_migrate" under bin/docket (only - # lib/ on sys.path) and as "lib.docket_migrate" under the test suite (the - # repo root on sys.path). A module-level import cannot satisfy both, so - # core_validator's own root-plus-package-name approach is reused here. - root = Path(__file__).resolve().parent.parent - if str(root) not in sys.path: - sys.path.insert(0, str(root)) - ledger_lock = importlib.import_module("lib.docket_ledger").ledger_lock with ledger_lock(path): if temp.exists(): raise MigrationError(f"{temp} already exists; remove it and retry") diff --git a/lib/docket_rebase.py b/docket/rebase.py similarity index 93% rename from lib/docket_rebase.py rename to docket/rebase.py index 734d957..637c187 100644 --- a/lib/docket_rebase.py +++ b/docket/rebase.py @@ -18,11 +18,7 @@ from collections.abc import Mapping, Sequence from typing import Any -try: - # bin/docket puts lib/ on sys.path; the tests import lib.docket_rebase. - from docket_ledger import ID_RE, allocate_id -except ImportError: - from .docket_ledger import ID_RE, allocate_id +from docket.ledger import ID_RE, allocate_id class RebaseError(ValueError): @@ -33,7 +29,7 @@ class RebaseError(ValueError): # A migrated record carries legacy.relation_map, whose mapped_* fields # validation requires to equal the record's own relations -# (lib/docket_ledger.py:236-237). Rewriting the relations without the audit map +# (docket/ledger.py:236-237). Rewriting the relations without the audit map # makes append reject the record. Every record in a migrated ledger carries # this, so a rebase that skipped it would fail on the first tail record. _AUDIT_FIELDS = { diff --git a/lib/docket_update.py b/docket/update.py similarity index 100% rename from lib/docket_update.py rename to docket/update.py diff --git a/experiments/context-format/README.md b/experiments/context-format/README.md index 0b48b48..91f8027 100644 --- a/experiments/context-format/README.md +++ b/experiments/context-format/README.md @@ -18,7 +18,7 @@ dependency for this, and the character column compares formats on its own. | Variant | Inputs | | --- | --- | -| `scoped` | `files=("lib/docket_context.py",)` | +| `scoped` | `files=("docket/context.py",)` | | `scoped tight` | the same files, with `budget.target` halved | | `unscoped` | no query and no files | | `all` | `all_records=True` | diff --git a/experiments/context-format/measure.py b/experiments/context-format/measure.py index 7a9a728..e079e90 100644 --- a/experiments/context-format/measure.py +++ b/experiments/context-format/measure.py @@ -9,11 +9,11 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "lib")) +sys.path.insert(0, str(ROOT)) -from docket_config import DEFAULTS, merge -from docket_context import build_context -from docket_ledger import project, read +from docket.config import DEFAULTS, merge +from docket.context import build_context +from docket.ledger import project, read def tokens(text: str) -> str: @@ -32,8 +32,8 @@ def main() -> int: return 1 tight = merge({"budget": {"target": DEFAULTS["budget"]["target"] // 2}}) variants = { - "scoped": {"files": ("lib/docket_context.py",)}, - "scoped tight": {"files": ("lib/docket_context.py",), "settings": tight}, + "scoped": {"files": ("docket/context.py",)}, + "scoped tight": {"files": ("docket/context.py",), "settings": tight}, "unscoped": {}, "all": {"all_records": True}, } diff --git a/experiments/context-scale/generate.py b/experiments/context-scale/generate.py index c2416e1..670d28f 100644 --- a/experiments/context-scale/generate.py +++ b/experiments/context-scale/generate.py @@ -10,10 +10,10 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "lib")) +sys.path.insert(0, str(ROOT)) -from docket_context import build_context -from docket_ledger import make_record, project +from docket.context import build_context +from docket.ledger import make_record, project AREAS = ("lib", "bin", "docs", "installer", "graph", "tests") diff --git a/scripts/migrate_ledger.py b/scripts/migrate_ledger.py index 6e1d6e6..25ccdc4 100755 --- a/scripts/migrate_ledger.py +++ b/scripts/migrate_ledger.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Command-line entry point for the schema-1 to schema-2 ledger migration. -The implementation lives in lib/ because an installed Docket ships lib/ and -bin/ only. This file stays so the documented invocation keeps working. +The implementation lives in docket/ because an installed Docket ships docket/ +and bin/ only. This file stays so the documented invocation keeps working. """ from __future__ import annotations @@ -14,7 +14,13 @@ if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) -from lib.docket_migrate import main # noqa: E402 +try: + from docket.migrate import main # noqa: E402 +except ImportError as exc: # pragma: no cover - a broken or partial install + # docket.migrate pulls docket.ledger at module scope. Report which import + # failed instead of printing a traceback at someone mid-migration. + print(f"migrate_ledger: cannot import docket.migrate: {exc}", file=sys.stderr) + raise SystemExit(1) from None if __name__ == "__main__": diff --git a/scripts/rescope_ledger.py b/scripts/rescope_ledger.py new file mode 100644 index 0000000..f65ca5a --- /dev/null +++ b/scripts/rescope_ledger.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Repoint this project's ledger scopes at the docket package. + +Scope decides which records a briefing surfaces, so the lib/ to docket/ rename +left 35 scope entries matching nothing. This rewrites them in place after +writing a backup beside the ledger. + +One-off, for this repository. Another project's ledger names its own +directories, so nothing here generalises into a docket subcommand. + +Run from the repository root: + + python3 scripts/rescope_ledger.py --dry-run + python3 scripts/rescope_ledger.py +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from pathlib import Path + +LEDGER = Path(".docket/ledger.jsonl") +BACKUP = Path(".docket/ledger-prepackage-backup.jsonl") + +# Mechanical: the module moved, so the scope follows it. +PATHS = { + "lib/**": "docket/**", + "lib/docket_context.py": "docket/context.py", + "lib/docket_ledger.py": "docket/ledger.py", + "lib/docket_config.py": "docket/config.py", + "lib/docket_migrate.py": "docket/migrate.py", + "lib/docket_rebase.py": "docket/rebase.py", + "lib/docket_update.py": "docket/update.py", +} + +# bin/docket still exists as the launcher, so the right target depends on what +# each record is about. Routed by hand, by reading each one. A record absent +# from this table keeps bin/docket: d80 and d82 both decide what the entry +# point is, which is still this file. +BIN_ROUTES = { + "d12": "docket/env.py", # where the ledger lives by default + "d13": "docket/env.py", # provenance capture + "q15": "docket/env.py", # justification sets + "c20": "docket/env.py", # retirement reporting + "d78": "docket/cli/query.py", # the update notice line + "d36": "docket/cli/graph.py", # graph browsing + "d77": "docket/cli/admin.py", # the update command + "d14": "docs/installation.md", # which harnesses ship config + # Nothing in the CLI: d16 puts defects in GitHub issues, and d43, d44 and + # d45 decide the record and context model, which docket/** already covers. + "d16": None, + "d43": None, + "d44": None, + "d45": None, +} + +# The first run routed d14, d16, d43, d44 and d45 by mechanical assignment +# instead of by subject, so it sent them to the CLI. Re-running is idempotent +# for every other record; these five need the wrong target taken back out. +FIRST_RUN_MISTAKES = { + "d14": "docket/cli/query.py", + "d16": "docket/cli/**", + "d43": "docket/cli/**", + "d44": "docket/cli/**", + "d45": "docket/cli/**", +} + + +def rescope(entry: dict) -> list[str] | None: + """The record's new scope, or None when nothing changes.""" + old = entry.get("scope") or [] + wrong = FIRST_RUN_MISTAKES.get(entry["id"]) + new: list[str] = [] + for item in old: + if item == wrong: + continue + if item in PATHS: + new.append(PATHS[item]) + elif item == "bin/docket": + target = BIN_ROUTES.get(entry["id"], "bin/docket") + if target is not None: + new.append(target) + else: + new.append(item) + # The first run already consumed this record's bin/docket entry, so the + # corrected target has to be added rather than substituted. + if wrong is not None: + target = BIN_ROUTES.get(entry["id"]) + if target is not None: + new.append(target) + # Routing can collide with a scope the record already carries. + new = list(dict.fromkeys(new)) + return new if new != old else None + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dry-run", action="store_true", + help="print the changes and write nothing") + parser.add_argument("--ledger", type=Path, default=LEDGER) + args = parser.parse_args(argv) + + if not args.ledger.exists(): + print(f"rescope: {args.ledger} does not exist", file=sys.stderr) + return 1 + + lines = args.ledger.read_text().splitlines() + out: list[str] = [] + changed = 0 + for line in lines: + entry = json.loads(line) + new = rescope(entry) + if new is None: + out.append(line) + continue + changed += 1 + print(f"{entry['id']:5} {entry.get('scope')} -> {new}") + entry["scope"] = new + out.append(json.dumps(entry, ensure_ascii=False, separators=(",", ":"))) + + unrouted = sorted( + json.loads(line)["id"] for line in lines + if "bin/docket" in (json.loads(line).get("scope") or []) + and json.loads(line)["id"] not in BIN_ROUTES + ) + if unrouted: + print(f"\nkept bin/docket, decided by hand: {', '.join(unrouted)}") + + if not changed: + print("rescope: nothing to change") + return 0 + if args.dry_run: + print(f"\nrescope: {changed} records would change; wrote nothing") + return 0 + + shutil.copy2(args.ledger, BACKUP) + args.ledger.write_text("\n".join(out) + "\n") + print(f"\nrescope: rewrote {changed} records; backup at {BACKUP}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/golden/regenerate.py b/tests/golden/regenerate.py index a6d7c3c..8770c88 100644 --- a/tests/golden/regenerate.py +++ b/tests/golden/regenerate.py @@ -12,7 +12,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from tests.test_context import entry, projected -from lib.docket_context import build_context +from docket.context import build_context SEED = 20260913 diff --git a/tests/test_config.py b/tests/test_config.py index aa5e24e..e77b96f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,7 @@ import unittest from pathlib import Path -from lib.docket_config import DEFAULTS, ConfigError, load, merge +from docket.config import DEFAULTS, ConfigError, load, merge def write(directory, text): @@ -75,7 +75,7 @@ def test_malformed_toml_is_rejected(self): load(home) def test_settings_change_the_briefing_and_the_header(self): - from lib.docket_context import build_context + from docket.context import build_context from tests.test_context import entry, projected records = [ diff --git a/tests/test_context.py b/tests/test_context.py index 1e90356..26b956f 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -2,8 +2,8 @@ import sys import unittest -from lib.docket_context import build_context, build_delta, _term_weights, _blocking_paths -from lib.docket_ledger import make_record, project +from docket.context import build_context, build_delta, _term_weights, _blocking_paths +from docket.ledger import make_record, project def entry( @@ -279,15 +279,15 @@ def test_precise_scope_outranks_a_glob_match(self): def test_recency_orders_records_of_equal_scope_strength(self): records = [ - entry("d1", "decision", "Older renderer decision", choice="x", scope=("lib/**",)), - entry("d2", "decision", "Newer renderer decision", choice="y", scope=("lib/**",)), + entry("d1", "decision", "Older renderer decision", choice="x", scope=("docket/**",)), + entry("d2", "decision", "Newer renderer decision", choice="y", scope=("docket/**",)), ] - rendered = build_context(projected(records), files=("lib/docket_context.py",), ledger="repo") + rendered = build_context(projected(records), files=("docket/context.py",), ledger="repo") self.assertLess(rendered.index("### d2 "), rendered.index("### d1 ")) def test_selection_reason_names_the_score_and_components(self): - records = [entry("d1", "decision", "Renderer budget", choice="y", scope=("lib/**",))] - rendered = build_context(projected(records), files=("lib/docket_context.py",), ledger="repo") + records = [entry("d1", "decision", "Renderer budget", choice="y", scope=("docket/**",))] + rendered = build_context(projected(records), files=("docket/context.py",), ledger="repo") self.assertRegex(rendered, r"selection: score \d+ \| ") self.assertIn("scope=", rendered) @@ -411,7 +411,7 @@ def test_an_exact_file_scope_outranks_an_incidental_query_word(self): self.assertLess(rendered.index("### d1 "), rendered.index("### c2 ")) def test_index_caps_and_counts_the_remainder(self): - from lib.docket_config import merge + from docket.config import merge records = [entry(f"c{n}", "claim", f"Premise {n}", state="accepted") for n in range(1, 101)] settings = merge({"index": {"max_lines": 10}}) @@ -421,7 +421,7 @@ def test_index_caps_and_counts_the_remainder(self): self.assertIn("more; docket list", rendered) def test_the_capped_index_keeps_the_highest_scoring_records(self): - from lib.docket_config import merge + from docket.config import merge records = [entry(f"c{n}", "claim", f"Premise {n}", state="accepted") for n in range(1, 101)] settings = merge({"index": {"max_lines": 5}}) @@ -624,7 +624,7 @@ def ledger(self, count): return projected(records) def test_every_trial_length_matches_the_rendered_length(self): - import lib.docket_context as context + import docket.context as context context._VERIFY_TRIAL = True try: @@ -686,7 +686,7 @@ def test_degree_counts_every_relation_field(self): def test_relation_scan_stays_linear_in_ledger_size(self): # _degree once scanned the whole history per record, so a briefing cost # n^2 relation scans: 1000 records took 5.8s and 10000 did not finish. - import lib.docket_context as context + import docket.context as context records = [entry("c1", "claim", "Root premise", state="accepted")] for index in range(2, 202): diff --git a/tests/test_docket.py b/tests/test_docket.py index ce3c5e6..ebb89e2 100644 --- a/tests/test_docket.py +++ b/tests/test_docket.py @@ -1,20 +1,33 @@ """CLI checks. Run directly with ``python3 tests/test_docket.py``.""" -import importlib.util import json import os import subprocess import sys import tempfile +import types import unittest -from importlib.machinery import SourceFileLoader from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from docket import env as docket_env # noqa: E402 +from docket.cli import graph as cli_graph, query as cli_query # noqa: E402 +from docket.ledger import make_record # noqa: E402 + DOCKET = str(Path(__file__).resolve().parent.parent / "bin" / "docket") -loader = SourceFileLoader("docket_cli", DOCKET) -spec = importlib.util.spec_from_loader("docket_cli", loader) -docket_cli = importlib.util.module_from_spec(spec) -loader.exec_module(docket_cli) + +# The CLI used to load as one module, and these tests patch its attributes. +# Each name now belongs to the module that owns it, and a patch must land +# there: patching a copy bound into another module changes nothing, and these +# tests swap ledger_path precisely to keep themselves off the real ledger. +docket_cli = types.SimpleNamespace( + make_record=make_record, + read=docket_env.read, + retired_by=docket_env.retired_by, + _graph_payload=cli_graph._graph_payload, + auto_scope_files=cli_query.auto_scope_files, +) def run(cwd, *args): @@ -105,17 +118,17 @@ def test_graph_dispatch_preserves_terminal_modes_and_cleans_temp_data(self): docket_cli.make_record("question", "Open", author="test", record_id="q3"), ] ledger.write_text("\n".join(json.dumps(item) for item in entries) + "\n") - originals = (docket_cli.ledger_path, docket_cli.sys.stdin, docket_cli.sys.stdout, - docket_cli.sys.stderr, docket_cli._graph_viewer_path, - docket_cli.subprocess.run) + originals = (docket_env.ledger_path, sys.stdin, sys.stdout, + sys.stderr, cli_graph._graph_viewer_path, + cli_graph.subprocess.run) try: - docket_cli.ledger_path = lambda: ledger - docket_cli.sys.stdin = _TTYBuffer(True) - docket_cli.sys.stdout = _TTYBuffer(True) - docket_cli.sys.stderr = _TTYBuffer(False) + docket_env.ledger_path = lambda: ledger + sys.stdin = _TTYBuffer(True) + sys.stdout = _TTYBuffer(True) + sys.stderr = _TTYBuffer(False) viewer = root / "viewer" viewer.write_text("viewer") - docket_cli._graph_viewer_path = lambda: viewer + cli_graph._graph_viewer_path = lambda: viewer seen = {} def fake_run(argv, **kwargs): @@ -124,65 +137,65 @@ def fake_run(argv, **kwargs): self.assertTrue(Path(argv[2]).exists()) return subprocess.CompletedProcess(argv, 7) - docket_cli.subprocess.run = fake_run + cli_graph.subprocess.run = fake_run args = type("Args", (), {"style": None, "state": None, "kind": None, "find": None, "plain": False, "pretty": False, "interactive": False, "no_interactive": False})() - self.assertEqual(docket_cli.cmd_graph(args), 7) + self.assertEqual(cli_graph.cmd_graph(args), 7) self.assertEqual(seen["payload"]["version"], 2) self.assertFalse(Path(seen["argv"][2]).exists()) pretty = type("Args", (), {"style": None, "state": None, "kind": None, "find": None, "plain": False, "pretty": True, "interactive": False, "no_interactive": False})() - self.assertEqual(docket_cli.cmd_graph(pretty), 7) + self.assertEqual(cli_graph.cmd_graph(pretty), 7) self.assertEqual(seen["argv"][3], "--pretty") def broken_run(*argv, **kwargs): seen["broken"] = argv[0][2] raise OSError("Exec format error") - docket_cli.subprocess.run = broken_run - self.assertEqual(docket_cli.cmd_graph(args), 1) - self.assertIn("could not start interactive viewer", docket_cli.sys.stderr.getvalue()) + cli_graph.subprocess.run = broken_run + self.assertEqual(cli_graph.cmd_graph(args), 1) + self.assertIn("could not start interactive viewer", sys.stderr.getvalue()) self.assertFalse(Path(seen["broken"]).exists()) - docket_cli.sys.stdin = _TTYBuffer(False) - docket_cli.sys.stdout = _TTYBuffer(False) + sys.stdin = _TTYBuffer(False) + sys.stdout = _TTYBuffer(False) called = [] - docket_cli.subprocess.run = lambda *a, **k: called.append((a, k)) - self.assertEqual(docket_cli.cmd_graph(args), 0) + cli_graph.subprocess.run = lambda *a, **k: called.append((a, k)) + self.assertEqual(cli_graph.cmd_graph(args), 0) self.assertFalse(called) - self.assertIn("Root", docket_cli.sys.stdout.getvalue()) + self.assertIn("Root", sys.stdout.getvalue()) - docket_cli.sys.stdin = _TTYBuffer(True) - docket_cli.sys.stdout = _TTYBuffer(True) - docket_cli.sys.stderr = _TTYBuffer(False) - docket_cli._graph_viewer_path = lambda: root / "missing" - self.assertEqual(docket_cli.cmd_graph(args), 0) - self.assertIn("Root", docket_cli.sys.stdout.getvalue()) - self.assertIn("build", docket_cli.sys.stderr.getvalue().lower()) + sys.stdin = _TTYBuffer(True) + sys.stdout = _TTYBuffer(True) + sys.stderr = _TTYBuffer(False) + cli_graph._graph_viewer_path = lambda: root / "missing" + self.assertEqual(cli_graph.cmd_graph(args), 0) + self.assertIn("Root", sys.stdout.getvalue()) + self.assertIn("build", sys.stderr.getvalue().lower()) interactive = type("Args", (), {"style": None, "state": None, "kind": None, "find": None, "plain": False, "pretty": False, "interactive": True, "no_interactive": False})() - self.assertEqual(docket_cli.cmd_graph(interactive), 1) - docket_cli.sys.stdin = _TTYBuffer(False) - self.assertEqual(docket_cli.cmd_graph(interactive), 1) - docket_cli.sys.stdin = _TTYBuffer(True) - docket_cli._graph_viewer_path = lambda: viewer + self.assertEqual(cli_graph.cmd_graph(interactive), 1) + sys.stdin = _TTYBuffer(False) + self.assertEqual(cli_graph.cmd_graph(interactive), 1) + sys.stdin = _TTYBuffer(True) + cli_graph._graph_viewer_path = lambda: viewer def interrupt(*args, **kwargs): seen["interrupt"] = args[0][2] raise KeyboardInterrupt - docket_cli.subprocess.run = interrupt - self.assertEqual(docket_cli.cmd_graph(interactive), 130) + cli_graph.subprocess.run = interrupt + self.assertEqual(cli_graph.cmd_graph(interactive), 130) self.assertFalse(Path(seen["interrupt"]).exists()) finally: - (docket_cli.ledger_path, docket_cli.sys.stdin, docket_cli.sys.stdout, - docket_cli.sys.stderr, docket_cli._graph_viewer_path, - docket_cli.subprocess.run) = originals + (docket_env.ledger_path, sys.stdin, sys.stdout, + sys.stderr, cli_graph._graph_viewer_path, + cli_graph.subprocess.run) = originals def test_graph_flag_conflicts(self): for flags in (("--interactive", "--no-interactive"), @@ -238,19 +251,19 @@ def test_static_graph_shows_blocked_decision_prerequisites(self): depends_on=["c1"], author="test", record_id="d2"), ] ledger.write_text("\n".join(json.dumps(item) for item in entries) + "\n") - original = docket_cli.ledger_path - old_stdout = docket_cli.sys.stdout + original = docket_env.ledger_path + old_stdout = sys.stdout try: - docket_cli.ledger_path = lambda: ledger - docket_cli.sys.stdout = _TTYBuffer(False) + docket_env.ledger_path = lambda: ledger + sys.stdout = _TTYBuffer(False) args = type("Args", (), {"style": "compact", "state": None, "kind": None, "find": None, "plain": True, "pretty": False, "interactive": False, "no_interactive": False})() - self.assertEqual(docket_cli.cmd_graph(args), 0) - self.assertIn("blocked by c1", docket_cli.sys.stdout.getvalue()) + self.assertEqual(cli_graph.cmd_graph(args), 0) + self.assertIn("blocked by c1", sys.stdout.getvalue()) finally: - docket_cli.ledger_path = original - docket_cli.sys.stdout = old_stdout + docket_env.ledger_path = original + sys.stdout = old_stdout class AutoScopeTests(unittest.TestCase): diff --git a/tests/test_guard_ledger.py b/tests/test_guard_ledger.py index 8d37b72..fdd4c40 100644 --- a/tests/test_guard_ledger.py +++ b/tests/test_guard_ledger.py @@ -33,7 +33,7 @@ def test_edit_and_write_on_a_ledger_ask(self): self.assertEqual(decide(tool, {"file_path": path}), "ask") def test_an_ordinary_file_is_untouched(self): - for path in ("lib/docket_ledger.py", "README.md", "docket/notes.jsonl", + for path in ("docket/ledger.py", "README.md", "docket/notes.jsonl", ".docket/notes.txt"): with self.subTest(path=path): self.assertEqual(decide("Edit", {"file_path": path}), "allow") diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 6e67437..6184f63 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -6,8 +6,8 @@ from concurrent.futures import ThreadPoolExecutor from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) -import docket_ledger as ledger +sys.path.insert(0, str(Path(__file__).parent.parent)) +import docket.ledger as ledger class LedgerTests(unittest.TestCase): diff --git a/tests/test_migration.py b/tests/test_migration.py index 1e9810b..c8c0980 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -11,7 +11,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from lib import docket_migrate +from docket import migrate as docket_migrate ROOT = Path(__file__).resolve().parent.parent @@ -334,7 +334,7 @@ def test_a_settled_record_superseding_an_open_record_becomes_an_answer(self): path = Path(work) / "ledger.jsonl" write_jsonl(path, source) docket_migrate.migrate_in_place(path) - from lib.docket_ledger import read as ledger_read, project + from docket.ledger import read as ledger_read, project entries = ledger_read(path) projected = {r["id"]: r for r in project(entries)} self.assertEqual(projected["q19"]["state"], "resolved") @@ -429,7 +429,7 @@ def test_conversion_replaces_the_file_and_keeps_the_original(self): self.assertTrue(all(r["schema"] == 2 for r in converted)) def test_the_result_reads_back_through_the_ledger_reader(self): - from lib.docket_ledger import read as ledger_read + from docket.ledger import read as ledger_read with tempfile.TemporaryDirectory() as work: path = Path(work) / "ledger.jsonl" write_jsonl(path, self.legacy()) diff --git a/tests/test_rebase.py b/tests/test_rebase.py index 3b501dd..bd131bb 100644 --- a/tests/test_rebase.py +++ b/tests/test_rebase.py @@ -3,8 +3,8 @@ import unittest from pathlib import Path -from lib.docket_ledger import make_record, read -from lib.docket_rebase import RebaseError, common_prefix, renumber +from docket.ledger import make_record, read +from docket.rebase import RebaseError, common_prefix, renumber def claim(ident, text, **kwargs): diff --git a/tests/test_update.py b/tests/test_update.py index c2ebdbe..69dc308 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -1,6 +1,5 @@ import argparse import contextlib -import importlib.util import io import json import os @@ -8,12 +7,11 @@ import tempfile import unittest import unittest.mock -from importlib.machinery import SourceFileLoader from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -import docket_update as up +import docket.update as up class VersionCompare(unittest.TestCase): @@ -285,10 +283,8 @@ def setUp(self): self.addCleanup(self.tmp.cleanup) os.environ["XDG_STATE_HOME"] = self.tmp.name self.addCleanup(os.environ.pop, "XDG_STATE_HOME", None) - loader = SourceFileLoader("docket_cli_update_line", str(DOCKET)) - spec = importlib.util.spec_from_loader("docket_cli_update_line", loader) - self.docket_cli = importlib.util.module_from_spec(spec) - loader.exec_module(self.docket_cli) + from docket.cli import query + self.docket_cli = query def test_due_check_forks_instead_of_fetching_inline(self): def boom(url=up.RELEASES_URL): @@ -413,10 +409,8 @@ def setUp(self): self.addCleanup(self.tmp.cleanup) os.environ["XDG_STATE_HOME"] = self.tmp.name self.addCleanup(os.environ.pop, "XDG_STATE_HOME", None) - loader = SourceFileLoader("docket_cli_update", str(DOCKET)) - spec = importlib.util.spec_from_loader("docket_cli_update", loader) - self.docket_cli = importlib.util.module_from_spec(spec) - loader.exec_module(self.docket_cli) + from docket.cli import admin + self.docket_cli = admin original_call = self.docket_cli.subprocess.call self.addCleanup(setattr, self.docket_cli.subprocess, "call", original_call) @@ -455,7 +449,9 @@ def test_source_shape_runs_the_installer_with_checkout(self): self.docket_cli.subprocess.call = lambda command, **k: recorded.append(command) or 0 rc = self.docket_cli.cmd_update(self.args()) self.assertEqual(rc, 0) - root = self.docket_cli.Path(self.docket_cli.__file__).resolve().parent.parent + # docket.ROOT, never __file__: cmd_update lives two levels below the + # checkout now, so parent.parent from here names docket/. + root = self.docket_cli.ROOT self.assertEqual(recorded, [[ self.docket_cli.sys.executable, str(root / "installer" / "install.py"), "--checkout", str(root), "--update",