@try-works/dsh-recursive-mode 0.3.1 → 0.4.1
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.
- package/README.md +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +32 -12
- package/lib/index.js +9819 -3858
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +29 -30
- package/preset/recursive/agent.cordis.yml +2 -2
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +385 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- 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
|
+
}
|
package/src/phase-rules.ts
CHANGED
|
@@ -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
|
+
}
|