@try-works/dsh-recursive-mode 0.3.1 → 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 (117) 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 +32 -12
  17. package/lib/index.js +9819 -3858
  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/status.d.ts +53 -1
  46. package/lib/teams-loop.d.ts +91 -2
  47. package/lib/training.d.ts +211 -0
  48. package/lib/ts-lint.d.ts +15 -0
  49. package/lib/types.d.ts +48 -0
  50. package/lib/workflow-audit.d.ts +207 -0
  51. package/package.json +29 -30
  52. package/preset/recursive.patch.yml +312 -0
  53. package/scripts/e2e-run.mjs +51 -0
  54. package/scripts/link-dsh.mjs +233 -0
  55. package/scripts/live/fake-llm.mjs +150 -0
  56. package/scripts/live-session-plugin.mjs +179 -0
  57. package/scripts/live-session-stock.mjs +106 -0
  58. package/scripts/live-session.mjs +139 -0
  59. package/src/client/derive.ts +18 -2
  60. package/src/closeout-report.ts +274 -0
  61. package/src/closeout-standards.ts +102 -0
  62. package/src/closeout.ts +39 -2
  63. package/src/commands.ts +116 -4
  64. package/src/config.ts +113 -0
  65. package/src/delegation.ts +336 -18
  66. package/src/enforcement.ts +262 -72
  67. package/src/errors.ts +197 -0
  68. package/src/git-context.ts +33 -2
  69. package/src/guard-log.ts +134 -0
  70. package/src/handoff.ts +62 -0
  71. package/src/hooks.ts +316 -0
  72. package/src/identity.ts +230 -0
  73. package/src/index.ts +385 -20
  74. package/src/job-log.ts +112 -0
  75. package/src/jobs-runner.ts +222 -0
  76. package/src/json-safe.ts +75 -0
  77. package/src/lock.ts +153 -16
  78. package/src/memory-feedback.ts +185 -0
  79. package/src/memory-select.ts +187 -0
  80. package/src/memory.ts +309 -0
  81. package/src/model-inventory.ts +196 -0
  82. package/src/phase-graph.ts +191 -0
  83. package/src/phase-rules.ts +236 -0
  84. package/src/plan-gate.ts +111 -0
  85. package/src/policy-globs.ts +636 -0
  86. package/src/policy-write.ts +210 -0
  87. package/src/policy.ts +70 -5
  88. package/src/recursive_ask.tool.ts +276 -0
  89. package/src/recursive_audit_team.tool.ts +7 -3
  90. package/src/recursive_closeout.tool.ts +36 -35
  91. package/src/recursive_delegate.tool.ts +194 -0
  92. package/src/recursive_init.tool.ts +4 -3
  93. package/src/recursive_lint.tool.ts +81 -6
  94. package/src/recursive_lock.tool.ts +21 -4
  95. package/src/recursive_phase.tool.ts +3 -2
  96. package/src/recursive_preview.tool.ts +142 -0
  97. package/src/recursive_review.tool.ts +190 -0
  98. package/src/recursive_scratch.tool.ts +5 -4
  99. package/src/recursive_status.tool.ts +3 -2
  100. package/src/recursive_worktree.tool.ts +6 -5
  101. package/src/result-cap.ts +130 -0
  102. package/src/review-round.ts +335 -0
  103. package/src/review.ts +17 -3
  104. package/src/role-route.ts +230 -0
  105. package/src/router.ts +128 -2
  106. package/src/runtime.ts +968 -39
  107. package/src/settlement.ts +355 -0
  108. package/src/skills-phase.ts +143 -0
  109. package/src/snapshot.ts +39 -8
  110. package/src/status.ts +209 -4
  111. package/src/teams-loop.ts +223 -9
  112. package/src/training.ts +565 -0
  113. package/src/ts-lint.ts +38 -4
  114. package/src/types.ts +51 -0
  115. package/src/workflow-audit.ts +288 -0
  116. package/scripts/install-preset.cmd +0 -7
  117. package/scripts/install-preset.js +0 -101
