@try-works/dsh-recursive-mode 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +33 -12
  17. package/lib/index.js +10017 -3969
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/skills.d.ts +70 -0
  46. package/lib/status.d.ts +53 -1
  47. package/lib/teams-loop.d.ts +91 -2
  48. package/lib/training.d.ts +211 -0
  49. package/lib/ts-lint.d.ts +15 -0
  50. package/lib/types.d.ts +48 -0
  51. package/lib/workflow-audit.d.ts +207 -0
  52. package/package.json +31 -31
  53. package/preset/recursive.patch.yml +312 -0
  54. package/scripts/e2e-run.mjs +51 -0
  55. package/scripts/link-dsh.mjs +233 -0
  56. package/scripts/live/fake-llm.mjs +150 -0
  57. package/scripts/live-session-plugin.mjs +179 -0
  58. package/scripts/live-session-stock.mjs +106 -0
  59. package/scripts/live-session.mjs +139 -0
  60. package/skills/recursive-mode/SKILL.md +66 -0
  61. package/src/client/derive.ts +18 -2
  62. package/src/closeout-report.ts +274 -0
  63. package/src/closeout-standards.ts +102 -0
  64. package/src/closeout.ts +39 -2
  65. package/src/commands.ts +116 -4
  66. package/src/config.ts +113 -0
  67. package/src/delegation.ts +336 -18
  68. package/src/enforcement.ts +262 -72
  69. package/src/errors.ts +197 -0
  70. package/src/git-context.ts +33 -2
  71. package/src/guard-log.ts +134 -0
  72. package/src/handoff.ts +62 -0
  73. package/src/hooks.ts +316 -0
  74. package/src/identity.ts +230 -0
  75. package/src/index.ts +394 -20
  76. package/src/job-log.ts +112 -0
  77. package/src/jobs-runner.ts +222 -0
  78. package/src/json-safe.ts +75 -0
  79. package/src/lock.ts +153 -16
  80. package/src/memory-feedback.ts +185 -0
  81. package/src/memory-select.ts +187 -0
  82. package/src/memory.ts +309 -0
  83. package/src/model-inventory.ts +196 -0
  84. package/src/phase-graph.ts +191 -0
  85. package/src/phase-rules.ts +236 -0
  86. package/src/plan-gate.ts +111 -0
  87. package/src/policy-globs.ts +636 -0
  88. package/src/policy-write.ts +210 -0
  89. package/src/policy.ts +70 -5
  90. package/src/recursive_ask.tool.ts +276 -0
  91. package/src/recursive_audit_team.tool.ts +7 -3
  92. package/src/recursive_closeout.tool.ts +36 -35
  93. package/src/recursive_delegate.tool.ts +194 -0
  94. package/src/recursive_init.tool.ts +4 -3
  95. package/src/recursive_lint.tool.ts +81 -6
  96. package/src/recursive_lock.tool.ts +21 -4
  97. package/src/recursive_phase.tool.ts +3 -2
  98. package/src/recursive_preview.tool.ts +142 -0
  99. package/src/recursive_review.tool.ts +190 -0
  100. package/src/recursive_scratch.tool.ts +5 -4
  101. package/src/recursive_status.tool.ts +3 -2
  102. package/src/recursive_worktree.tool.ts +6 -5
  103. package/src/result-cap.ts +130 -0
  104. package/src/review-round.ts +335 -0
  105. package/src/review.ts +17 -3
  106. package/src/role-route.ts +230 -0
  107. package/src/router.ts +128 -2
  108. package/src/runtime.ts +968 -39
  109. package/src/settlement.ts +355 -0
  110. package/src/skills-phase.ts +143 -0
  111. package/src/skills.ts +151 -0
  112. package/src/snapshot.ts +39 -8
  113. package/src/status.ts +209 -4
  114. package/src/teams-loop.ts +223 -9
  115. package/src/training.ts +565 -0
  116. package/src/ts-lint.ts +38 -4
  117. package/src/types.ts +51 -0
  118. package/src/workflow-audit.ts +288 -0
  119. package/scripts/install-preset.cmd +0 -7
  120. package/scripts/install-preset.js +0 -101