@@ -0,0 +1,196 @@
1
+ /**
2
+ * ⚠ FU-19 — THE PROVIDER AND MODEL INVENTORY, RESOLVED FROM DSH ITSELF.
3
+ *
4
+ * The requirement, in the user's words: *"provider and model inventory should be resolved from dsh itself, we only
5
+ * concern ourselves with what is configured inside dsh."* So this module does not keep a list, does not ship a
6
+ * table of known models, and does not guess. It asks the host what it has.
7
+ *
8
+ * ## Where the truth lives (measured, not assumed)
9
+ *
10
+ * `packages/llm/llm/src/index.ts` does `super(ctx, 'llm')`, so the service a plugin reaches is `ctx.llm`:
11
+ *
12
+ * - `ctx.llm.listProviders()` → the providers DSH has
13
+ * - `await ctx.llm.listModels(providerId)` → that provider's models
14
+ * - `await ctx.llm.resolveModelInfo(providerId, id)` → model detail (reasoning efforts, defaults)
15
+ *
16
+ * `api/session-controller/src/catalog.ts` builds the BROWSER model catalog from exactly that trio, so a plugin
17
+ * reading the same calls is reading what the UI shows. The service is resolved as an OPTIONAL seam, like every
18
+ * other host service this plugin uses.
19
+ *
20
+ * ## ⚠ TWO DIFFERENT THINGS ARE CALLED "PROVIDER", AND CONFLATING THEM WAS A REAL DEFECT IN THIS FEATURE
21
+ *
22
+ * - a **SUBAGENT provider** creates the child — `spawn`, `fork` — and comes from `ctx.subagents.list()`;
23
+ * - an **LLM provider** serves the model — `deepseek-official` — and comes from `ctx.llm.listProviders()`.
24
+ *
25
+ * A policy field called plain `provider` could mean either, and a user setting it would have no way to know which.
26
+ * So this module deals ONLY in the second kind, and the policy names it `modelProvider` for exactly that reason.
27
+ *
28
+ * ## ⚠ THE THREE-STATE VERDICT, WHICH IS THE POINT OF THE WHOLE MODULE
29
+ *
30
+ * A checker that answers only yes/no forces a lie in one direction or the other: either an unverifiable choice is
31
+ * reported as fine (a silent pass, which this project has fixed ten times over) or it is reported as wrong (a
32
+ * refusal to do something the user asked for, on the strength of a service that merely was not mounted). The
33
+ * verdict therefore has three states, and `unverified` is not a synonym for either:
34
+ *
35
+ * - `available` — the model is in the inventory; the choice is real
36
+ * - `missing` — the inventory answered and does NOT have it; the caller should say so, and must NOT swap it
37
+ * - `unverified` — there was no inventory to ask; nothing is claimed, and nothing is replaced
38
+ */
39
+ import type { SubagentProviderLike } from './router.ts'
40
+
41
+ /**
42
+ * The structural view of `ctx.llm` this module needs. Structural rather than imported, like every other seam in
43
+ * this plugin: the harness packages are peer dependencies, and a plugin that imported their types would not load
44
+ * on a host that mounts a compatible service under its own class.
45
+ */
46
+ export interface LlmInventoryLike {
47
+ listProviders(): ReadonlyArray<{ id: string }>
48
+ listModels(providerId: string): Promise<ReadonlyArray<{ id: string }>> | ReadonlyArray<{ id: string }>
49
+ }
50
+
51
+ /** One provider and the models it advertises. */
52
+ export interface InventoryProvider {
53
+ id: string
54
+ models: string[]
55
+ }
56
+
57
+ /** The inventory, when there was one to read. */
58
+ export interface InventoryAvailable {
59
+ available: true
60
+ providers: InventoryProvider[]
61
+ /** One sentence naming what was read, for the record. */
62
+ note: string
63
+ }
64
+
65
+ /** The inventory was not readable, and why — never an empty inventory pretending to be one. */
66
+ export interface InventoryUnavailable {
67
+ available: false
68
+ reason: string
69
+ }
70
+
71
+ export type ModelInventory = InventoryAvailable | InventoryUnavailable
72
+
73
+ /**
74
+ * Read the provider/model inventory from the host.
75
+ *
76
+ * Never throws: a provider whose model list fails is recorded with a NAME and no models rather than dropping the
77
+ * whole inventory, because "this provider would not answer" and "there are no providers" are different facts.
78
+ */
79
+ export async function describeInventory(llm: LlmInventoryLike | null | undefined): Promise<ModelInventory> {
80
+ if (llm === null || llm === undefined) {
81
+ return {
82
+ available: false,
83
+ reason: 'no llm service is mounted, so the provider and model inventory could not be read from DSH',
84
+ }
85
+ }
86
+ let providerIds: string[]
87
+ try {
88
+ providerIds = llm.listProviders().map((entry) => entry.id).filter((id) => typeof id === 'string' && id !== '')
89
+ } catch (err) {
90
+ return { available: false, reason: 'llm.listProviders() failed: ' + (err instanceof Error ? err.message : String(err)) }
91
+ }
92
+
93
+ const providers: InventoryProvider[] = []
94
+ for (const id of providerIds) {
95
+ try {
96
+ const models = await llm.listModels(id)
97
+ providers.push({ id, models: models.map((model) => model.id).filter((modelId) => typeof modelId === 'string' && modelId !== '') })
98
+ } catch {
99
+ // Named, kept, and empty: a provider that would not answer is not the same as a provider with no models.
100
+ providers.push({ id, models: [] })
101
+ }
102
+ }
103
+ return {
104
+ available: true,
105
+ providers,
106
+ note: 'read from DSH: ' + providers.length + ' provider(s), '
107
+ + providers.reduce((sum, provider) => sum + provider.models.length, 0) + ' model(s)',
108
+ }
109
+ }
110
+
111
+ /** Which LLM provider advertises a model id — the lookup that makes `modelProvider` optional for the user. */
112
+ export function findModelProvider(inventory: ModelInventory, modelId: string): string | null {
113
+ if (!inventory.available) return null
114
+ const wanted = modelId.trim()
115
+ if (wanted === '') return null
116
+ for (const provider of inventory.providers) {
117
+ if (provider.models.includes(wanted)) return provider.id
118
+ }
119
+ return null
120
+ }
121
+
122
+ export type ChoiceVerdict = 'available' | 'missing' | 'unverified'
123
+
124
+ export interface ChoiceCheck {
125
+ verdict: ChoiceVerdict
126
+ /** One sentence, always present, always saying which of the three states this is and why. */
127
+ reason: string
128
+ }
129
+
130
+ /**
131
+ * Check a model choice against the inventory — and say `unverified` rather than guessing when there is none.
132
+ *
133
+ * ⚠ IT NEVER CHANGES THE CHOICE. A caller that receives `missing` must report it; it must not quietly substitute
134
+ * a model the inventory does have, because silently running a child on a different model than the user asked for
135
+ * is precisely the failure this whole feature exists to prevent.
136
+ */
137
+ export function checkModelChoice(
138
+ inventory: ModelInventory,
139
+ modelId: string,
140
+ modelProvider?: string | null,
141
+ ): ChoiceCheck {
142
+ const model = modelId.trim()
143
+ if (model === '') {
144
+ return { verdict: 'unverified', reason: 'no model was chosen, so the child inherits the session default' }
145
+ }
146
+ if (!inventory.available) {
147
+ return {
148
+ verdict: 'unverified',
149
+ reason: 'model ' + model + ' is UNVERIFIED: ' + inventory.reason
150
+ + ' — it was neither accepted as available nor replaced with another',
151
+ }
152
+ }
153
+ const named = typeof modelProvider === 'string' && modelProvider.trim() !== '' ? modelProvider.trim() : null
154
+ if (named !== null) {
155
+ const provider = inventory.providers.find((entry) => entry.id === named)
156
+ if (provider === undefined) {
157
+ return { verdict: 'missing', reason: 'model provider ' + named + ' is not one DSH has: ' + providerIdsText(inventory) }
158
+ }
159
+ return provider.models.includes(model)
160
+ ? { verdict: 'available', reason: 'model ' + model + ' is available from ' + named }
161
+ : { verdict: 'missing', reason: 'provider ' + named + ' does not advertise model ' + model }
162
+ }
163
+ const owner = findModelProvider(inventory, model)
164
+ return owner === null
165
+ ? { verdict: 'missing', reason: 'no provider DSH has advertises model ' + model + ': ' + providerIdsText(inventory) }
166
+ : { verdict: 'available', reason: 'model ' + model + ' is available from ' + owner }
167
+ }
168
+
169
+ /** The provider ids, for a message that tells the caller what IS available instead of only what is not. */
170
+ export function providerIdsText(inventory: ModelInventory): string {
171
+ if (!inventory.available) return '(inventory unavailable)'
172
+ if (inventory.providers.length === 0) return '(no providers)'
173
+ return inventory.providers.map((provider) => provider.id).join(', ')
174
+ }
175
+
176
+ /**
177
+ * Which subagent providers the host actually offers — the OTHER kind of provider, kept adjacent on purpose so the
178
+ * two are never confused in a message.
179
+ */
180
+ export function subagentProviderNames(seam: { list?: () => unknown } | null | undefined): string[] {
181
+ if (seam === null || seam === undefined || typeof seam.list !== 'function') return []
182
+ try {
183
+ const listed = seam.list()
184
+ return Array.isArray(listed) ? listed.filter((entry): entry is string => typeof entry === 'string') : []
185
+ } catch {
186
+ return []
187
+ }
188
+ }
189
+
190
+ /** The declared capabilities of a subagent provider, or null when it is not registered. */
191
+ export function capabilitiesOf(
192
+ providers: Record<string, SubagentProviderLike>,
193
+ name: string,
194
+ ): SubagentProviderLike['capabilities'] | null {
195
+ return providers[name]?.capabilities ?? null
196
+ }
@@ -0,0 +1,191 @@
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
+
22
+ /** One node: an artifact on disk, or one being asked about before it exists. */
23
+ export interface PhaseNode {
24
+ /** Artifact file name, the node's identity. */
25
+ id: string
26
+ /** Position in the canonical sequence — used for ordering, NOT as the dependency model. */
27
+ index: number
28
+ /** Whether the artifact is on disk. A queried, absent node emits no prerequisite edge. */
29
+ present?: boolean
30
+ /** Whether the artifact is locked, when the caller knows. */
31
+ locked?: boolean
32
+ }
33
+
34
+ /** One directed edge: `to` depends on `from`. */
35
+ export interface PhaseEdge {
36
+ from: string
37
+ to: string
38
+ /** `prerequisite` — an earlier artifact that must be locked first. `addendum` — a cited dependency. */
39
+ kind: 'prerequisite' | 'addendum'
40
+ }
41
+
42
+ export interface PhaseGraph {
43
+ nodes: PhaseNode[]
44
+ edges: PhaseEdge[]
45
+ }
46
+
47
+ /**
48
+ * Build the graph for one run.
49
+ *
50
+ * `sequence` is the canonical phase order; `present` is what exists on disk, so a phase that was
51
+ * never created is not a node and cannot be waited on forever. `addenda` are the cited dependencies a
52
+ * document declares — including ones pointing FORWARD, which is what makes the graph a graph.
53
+ */
54
+ export function buildPhaseGraph(input: {
55
+ sequence: readonly string[]
56
+ present: readonly string[]
57
+ locked?: readonly string[]
58
+ /** Declared dependencies: `from` must be settled before `to`. Order is irrelevant. */
59
+ citations?: ReadonlyArray<{ from: string; to: string }>
60
+ /**
61
+ * Artifacts to answer ABOUT, whether or not they exist yet.
62
+ *
63
+ * ⚠ THIS EXISTS BECAUSE OF A MEASURED PARITY FAILURE, and it is the item's central modelling gap:
64
+ * with nodes limited to what is present, a query about an artifact **that does not exist yet**
65
+ * found no in-edges and answered `[]` — but checking prerequisites **before creating** the artifact
66
+ * is the NORMAL case, so that made every pre-write check silently vacuous. A queried node is
67
+ * therefore a node for EDGE TARGETS ONLY: it emits no outgoing prerequisite edge, because an
68
+ * artifact that does not exist cannot block anything.
69
+ */
70
+ queried?: readonly string[]
71
+ }): PhaseGraph {
72
+ const presentSet = new Set(input.present)
73
+ const lockedSet = new Set(input.locked ?? [])
74
+ const queriedSet = new Set(input.queried ?? [])
75
+ const nodes: PhaseNode[] = input.sequence
76
+ .map((id, index) => ({ id, index }))
77
+ .filter((node) => presentSet.has(node.id) || queriedSet.has(node.id))
78
+ .map((node) => ({
79
+ ...node,
80
+ // `present` is what makes the source rule below possible: a queried-but-absent node is a
81
+ // question, not a dependency.
82
+ present: presentSet.has(node.id),
83
+ locked: lockedSet.has(node.id),
84
+ }))
85
+
86
+ const indexOf = new Map(input.sequence.map((id, index) => [id, index]))
87
+ const ids = new Set(nodes.filter((node) => node.present === true).map((node) => node.id))
88
+ const edges: PhaseEdge[] = []
89
+
90
+ // PREREQUISITE edges: every earlier PRESENT artifact blocks this one. Restricted to what exists so a
91
+ // missing phase cannot hold a run hostage, and emitted only FROM present nodes so a queried one
92
+ // cannot appear as anybody's prerequisite.
93
+ for (const node of nodes) {
94
+ for (const other of nodes) {
95
+ if (other.present !== true) continue
96
+ if (other.index < node.index) edges.push({ from: other.id, to: node.id, kind: 'prerequisite' })
97
+ }
98
+ }
99
+
100
+ // ADDENDUM edges: the declared citations, in either direction. Deduplicated so a citation that
101
+ // coincides with a prerequisite is ONE edge, not two.
102
+ const seen = new Set(edges.map((edge) => edge.from + '\u0000' + edge.to))
103
+ for (const citation of input.citations ?? []) {
104
+ if (!ids.has(citation.from) || !ids.has(citation.to)) continue
105
+ const key = citation.from + '\u0000' + citation.to
106
+ if (seen.has(key)) continue
107
+ seen.add(key)
108
+ edges.push({ from: citation.from, to: citation.to, kind: 'addendum' })
109
+ }
110
+
111
+ return { nodes, edges }
112
+ }
113
+
114
+ /** The nodes that must be settled before `id` — the IN-EDGE query. */
115
+ export function prerequisitesOf(graph: PhaseGraph, id: string): string[] {
116
+ return graph.edges.filter((edge) => edge.to === id).map((edge) => edge.from)
117
+ }
118
+
119
+ /** The artifacts that depend, directly, on `id` — the OUT-EDGE query. */
120
+ export function dependentsOf(graph: PhaseGraph, id: string): string[] {
121
+ return graph.edges.filter((edge) => edge.from === id).map((edge) => edge.to)
122
+ }
123
+
124
+ /**
125
+ * Everything reachable downstream of `id` — the REACHABILITY query behind `getStaleDownstreamPhases`.
126
+ *
127
+ * ⚠ The VISITED set is load-bearing, not defensive: a back-edge makes the graph cyclic, and a walk
128
+ * without a visited set recurses forever on exactly the input this item exists to support.
129
+ */
130
+ export function reachableFrom(graph: PhaseGraph, id: string): string[] {
131
+ const seen = new Set<string>()
132
+ const queue = [id]
133
+ while (queue.length > 0) {
134
+ const current = queue.shift() as string
135
+ for (const next of dependentsOf(graph, current)) {
136
+ if (seen.has(next)) continue
137
+ seen.add(next)
138
+ queue.push(next)
139
+ }
140
+ }
141
+ seen.delete(id)
142
+ return graph.nodes.map((node) => node.id).filter((nodeId) => seen.has(nodeId))
143
+ }
144
+
145
+ /**
146
+ * The next phase a run may work on.
147
+ *
148
+ * ⚠ THIS MODELS THE SHIPPED RULES EXACTLY, because `lock.ts` delegates to it and `lock.parity.spec.ts`
149
+ * is the proof. Three of them are easy to miss and are asserted in the spec:
150
+ * 1. a LOCKED node is skipped;
151
+ * 2. a node that is **absent and declared OPTIONAL** is skipped — an optional phase nobody created
152
+ * must not stop the run;
153
+ * 3. if the first node that survives 1 and 2 has a prerequisite that is not LOCKED, the answer is
154
+ * **`null`, NOT the next node** — the run is BLOCKED, and returning the node after it would
155
+ * silently skip a dependency.
156
+ *
157
+ * ⚠ RULE 3 IS WHERE A GRAPH EARNS ITS KEEP, and it is invisible in a linear array: with a back-edge
158
+ * (an `upstream-gap` addendum citing a LATER artifact), an EARLY unlocked node can be blocked by a
159
+ * LATER one — so "continue to the next node" and "blocked, report null" give different answers, and
160
+ * only the second is correct.
161
+ *
162
+ * Nodes must cover the whole sequence for this query (an absent, required phase is a legitimate
163
+ * answer); `queried` is how the caller supplies them.
164
+ */
165
+ export function nextLegalPhase(
166
+ graph: PhaseGraph,
167
+ options: { optional?: ReadonlySet<string> } = {},
168
+ ): string | null {
169
+ const optional = options.optional ?? new Set<string>()
170
+ const locked = new Set(graph.nodes.filter((node) => node.locked === true).map((node) => node.id))
171
+ const ordered = [...graph.nodes].sort((a, b) => a.index - b.index)
172
+ for (const node of ordered) {
173
+ if (locked.has(node.id)) continue
174
+ if (node.present !== true && optional.has(node.id)) continue
175
+ const blocked = prerequisitesOf(graph, node.id).some((from) => !locked.has(from))
176
+ if (blocked) return null
177
+ return node.id
178
+ }
179
+ return null
180
+ }
181
+
182
+ /**
183
+ * Whether the graph has a back-edge — an edge pointing at a LATER phase than its source.
184
+ *
185
+ * Reported rather than hidden: a back-edge is legitimate (an upstream gap), and it is exactly the
186
+ * shape a linear model cannot represent. A caller that wants to warn about it should be able to ask.
187
+ */
188
+ export function backEdges(graph: PhaseGraph): PhaseEdge[] {
189
+ const indexOf = new Map(graph.nodes.map((node) => [node.id, node.index]))
190
+ return graph.edges.filter((edge) => (indexOf.get(edge.to) ?? 0) < (indexOf.get(edge.from) ?? 0))
191
+ }
@@ -6,6 +6,11 @@
6
6
  * injection. Values are byte-identical to the canonical linter (recursive-
7
7
  * mode-audit-v2).
8
8
  */
9
+ import { join, resolve, sep } from 'node:path'
10
+ import {
11
+ tddEvidenceVerdict as tddEvidenceMatch,
12
+ type ToolPolicy, type ToolPolicyRule, type ToolPolicyContext, type Verdict,
13
+ } from './policy-globs.ts'
9
14
 
10
15
  export const CURRENT_WORKFLOW_PROFILE = 'recursive-mode-audit-v2'
11
16
  export const STRICT_WORKFLOW_PROFILE = 'recursive-mode-audit-v1'
@@ -303,3 +308,234 @@ export function phaseLintRulesMessage(fileName: string, workflowProfile: string
303
308
  ]
304
309
  return lines.join('\n')
305
310
  }
311
+
312
+ /* -------------------------------------------------------------------------- */
313
+ /* T16 — per-phase baseline tool policy (NARROWING ONLY) */
314
+ /* -------------------------------------------------------------------------- */
315
+
316
+ /**
317
+ * How restrictive a verdict is, lower = stricter. `deny` < `ask` < `allow`.
318
+ * This ordering IS the narrowing rule: a phase baseline may replace a global
319
+ * verdict with a lower-ranked one, and never with a higher-ranked one. It also
320
+ * explains the engine's "deny wins over allow" claim in rank terms — deny is
321
+ * the minimum, so nothing a phase adds can soften it.
322
+ */
323
+ const VERDICT_RANK: Record<Verdict, number> = { deny: 0, ask: 1, allow: 2 }
324
+
325
+ export function policyVerdictRank(verdict: Verdict): number {
326
+ return VERDICT_RANK[verdict]
327
+ }
328
+
329
+ /**
330
+ * The phase number an artifact belongs to: the leading digits of its canonical
331
+ * filename (`03-implementation-summary.md` -> `3`). Returns `''` when the
332
+ * filename carries no phase number, so a caller can skip the baseline rather
333
+ * than guess.
334
+ */
335
+ export function phaseNumberForArtifact(fileName: string): string {
336
+ const match = /^(\d+)/.exec(fileName)
337
+ if (!match) return ''
338
+ return String(Number(match[1]))
339
+ }
340
+
341
+ function isUnder(root: string, target: string): boolean {
342
+ const rootAbs = resolve(root)
343
+ const targetAbs = resolve(target)
344
+ const prefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep
345
+ return targetAbs !== rootAbs && targetAbs.startsWith(prefix)
346
+ }
347
+
348
+ /** True when the path is inside the run tree this phase is writing. */
349
+ function insideRunTree(path: string, runDir: string | undefined): boolean {
350
+ if (!runDir) return false
351
+ return isUnder(runDir, path)
352
+ }
353
+
354
+ /**
355
+ * The absolute target of a call, or `'unresolvable'` when it cannot be placed.
356
+ * An unresolvable target FAILS CLOSED in every baseline below: "we could not
357
+ * tell where this write lands" is not a reason to allow it. A caller with no
358
+ * worktree root can still place an ABSOLUTE target; a relative one it cannot.
359
+ */
360
+ function baselineTarget(args: Record<string, unknown>, ctx: ToolPolicyContext): string | 'unresolvable' {
361
+ const target = policyTargetPath(args)
362
+ if (!target) return 'unresolvable'
363
+ const normalized = target.replace(/\\/g, '/')
364
+ if (!ctx.worktreeRoot && !isAbsolutePath(normalized)) return 'unresolvable'
365
+ const abs = resolveFrom(ctx.worktreeRoot ?? '/', target)
366
+ return abs ?? 'unresolvable'
367
+ }
368
+
369
+ function isAbsolutePath(normalized: string): boolean {
370
+ return /^[A-Za-z]:\//.test(normalized) || normalized.startsWith('/')
371
+ }
372
+
373
+ /**
374
+ * PER-PHASE BASELINE (plan §4 T16). Additive, phase-scoped rules that can only
375
+ * NARROW the global policy. Three honest limits, stated here rather than hidden:
376
+ *
377
+ * 1. The two structures the plan names — phase 6 "writes only under
378
+ * `.recursive/DECISIONS.md`" and phase 8 "only under `.recursive/memory/**`"
379
+ * — as ABSOLUTE path scopes would also forbid the normal artifact edits of
380
+ * those phases, so they are implemented as the narrower rules that bite
381
+ * without blocking the phase's own work: no source-tree writes in phases
382
+ * 6-8 (by then the repo changes are done and the run documents its
383
+ * decisions/state/memory), and the memory planes are read-only in phases
384
+ * 1-2 and 6-7. `deny` on `write` in phase 6 is also deliberately absent,
385
+ * because phase 6's own artifact is `<run>/06-decisions-update.md` and a
386
+ * blanket write denial would make the phase uncompletable.
387
+ * 2. Narrowing is enforced MECHANICALLY in withPhaseBaseline, not by trusting
388
+ * this table: a phase rule whose verdict is ranked above the strictest
389
+ * global verdict for the same pattern — which would widen `ask` to `allow`,
390
+ * or soften a global `deny` — is DROPPED before it can be evaluated.
391
+ * 3. No rule below uses the bare catch-all `*`. A phase rule of that shape
392
+ * would outrank every specific global rule at once (precedence is
393
+ * specificity-first), including `recursive_lock*`, and would deny the phase
394
+ * its own tools. The write-tool family is spelled `write*`.
395
+ *
396
+ * Phase 3's TDD-evidence rule is the one the plan states verbatim: a phase-3
397
+ * lock is denied until RED + GREEN evidence exists. It is reached for the phase
398
+ * whose artifact is the current one, i.e. the phase-3 lock itself — before that
399
+ * point the earlier phases are still unlocked, so the global lock-order rule
400
+ * already refuses.
401
+ */
402
+ export function phaseBaselineRules(fileName: string): ToolPolicyRule[] {
403
+ const phase = phaseNumberForArtifact(fileName)
404
+ const rules: ToolPolicyRule[] = []
405
+
406
+ if (phase === '3') {
407
+ // Narrowing: the global `recursive_lock` rule denies a lock whose
408
+ // prerequisites are unmet. This adds a SECOND, stronger condition on the
409
+ // SAME tool — TDD evidence before the phase-3 lock — which the tool itself
410
+ // does not check.
411
+ rules.push({
412
+ pattern: 'recursive_lock*',
413
+ verdict: 'deny',
414
+ reason: 'TDD Mode: strict requires RED + GREEN evidence before locking Phase 3',
415
+ label: 'tdd-evidence',
416
+ predicate: (_id, args, ctx) => tddEvidenceMatch(String(args.artifact ?? ''), ctx.runDir),
417
+ })
418
+ }
419
+
420
+ if (phase === '6' || phase === '7' || phase === '8') {
421
+ // Late phases document, they do not change the implementation. Scope: the
422
+ // write-tool family only (`write*`), so this narrowing cannot touch a
423
+ // read/status/lock tool — and so it outranks a catch-all `allow` in the
424
+ // global policy even when that allow is listed FIRST (T16 precedence is
425
+ // specificity-first).
426
+ rules.push({
427
+ pattern: 'write*',
428
+ verdict: 'deny',
429
+ reason: 'phase ' + phase + ' is a documentation phase: writes outside the run tree are denied (the implementation is frozen)',
430
+ predicate: (_id, args, ctx) => (writesOutsideRunTree(args, ctx) ? { verdict: 'deny' } : null),
431
+ })
432
+ }
433
+
434
+ if (phase === '6' || phase === '7') {
435
+ // `06-decisions-update.md` / `07-state-update.md` transcribe decisions and
436
+ // state into their memory planes; phase 8 is where those planes are shaped.
437
+ rules.push({
438
+ pattern: 'write*',
439
+ verdict: 'deny',
440
+ reason: 'phase ' + phase + ' writes no memory plane: .recursive/DECISIONS.md|STATE.md and .recursive/memory/** are written by their own phases',
441
+ predicate: (_id, args, ctx) => (writesMemoryPlane(args, ctx) ? { verdict: 'deny' } : null),
442
+ })
443
+ }
444
+
445
+ if (phase === '1' || phase === '2') {
446
+ // Phases 1-2 characterize and plan; they do not write the memory the run
447
+ // only earns later. Scope: the memory planes only, so no other write
448
+ // (including a repo source write a plan legitimately needs) is affected.
449
+ rules.push({
450
+ pattern: 'write*',
451
+ verdict: 'deny',
452
+ reason: 'phase ' + phase + ' writes no memory plane: .recursive/memory/** and .recursive/DECISIONS.md|STATE.md are earned at phases 6-8',
453
+ predicate: (_id, args, ctx) => (writesMemoryPlane(args, ctx) ? { verdict: 'deny' } : null),
454
+ })
455
+ }
456
+
457
+ return rules
458
+ }
459
+
460
+ /**
461
+ * True when the call writes OUTSIDE the run tree. A target that cannot be
462
+ * resolved counts as outside: the baseline cannot prove the write lands in the
463
+ * run tree, and "we could not tell" is not a reason to allow it.
464
+ */
465
+ function writesOutsideRunTree(args: Record<string, unknown>, ctx: ToolPolicyContext): boolean {
466
+ const abs = baselineTarget(args, ctx)
467
+ if (abs === 'unresolvable') return true
468
+ return !insideRunTree(abs, ctx.runDir)
469
+ }
470
+
471
+ /** True when the call writes one of the run's memory planes. */
472
+ function writesMemoryPlane(args: Record<string, unknown>, ctx: ToolPolicyContext): boolean {
473
+ const abs = baselineTarget(args, ctx)
474
+ if (abs === 'unresolvable') return true
475
+ return memoryPlanePath(abs)
476
+ }
477
+
478
+ /** True when the absolute path names one of the run's memory planes. */
479
+ function memoryPlanePath(abs: string): boolean {
480
+ const normalized = abs.replace(/\\/g, '/')
481
+ return /\/(decisions|state)\.md$/i.test(normalized) || /\/\.recursive\/memory(\/|$)/.test(normalized)
482
+ }
483
+
484
+ /**
485
+ * Resolve a tool-target path to an absolute path. Mirrors enforcement.ts's
486
+ * resolution rules: an absolute path stays as it is; a relative path resolves
487
+ * against the worktree root. `null` when the value cannot be a path at all.
488
+ */
489
+ export function resolveFrom(worktreeRoot: string, target: string): string | null {
490
+ const normalized = target.replace(/\\/g, '/').trim()
491
+ if (!normalized) return null
492
+ if (/^[A-Za-z]:\//.test(normalized) || normalized.startsWith('/')) return resolve(normalized)
493
+ return resolve(join(worktreeRoot, normalized.replace(/^\.?\/?/, '')))
494
+ }
495
+
496
+ /** The tool-target path of a call (same key order enforcement.ts uses). */
497
+ export function policyTargetPath(args: Record<string, unknown>): string | null {
498
+ for (const key of ['file_path', 'path', 'command', 'target', 'filePath']) {
499
+ const value = args[key]
500
+ if (typeof value === 'string' && value.trim() !== '') return value.trim()
501
+ }
502
+ return null
503
+ }
504
+
505
+ /**
506
+ * TDD evidence predicate for a phase-3 lock: `deny` when the artifact declares
507
+ * `TDD Mode: strict` without both RED and GREEN evidence, `null` otherwise
508
+ * (nothing to say — the global rules still apply). Lives in `policy-globs.ts`
509
+ * beside the built-in list, which carries the same `tdd-evidence` guard rule.
510
+ */
511
+ export const tddEvidenceVerdict = tddEvidenceMatch
512
+
513
+ /**
514
+ * Compose the effective policy for a phase: the phase baseline rules FIRST (so
515
+ * a narrowing rule is reached before the global rule it tightens), then the
516
+ * global rules.
517
+ *
518
+ * NARROWING IS MECHANICAL, not a convention this table is trusted to respect. A
519
+ * baseline rule whose verdict is ranked ABOVE the strictest global verdict for
520
+ * the same pattern is DROPPED before it can be evaluated, so no per-phase
521
+ * baseline can turn a global `deny` into an `allow` (or an `ask` into an
522
+ * `allow`). A baseline rule for a pattern the global policy does not mention is
523
+ * always kept: it can only add a restriction.
524
+ */
525
+ export function withPhaseBaseline(policy: ToolPolicy, fileName: string, runDir?: string): ToolPolicy {
526
+ const baseline = phaseBaselineRules(fileName)
527
+ if (baseline.length === 0) return policy
528
+ const globalRank = new Map<string, number>()
529
+ for (const rule of policy.rules) {
530
+ const rank = VERDICT_RANK[rule.verdict]
531
+ const known = globalRank.get(rule.pattern)
532
+ if (known === undefined || rank < known) globalRank.set(rule.pattern, rank)
533
+ }
534
+ const kept = baseline.filter((rule) => {
535
+ const rank = VERDICT_RANK[rule.verdict]
536
+ const known = globalRank.get(rule.pattern)
537
+ return known === undefined || rank <= known
538
+ })
539
+ if (kept.length === 0) return policy
540
+ return { ...policy, rules: [...kept, ...policy.rules] }
541
+ }