@@ -0,0 +1,111 @@
1
+ /**
2
+ * T17 — the phase dependency as a GRAPH, not a flat sequence.
3
+ *
4
+ * WHY. Three core queries in this plugin are graph operations being run over a linear array:
5
+ * `getStaleDownstreamPhases` is a REACHABILITY query, `getPrerequisites` is an IN-EDGE query, and
6
+ * `getNextLegalPhase` is a topological walk. An array can answer all three only while the dependency
7
+ * happens to be linear — and the moment an artifact depends on something LATER than itself (an
8
+ * addendum written to close an upstream gap), the array model answers *wrongly* rather than not at
9
+ * all. That is the case this module exists to get right.
10
+ *
11
+ * ⚠ A HAND-ROLLED GRAPH, DELIBERATELY. The item's note is explicit: Effect ships a stable,
12
+ * runtime-free `Graph` doing exactly this, and adopting it would trade this plugin's zero-dependency
13
+ * posture and its parity goldens for a few hundred lines it does not need. The queries below are
14
+ * small because the graph is small.
15
+ *
16
+ * ⚠ BACK-EDGES ARE FIRST-CLASS, NOT AN ERROR. An `upstream-gap` addendum legitimately points at a
17
+ * LATER artifact: the gap was discovered downstream and must be closed upstream. A depth-first walk
18
+ * with a VISITED set answers reachability over such a graph; a walk without one loops forever, and an
19
+ * array cannot express the edge at all.
20
+ */
21
+ /** One node: an artifact on disk, or one being asked about before it exists. */
22
+ export interface PhaseNode {
23
+ /** Artifact file name, the node's identity. */
24
+ id: string;
25
+ /** Position in the canonical sequence — used for ordering, NOT as the dependency model. */
26
+ index: number;
27
+ /** Whether the artifact is on disk. A queried, absent node emits no prerequisite edge. */
28
+ present?: boolean;
29
+ /** Whether the artifact is locked, when the caller knows. */
30
+ locked?: boolean;
31
+ }
32
+ /** One directed edge: `to` depends on `from`. */
33
+ export interface PhaseEdge {
34
+ from: string;
35
+ to: string;
36
+ /** `prerequisite` — an earlier artifact that must be locked first. `addendum` — a cited dependency. */
37
+ kind: 'prerequisite' | 'addendum';
38
+ }
39
+ export interface PhaseGraph {
40
+ nodes: PhaseNode[];
41
+ edges: PhaseEdge[];
42
+ }
43
+ /**
44
+ * Build the graph for one run.
45
+ *
46
+ * `sequence` is the canonical phase order; `present` is what exists on disk, so a phase that was
47
+ * never created is not a node and cannot be waited on forever. `addenda` are the cited dependencies a
48
+ * document declares — including ones pointing FORWARD, which is what makes the graph a graph.
49
+ */
50
+ export declare function buildPhaseGraph(input: {
51
+ sequence: readonly string[];
52
+ present: readonly string[];
53
+ locked?: readonly string[];
54
+ /** Declared dependencies: `from` must be settled before `to`. Order is irrelevant. */
55
+ citations?: ReadonlyArray<{
56
+ from: string;
57
+ to: string;
58
+ }>;
59
+ /**
60
+ * Artifacts to answer ABOUT, whether or not they exist yet.
61
+ *
62
+ * ⚠ THIS EXISTS BECAUSE OF A MEASURED PARITY FAILURE, and it is the item's central modelling gap:
63
+ * with nodes limited to what is present, a query about an artifact **that does not exist yet**
64
+ * found no in-edges and answered `[]` — but checking prerequisites **before creating** the artifact
65
+ * is the NORMAL case, so that made every pre-write check silently vacuous. A queried node is
66
+ * therefore a node for EDGE TARGETS ONLY: it emits no outgoing prerequisite edge, because an
67
+ * artifact that does not exist cannot block anything.
68
+ */
69
+ queried?: readonly string[];
70
+ }): PhaseGraph;
71
+ /** The nodes that must be settled before `id` — the IN-EDGE query. */
72
+ export declare function prerequisitesOf(graph: PhaseGraph, id: string): string[];
73
+ /** The artifacts that depend, directly, on `id` — the OUT-EDGE query. */
74
+ export declare function dependentsOf(graph: PhaseGraph, id: string): string[];
75
+ /**
76
+ * Everything reachable downstream of `id` — the REACHABILITY query behind `getStaleDownstreamPhases`.
77
+ *
78
+ * ⚠ The VISITED set is load-bearing, not defensive: a back-edge makes the graph cyclic, and a walk
79
+ * without a visited set recurses forever on exactly the input this item exists to support.
80
+ */
81
+ export declare function reachableFrom(graph: PhaseGraph, id: string): string[];
82
+ /**
83
+ * The next phase a run may work on.
84
+ *
85
+ * ⚠ THIS MODELS THE SHIPPED RULES EXACTLY, because `lock.ts` delegates to it and `lock.parity.spec.ts`
86
+ * is the proof. Three of them are easy to miss and are asserted in the spec:
87
+ * 1. a LOCKED node is skipped;
88
+ * 2. a node that is **absent and declared OPTIONAL** is skipped — an optional phase nobody created
89
+ * must not stop the run;
90
+ * 3. if the first node that survives 1 and 2 has a prerequisite that is not LOCKED, the answer is
91
+ * **`null`, NOT the next node** — the run is BLOCKED, and returning the node after it would
92
+ * silently skip a dependency.
93
+ *
94
+ * ⚠ RULE 3 IS WHERE A GRAPH EARNS ITS KEEP, and it is invisible in a linear array: with a back-edge
95
+ * (an `upstream-gap` addendum citing a LATER artifact), an EARLY unlocked node can be blocked by a
96
+ * LATER one — so "continue to the next node" and "blocked, report null" give different answers, and
97
+ * only the second is correct.
98
+ *
99
+ * Nodes must cover the whole sequence for this query (an absent, required phase is a legitimate
100
+ * answer); `queried` is how the caller supplies them.
101
+ */
102
+ export declare function nextLegalPhase(graph: PhaseGraph, options?: {
103
+ optional?: ReadonlySet<string>;
104
+ }): string | null;
105
+ /**
106
+ * Whether the graph has a back-edge — an edge pointing at a LATER phase than its source.
107
+ *
108
+ * Reported rather than hidden: a back-edge is legitimate (an upstream gap), and it is exactly the
109
+ * shape a linear model cannot represent. A caller that wants to warn about it should be able to ask.
110
+ */
111
+ export declare function backEdges(graph: PhaseGraph): PhaseEdge[];
@@ -1,11 +1,4 @@
1
- /**
2
- * Phase rules (R5): canonical parity port of lint-recursive-run.py's
3
- * get_artifact_required_sections + workflow/audit constants. Single source of
4
- * truth for per-phase required section headings and audit extras; consumed by
5
- * initRun templates, renderRecursivePolicy, and the pre-step lint-rules
6
- * injection. Values are byte-identical to the canonical linter (recursive-
7
- * mode-audit-v2).
8
- */
1
+ import { tddEvidenceVerdict as tddEvidenceMatch, type ToolPolicy, type ToolPolicyRule, type Verdict } from './policy-globs.ts';
9
2
  export declare const CURRENT_WORKFLOW_PROFILE = "recursive-mode-audit-v2";
10
3
  export declare const STRICT_WORKFLOW_PROFILE = "recursive-mode-audit-v1";
11
4
  export declare const COMPAT_WORKFLOW_PROFILE = "memory-phase8";
@@ -57,3 +50,69 @@ export declare function phaseRulesFor(fileName: string, workflowProfile?: string
57
50
  * from the prior inline build so r5-parity.spec.ts stays green.
58
51
  */
59
52
  export declare function phaseLintRulesMessage(fileName: string, workflowProfile?: string): string;
53
+ export declare function policyVerdictRank(verdict: Verdict): number;
54
+ /**
55
+ * The phase number an artifact belongs to: the leading digits of its canonical
56
+ * filename (`03-implementation-summary.md` -> `3`). Returns `''` when the
57
+ * filename carries no phase number, so a caller can skip the baseline rather
58
+ * than guess.
59
+ */
60
+ export declare function phaseNumberForArtifact(fileName: string): string;
61
+ /**
62
+ * PER-PHASE BASELINE (plan §4 T16). Additive, phase-scoped rules that can only
63
+ * NARROW the global policy. Three honest limits, stated here rather than hidden:
64
+ *
65
+ * 1. The two structures the plan names — phase 6 "writes only under
66
+ * `.recursive/DECISIONS.md`" and phase 8 "only under `.recursive/memory/**`"
67
+ * — as ABSOLUTE path scopes would also forbid the normal artifact edits of
68
+ * those phases, so they are implemented as the narrower rules that bite
69
+ * without blocking the phase's own work: no source-tree writes in phases
70
+ * 6-8 (by then the repo changes are done and the run documents its
71
+ * decisions/state/memory), and the memory planes are read-only in phases
72
+ * 1-2 and 6-7. `deny` on `write` in phase 6 is also deliberately absent,
73
+ * because phase 6's own artifact is `<run>/06-decisions-update.md` and a
74
+ * blanket write denial would make the phase uncompletable.
75
+ * 2. Narrowing is enforced MECHANICALLY in withPhaseBaseline, not by trusting
76
+ * this table: a phase rule whose verdict is ranked above the strictest
77
+ * global verdict for the same pattern — which would widen `ask` to `allow`,
78
+ * or soften a global `deny` — is DROPPED before it can be evaluated.
79
+ * 3. No rule below uses the bare catch-all `*`. A phase rule of that shape
80
+ * would outrank every specific global rule at once (precedence is
81
+ * specificity-first), including `recursive_lock*`, and would deny the phase
82
+ * its own tools. The write-tool family is spelled `write*`.
83
+ *
84
+ * Phase 3's TDD-evidence rule is the one the plan states verbatim: a phase-3
85
+ * lock is denied until RED + GREEN evidence exists. It is reached for the phase
86
+ * whose artifact is the current one, i.e. the phase-3 lock itself — before that
87
+ * point the earlier phases are still unlocked, so the global lock-order rule
88
+ * already refuses.
89
+ */
90
+ export declare function phaseBaselineRules(fileName: string): ToolPolicyRule[];
91
+ /**
92
+ * Resolve a tool-target path to an absolute path. Mirrors enforcement.ts's
93
+ * resolution rules: an absolute path stays as it is; a relative path resolves
94
+ * against the worktree root. `null` when the value cannot be a path at all.
95
+ */
96
+ export declare function resolveFrom(worktreeRoot: string, target: string): string | null;
97
+ /** The tool-target path of a call (same key order enforcement.ts uses). */
98
+ export declare function policyTargetPath(args: Record<string, unknown>): string | null;
99
+ /**
100
+ * TDD evidence predicate for a phase-3 lock: `deny` when the artifact declares
101
+ * `TDD Mode: strict` without both RED and GREEN evidence, `null` otherwise
102
+ * (nothing to say — the global rules still apply). Lives in `policy-globs.ts`
103
+ * beside the built-in list, which carries the same `tdd-evidence` guard rule.
104
+ */
105
+ export declare const tddEvidenceVerdict: typeof tddEvidenceMatch;
106
+ /**
107
+ * Compose the effective policy for a phase: the phase baseline rules FIRST (so
108
+ * a narrowing rule is reached before the global rule it tightens), then the
109
+ * global rules.
110
+ *
111
+ * NARROWING IS MECHANICAL, not a convention this table is trusted to respect. A
112
+ * baseline rule whose verdict is ranked ABOVE the strictest global verdict for
113
+ * the same pattern is DROPPED before it can be evaluated, so no per-phase
114
+ * baseline can turn a global `deny` into an `allow` (or an `ask` into an
115
+ * `allow`). A baseline rule for a pattern the global policy does not mention is
116
+ * always kept: it can only add a restriction.
117
+ */
118
+ export declare function withPhaseBaseline(policy: ToolPolicy, fileName: string, runDir?: string): ToolPolicy;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * T13 — planMode for the discovery phases, and the gate out of them.
3
+ *
4
+ * WHY. Requirements / AS-IS / TO-BE-Plan are NON-MUTATING discovery. Running them under the
5
+ * harness's planMode enforces "plan before implement" at the harness level instead of by
6
+ * convention: a phase that cannot write cannot quietly start implementing while it is still
7
+ * deciding what to implement. Convention is what fails at 2am; a mode is what holds.
8
+ *
9
+ * ⚠ THE PHASE-FORM TRAP, which is why this module exists rather than a one-line comparison.
10
+ * This codebase names a phase THREE ways — `'2'`, `'02'`, and the artifact filename
11
+ * `'02-to-be-plan.md'` — and a mapping that understood only one of them would return `false`
12
+ * for the others. That failure is SILENT and in the dangerous direction: it turns the
13
+ * non-mutating guarantee OFF for exactly the phases that need it. So the index is parsed from
14
+ * the leading numeric run, and every form lands on the same answer.
15
+ *
16
+ * ⚠ AN UNRECOGNISED PHASE ANSWERS `false`, deliberately. Claiming plan mode for a phase we
17
+ * cannot identify would be guessing about the one property this module exists to guarantee;
18
+ * the safe reading of "I do not know what this is" is "do not assert a mode about it". The
19
+ * caller that needs certainty has {@link phaseIndexOf} and can refuse instead.
20
+ */
21
+ /**
22
+ * The numeric index of a phase, from any of the forms this codebase uses, or `null` when the
23
+ * input names no phase at all.
24
+ *
25
+ * Takes the leading numeric run, so `'2'`, `'02'`, `' 2 '` and `'02-to-be-plan.md'` all give
26
+ * `2`, and `'01.5-root-cause.md'` gives `1.5` — the fractional sub-phase keeps its position
27
+ * between 01 and 02, which is what makes `phaseUsesPlanMode` include it.
28
+ */
29
+ export declare function phaseIndexOf(phase: string): number | null;
30
+ /**
31
+ * The exclusive upper bound of the discovery block: phases below this are non-mutating.
32
+ *
33
+ * 3 = implementation. Everything strictly below it (0 requirements, 1 AS-IS, 1.5 root cause,
34
+ * 2 TO-BE plan) is discovery, which is the item's "00-02" plus the sub-phase that sits inside
35
+ * that range.
36
+ */
37
+ export declare const IMPLEMENTATION_PHASE_INDEX = 3;
38
+ /** True when the phase is discovery and must run non-mutating. */
39
+ export declare function phaseUsesPlanMode(phase: string): boolean;
40
+ /**
41
+ * True when a transition LEAVES the discovery block — the plan gate.
42
+ *
43
+ * The gate is on the DESTINATION, not the distance: `02 -> 03` and `02 -> 04` both need an
44
+ * approved plan, because skipping ahead does not make the plan less necessary. A transition
45
+ * that stays inside discovery (0 -> 1) is not gated, and one that starts after the boundary
46
+ * (3 -> 4) is not either — the plan was already required to get there.
47
+ */
48
+ export declare function planGateRequired(from: string, to: string): boolean;
49
+ /** A one-line, board-facing statement of what the phase owes, for a phase rules section. */
50
+ export declare function describePlanMode(phase: string): string;
51
+ /**
52
+ * T13 — should `exit_plan_mode` be allowed, given the phase the run is WAITING on?
53
+ *
54
+ * The run's "next legal phase" is exactly the right input: while it is still a discovery
55
+ * phase, the plan is not finished and leaving plan mode would start implementing on an
56
+ * unfinished plan; once it is an implementation phase, discovery is done and the gate is
57
+ * open. So the gate needs no separate state — it reads the state the run already keeps, and
58
+ * that is why it cannot drift from the workflow.
59
+ *
60
+ * ⚠ A NULL next phase means "nothing is pending", which is NOT the same as "discovery is
61
+ * done". It allows the exit, because refusing forever on a completed or unrecognised run
62
+ * would make plan mode a trap rather than a gate — and the reason is stated so a reader can
63
+ * tell the two cases apart.
64
+ */
65
+ export declare function planGateForExit(nextPhase: string | null): {
66
+ allow: boolean;
67
+ reason: string;
68
+ };
@@ -0,0 +1,222 @@
1
+ /** The three verdicts a rule may carry. */
2
+ export type Verdict = 'allow' | 'deny' | 'ask';
3
+ /**
4
+ * The policy engine's own decision shape (no `warn`/`transition` — those are the
5
+ * guard's, see enforcement.ts). `rule` is the `label` of the rule that decided
6
+ * the call, so a verdict is traceable to an auditable line in the policy file;
7
+ * it is absent when the no-match default decided.
8
+ */
9
+ export interface Decision {
10
+ kind: Verdict;
11
+ reason?: string;
12
+ rule?: string;
13
+ }
14
+ /**
15
+ * Extra facts a rule predicate may need. `args` is always the tool call's
16
+ * arguments; the run coordinates are present only when the caller has them
17
+ * (a guard call from a real session does; a pure policy unit test need not).
18
+ */
19
+ export interface ToolPolicyContext {
20
+ args: Record<string, unknown>;
21
+ runDir?: string;
22
+ runId?: string;
23
+ worktreeRoot?: string;
24
+ }
25
+ /**
26
+ * What a rule predicate returns when the condition it guards DOES apply:
27
+ * the verdict, plus an optional `detail` appended to the rule's own `reason`.
28
+ * The rule keeps the static, auditable sentence (the same sentence the policy
29
+ * file and the built-in list carry, so the two cannot drift); the predicate adds
30
+ * only the per-call particular — which artifact blocked the lock, and its status.
31
+ *
32
+ * `null` means the condition does NOT apply, and the engine falls through to the
33
+ * NEXT rule. That fall-through is how an id-glob rule such as `write` can guard
34
+ * write tools without deciding for an ordinary write that violates nothing
35
+ * (which a bare pattern match would otherwise deny).
36
+ */
37
+ export interface ToolPolicyPredicateMatch {
38
+ verdict: Verdict;
39
+ detail?: string;
40
+ }
41
+ export type ToolPolicyPredicate = (id: string, args: Record<string, unknown>, ctx: ToolPolicyContext) => ToolPolicyPredicateMatch | null;
42
+ export interface ToolPolicyRule {
43
+ pattern: string;
44
+ verdict: Verdict;
45
+ reason: string;
46
+ /** The guard's machine-readable rule label for a decision this rule makes. */
47
+ label?: string;
48
+ predicate?: ToolPolicyPredicate;
49
+ }
50
+ /**
51
+ * The `defaults` block of a policy file. `no_match_verdict` is documented for a
52
+ * human reader and may only ever be `"ask"`: the no-match default is a property
53
+ * of the engine, not a knob, because a configurable one would let a policy file
54
+ * silently allow (or deny) every unlisted tool.
55
+ */
56
+ export interface ToolPolicyFileDefaults {
57
+ no_match_verdict?: 'ask';
58
+ }
59
+ export interface ToolPolicy {
60
+ version: number;
61
+ description?: string;
62
+ rules: ToolPolicyRule[];
63
+ }
64
+ /** Where the shipped default policy lives, relative to the worktree root. */
65
+ export declare const TOOL_POLICY_RELATIVE_PATH = ".recursive/config/recursive-permissions.json";
66
+ /** Absolute path of the policy file for a worktree. */
67
+ export declare function toolPolicyPath(worktreeRoot: string): string;
68
+ /**
69
+ * The syntax error in `pattern`, or null when it is well formed.
70
+ *
71
+ * Beyond the character rule there are two STRUCTURAL rules, because a pattern
72
+ * that a human reads as "the worker tools" must not be a silent no-op:
73
+ * - a pattern may not END with a separator (`worker::`, `a.`, `a:`) — a
74
+ * dangling separator names nothing, so it would sit in the file looking like
75
+ * a rule while matching no tool id at all;
76
+ * - a pattern may not START with one (`.foo`), for the same reason.
77
+ * `worker::*` is unaffected: it ends with the wildcard, which is how the id-glob
78
+ * form is written.
79
+ */
80
+ export declare function patternSyntaxError(pattern: unknown): string | null;
81
+ export declare function isValidGlobPattern(pattern: unknown): boolean;
82
+ /**
83
+ * Glob-match a tool id. Every `*` matches strictly up to the next literal, so
84
+ * the matcher is anchored at both ends: `worker::*` does NOT match `xworker::y`.
85
+ * Single-restart backtracking, no recursion.
86
+ */
87
+ export declare function globMatch(pattern: string, id: string): boolean;
88
+ /** The first syntax/contract problem in a policy, in rule order. */
89
+ export declare function firstPolicyDefect(policy: ToolPolicy): string | null;
90
+ /** The fail-closed decision: everything is denied, and the defect is named. */
91
+ export declare function failClosedDecision(defect: string): Decision;
92
+ /** True when the pattern is the bare catch-all (`*`), which is least specific. */
93
+ export declare function isCatchAllPattern(pattern: unknown): boolean;
94
+ /**
95
+ * Evaluation PRECEDENCE: SPECIFICITY FIRST, THEN FILE ORDER.
96
+ *
97
+ * T16 asks for "deny wins over allow" AND for "first-match-wins", which cannot
98
+ * both hold under adversarial ordering. The reconciliation — and the semantics
99
+ * this engine implements — is:
100
+ *
101
+ * 1. Among the rules whose pattern MATCHES the call, a rule with a MORE
102
+ * SPECIFIC pattern outranks one with a less specific pattern. So a
103
+ * catch-all `*` allow can never shield an id from a specific `deny` written
104
+ * BELOW it.
105
+ * 2. Among equally specific matching rules, the EARLIER rule in the file wins.
106
+ * 3. A rule whose predicate ABSTAINS (`null`) does not participate at all: it
107
+ * is as if the rule were not in the file for this call, so the next rule in
108
+ * precedence order decides.
109
+ *
110
+ * Why not plain first-match-wins: it would make "deny wins over allow" merely a
111
+ * property of how the file happens to be ordered. A hand-edit that put a
112
+ * catch-all allow at the top would then silently disable every deny in the file,
113
+ * which is precisely the failure the phrase exists to prevent.
114
+ *
115
+ * Specificity tiers, most to least:
116
+ * 3 an EXACT id (`write`) — the narrowest thing a pattern can name;
117
+ * 2 a PREFIX glob (`worker::*`, `recursive_*`) — still names a family;
118
+ * 1 a glob with a wildcard in the middle (`*_write`) — matched anywhere in
119
+ * the id, so it is narrower than the bare catch-all but names no family;
120
+ * 0 the bare catch-all (`*`).
121
+ * These are TIERS, not a fine-grained metric: patterns within a tier are
122
+ * "equally specific" and are decided by file order.
123
+ */
124
+ export declare function patternSpecificity(pattern: string): number;
125
+ /**
126
+ * Evaluate a tool call against a policy. PURE: no filesystem, no config, no
127
+ * caching. The loader resolves WHICH policy applies; this decides what it says,
128
+ * using the precedence above.
129
+ */
130
+ export declare function evaluateToolPolicy(policy: ToolPolicy, id: string, args?: Record<string, unknown>, ctx?: ToolPolicyContext): Decision;
131
+ export interface ToolPolicyLoadResult {
132
+ ok: boolean;
133
+ /** `file` when a policy file was found, `builtin` when it was ABSENT. */
134
+ source: 'file' | 'builtin';
135
+ policy: ToolPolicy;
136
+ /** Absolute path of the policy file, when one was found. */
137
+ path?: string;
138
+ /** Why the file could not be used, when `ok` is false. */
139
+ error?: string;
140
+ }
141
+ /** Wire the built-in default policy used when no policy file exists. */
142
+ export declare function setBuiltInToolPolicy(factory: () => ToolPolicy): void;
143
+ /** The built-in policy in force for the absent-file case. */
144
+ export declare function builtInToolPolicy(): ToolPolicy;
145
+ /** Tool names the locked-artifact write rule guards. */
146
+ export declare const WRITE_TOOL_NAMES: Set<string>;
147
+ /** Tool names the monotonic lock-order rule guards. */
148
+ export declare const LOCK_TOOL_NAMES: Set<string>;
149
+ /**
150
+ * The BUILT-IN default rule list — the pre-T16 guard behaviour expressed as
151
+ * data:
152
+ *
153
+ * `recursive_lock*` -> the monotonic lock-order denial;
154
+ * the write-tool ids -> the locked-artifact write denial;
155
+ * `*` -> allow, so an ordinary tool is not turned into an `ask`.
156
+ *
157
+ * Every `deny` precedes the `allow`, which is what makes "deny wins over allow" a
158
+ * fact about the list rather than a hope. The predicates return `null` when
159
+ * their condition does not apply, so a matched-but-clean write falls through to
160
+ * the allow instead of being denied by the pattern alone.
161
+ *
162
+ * `.recursive/config/recursive-permissions.json` mirrors this list in the same
163
+ * order with the same reasons: a reviewer reads either one and predicts the same
164
+ * verdict.
165
+ */
166
+ export declare function builtInToolPolicyRules(): ToolPolicyRule[];
167
+ /** The built-in default policy (the absent-file fallback), as a policy object. */
168
+ export declare function builtInToolPolicyDefault(): ToolPolicy;
169
+ /**
170
+ * Attach the CODE-side condition to a rule that was authored in JSON.
171
+ *
172
+ * This is the seam that makes a policy file honest. JSON cannot carry a
173
+ * function, so a `deny` rule read from a file has no way to know whether its
174
+ * condition actually holds — and an unguarded `deny` is an UNCONDITIONAL denial,
175
+ * i.e. a policy file that denies locks whose prerequisites are met and writes to
176
+ * artifacts that are not locked. The pattern identifies the rule's subject, so
177
+ * the pattern is what selects the condition:
178
+ *
179
+ * `recursive_lock*` -> the monotonic lock-order condition;
180
+ * any write-tool id -> the locked-artifact condition.
181
+ *
182
+ * A pattern the code knows nothing about keeps NO condition and is therefore an
183
+ * absolute verdict — which is the honest reading of a rule like
184
+ * `{"pattern": "run_code", "verdict": "deny"}` with no lock condition behind it.
185
+ */
186
+ export declare function attachPolicyPredicate(rule: ToolPolicyRule): ToolPolicyRule;
187
+ /**
188
+ * TDD evidence predicate for a phase-3 lock (the `tdd-evidence` guard rule): the
189
+ * denial match when the artifact declares `TDD Mode: strict` without both RED
190
+ * and GREEN evidence, `null` otherwise — nothing to say, so the global lock rules
191
+ * still apply. `phase-rules.ts` uses it for the phase-3 baseline.
192
+ */
193
+ export declare function tddEvidenceVerdict(artifact: string, runDir: string | undefined): ToolPolicyPredicateMatch | null;
194
+ /**
195
+ * Read and validate the policy file. NEVER throws: an unreadable or malformed
196
+ * file yields `ok: false` plus a fail-closed policy, so the guard reasons about
197
+ * it instead of crashing the tools/pre-execute listener.
198
+ */
199
+ export declare function loadToolPolicyFile(worktreeRoot: string): ToolPolicyLoadResult;
200
+ /**
201
+ * Parse policy JSON into a policy. A parse/validation failure does NOT throw and
202
+ * does NOT return an empty rule list (which would mean "ask everything" and hide
203
+ * the defect) — it returns a single deny-all rule carrying the defect, plus
204
+ * `ok: false` and the same defect as `error`, so a caller can tell a healthy
205
+ * policy from a fail-closed one WITHOUT having to compare reasons. Both the
206
+ * malformed-JSON case and the PRESENT-but-empty case are `ok: false`.
207
+ */
208
+ export declare function parseToolPolicy(raw: string, path?: string): ToolPolicyParseResult;
209
+ export interface ToolPolicyParseResult {
210
+ ok: boolean;
211
+ policy: ToolPolicy;
212
+ /** The defect the policy failed closed on, when `ok` is false. */
213
+ error?: string;
214
+ }
215
+ /** A one-rule policy denying everything, with the defect as its reason. */
216
+ export declare function failClosedPolicy(defect: string): ToolPolicy;
217
+ /**
218
+ * Resolve the effective policy for a worktree: the file when present (even when
219
+ * broken — a present-but-invalid file fails closed), the built-in list when
220
+ * absent. Thin, so callers never re-implement the absent/present distinction.
221
+ */
222
+ export declare function resolveToolPolicy(worktreeRoot: string): ToolPolicy;
@@ -0,0 +1,42 @@
1
+ /** Which level a write targets. The narrowest matching level wins at resolution time. */
2
+ export type SelectionScope = 'general' | 'phase' | 'role';
3
+ /** What to write. Every field is optional; omitting one leaves that field alone. */
4
+ export interface SelectionPatch {
5
+ scope: SelectionScope;
6
+ /** Required for `phase` and `role`. */
7
+ phase?: string;
8
+ /** Required for `role`. */
9
+ role?: string;
10
+ /** The SUBAGENT provider — who creates the child. */
11
+ provider?: string;
12
+ /** The model id. */
13
+ model?: string;
14
+ /** The LLM provider — who SERVES the model. Not the same thing as `provider` above. */
15
+ modelProvider?: string;
16
+ /** Remove the named fields instead of setting them. With no field names, removes the whole level. */
17
+ clear?: boolean;
18
+ }
19
+ export interface SelectionWriteResult {
20
+ path: string;
21
+ /** One sentence, naming the level, what changed, and the rule that history is untouched. */
22
+ message: string;
23
+ /** Whether anything actually changed — a no-op write still rewrites the file, but says it was already so. */
24
+ changed: boolean;
25
+ }
26
+ /** Apply one patch to a policy object IN PLACE. Returns whether anything changed, or a refusal reason. */
27
+ export declare function applySelectionPatch(policy: Record<string, unknown>, patch: SelectionPatch): {
28
+ changed: boolean;
29
+ } | {
30
+ refused: string;
31
+ };
32
+ /**
33
+ * Write a selection patch to a workspace's policy file.
34
+ *
35
+ * Returns a refusal instead of throwing when the patch is incomplete, and a message that always states the rule
36
+ * the objective names: this applies to future delegations and never rewrites a run's recorded history.
37
+ */
38
+ export declare function writePolicySelection(root: string, patch: SelectionPatch, options?: {
39
+ path?: string;
40
+ }): SelectionWriteResult | {
41
+ refused: string;
42
+ };
package/lib/policy.d.ts CHANGED
@@ -10,3 +10,42 @@ export interface PolicyContext {
10
10
  * Render the current-phase contract. Empty string when no run is active.
11
11
  */
12
12
  export declare function renderRecursivePolicy(context: PolicyContext | null): string;
13
+ /**
14
+ * T22 — the STABLE contract: the part of the policy section that does not depend on the phase.
15
+ *
16
+ * WHY A SPLIT AT ALL, given the item's own premise check: the mechanism this was modelled on
17
+ * (iii's `system_sections` + `cache_boundary` + `cache_intent.surface_digest`) does **not** exist in
18
+ * DSH as a plugin-visible seam — the harness's only cache concepts live in the provider layer
19
+ * (`llm-pi-ai` accepts prompt-cache MARKERS and a retention preference), and whether any provider
20
+ * caches the prefix is **provider-side and unverified**. The item **withdrew its "largest cost
21
+ * lever" label for exactly that reason**, and this module keeps only what survives the check:
22
+ * **a byte-identical prefix is a PRECONDITION for any provider-side caching**, whatever the provider
23
+ * does with it — and a stable prefix costs nothing to produce.
24
+ *
25
+ * ⚠ THE CONTRACT DEPENDS ON THE CONFIG, NOT ON THE PHASE, and that distinction is the whole point:
26
+ * the enforcement modes and the lock rules are fixed for a RUN, so they belong in the prefix; the
27
+ * current phase, its required sections and its gate checklist change per phase and belong in the
28
+ * tail. A prefix that varied with the phase would be stable in name only.
29
+ */
30
+ export declare function renderStableContract(config?: EnforcementConfig): string;
31
+ /**
32
+ * T22 — a LOCAL identifier for the contract, not a cache directive.
33
+ *
34
+ * The item's rescope is explicit that the digest is worth computing as a **local identifier**: it is
35
+ * stable within a run and changes when the contract changes, which is what makes "did the contract
36
+ * change under me?" answerable from the prompt alone. It claims **nothing** about provider caching —
37
+ * a digest that implied one would be the withdrawn label wearing a hash.
38
+ */
39
+ export declare function contractDigest(config?: EnforcementConfig): string;
40
+ /**
41
+ * T22 — the per-phase TAIL: everything that legitimately changes between phases.
42
+ *
43
+ * Kept as its own name so the split is a fact in the code rather than a convention: a caller that
44
+ * wants a cacheable prefix takes {@link renderStableContract} and puts this after it.
45
+ */
46
+ export declare function renderPhaseTail(phase: {
47
+ label: string;
48
+ phaseName: string;
49
+ status: string;
50
+ key?: string;
51
+ } | null): string;
@@ -0,0 +1,88 @@
1
+ import type { RecursiveRuntime } from './runtime.ts';
2
+ /** The identifiers the workflow uses for its three human gates. */
3
+ export declare const ASK_GATE_IDS: readonly ["tdd-mode", "qa-signoff", "gate-block"];
4
+ export type AskGateId = (typeof ASK_GATE_IDS)[number];
5
+ /** The plugin's own documented bounds — see the module comment on why these are not a claimed mirror. */
6
+ export declare const MAX_HEADER_CHARS = 12;
7
+ export declare const MAX_LABEL_CHARS = 30;
8
+ export declare const MAX_DESCRIPTION_CHARS = 200;
9
+ export interface AskOption {
10
+ label: string;
11
+ description?: string;
12
+ }
13
+ /** A structured question, in the shape the ask flow renders as a card. */
14
+ export interface AskQuestion {
15
+ id: string;
16
+ header: string;
17
+ question: string;
18
+ options: AskOption[];
19
+ }
20
+ /** One of the three gates, as data: what to ask, what the options mean, and what the answer means. */
21
+ export interface AskGate {
22
+ id: AskGateId;
23
+ header: string;
24
+ question: string;
25
+ options: AskOption[];
26
+ /** The artifact marker name the answer is written under. */
27
+ marker: string;
28
+ }
29
+ export declare const ASK_GATES: Record<AskGateId, AskGate>;
30
+ /** Thrown for a request the ask flow would refuse; carries the FAILING FIELD PATH. */
31
+ export declare class AskValidationError extends Error {
32
+ readonly field: string;
33
+ constructor(field: string, message: string);
34
+ }
35
+ /**
36
+ * Validate a question and return it unchanged.
37
+ *
38
+ * The FIELD PATH travels in the error because a caller fixing an over-long header needs to know
39
+ * WHICH field — a bare "too long" for a request with four fields is a puzzle, not a diagnostic.
40
+ */
41
+ export declare function validateAskQuestion(question: AskQuestion): AskQuestion;
42
+ /** Build the question for a gate, validated. */
43
+ export declare function buildAskQuestion(gateId: AskGateId): AskQuestion;
44
+ /**
45
+ * Validate an answer against its gate.
46
+ *
47
+ * An answer that is not one of the offered labels is REFUSED rather than recorded: a marker saying
48
+ * `TDD Mode: maybe` would look like a decision and be a transcription error.
49
+ */
50
+ export declare function validateAskAnswer(gateId: AskGateId, answer: string): string;
51
+ /** The durable marker an accepted answer is written back as — a fact, not a chat aside. */
52
+ export declare function answerMarker(gateId: AskGateId, answer: string): string;
53
+ /**
54
+ * One-ask-per-step guard.
55
+ *
56
+ * A step that asks twice yields two cards for one decision and the second answer silently wins —
57
+ * so the second ask is REFUSED, naming the gate already asked this step.
58
+ */
59
+ export declare function createAskLedger(): {
60
+ claim(gateId: AskGateId): void;
61
+ asked(): AskGateId[];
62
+ };
63
+ /** Which artifact each gate's answer belongs in, when the caller does not name one. */
64
+ export declare const GATE_DEFAULT_ARTIFACT: Record<AskGateId, string>;
65
+ /**
66
+ * FU-7 — THE CALL POINTS: which gate, if any, a phase ENTRY still owes.
67
+ *
68
+ * ⚠ THE ARTIFACT IS THE RECORD, so no ledger is needed to ask once: if the document already carries the
69
+ * gate's marker line, the question has been answered and is not asked again. That is the same
70
+ * "once per run at entry" property T29 got from riding an existing gate — one mechanism, not two.
71
+ *
72
+ * ⚠ AND AN UNKNOWN PHASE OWES NOTHING. Returning a gate for a phase that has no such decision would ask a
73
+ * person a question the workflow does not act on, which is worse than not asking at all.
74
+ */
75
+ export declare function pendingGateFor(artifactFile: string, artifactText: string | null): AskGateId | null;
76
+ /**
77
+ * T23 — the tool.
78
+ *
79
+ * TWO BRANCHES, and the split is the point: called WITHOUT an answer it ASKS (returning the validated
80
+ * question, which the host renders as a card), and called WITH one it RECORDS — validating the label,
81
+ * writing the marker into the artifact, and reporting the line it wrote. A tool that did both in one
82
+ * call would have to invent the answer.
83
+ *
84
+ * ⚠ THE WRITE-BACK IS A MARKER LINE, REPLACED IN PLACE when the artifact already carries one. A
85
+ * second `TDD Mode:` line would leave two answers to one question and make "what was decided?"
86
+ * depend on which a reader found first.
87
+ */
88
+ export declare function createRecursiveAskTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -1,6 +1,6 @@
1
1
  import type { RecursiveRuntime } from './runtime.ts';
2
2
  /**
3
- * `recursive_closeout` — scaffold a Phase 4/5/6/7/8 closeout receipt under the
3
+ * `recursive_closeout` — scaffold a Phase 0-8 closeout receipt under the
4
4
  * SESSION's workspace only (R1 workspace-scoping invariant). The run is resolved
5
5
  * via the session agent's cwd -> workspace registry; a runId outside the current
6
6
  * workspace is rejected.