@try-works/dsh-recursive-mode 0.5.0 → 0.6.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.
- package/README.md +21 -8
- package/lib/enforcement.d.ts +58 -0
- package/lib/index.js +423 -107
- package/lib/memory.d.ts +35 -2
- package/lib/phase-rules.d.ts +102 -0
- package/lib/policy-globs.d.ts +5 -0
- package/lib/training.d.ts +240 -2
- package/package.json +1 -1
- package/references/artifact-template.md +26 -61
- package/references/bodies/claude.md +1 -1
- package/references/bodies/copilot.md +1 -1
- package/references/bodies/cursorrules.md +4 -2
- package/references/bodies/memory-router.md +1 -1
- package/references/bodies/recursive-agents-router.md +4 -3
- package/references/bootstrap/RECURSIVE.md +21 -30
- package/src/bootstrap.ts +30 -14
- package/src/enforcement.ts +89 -5
- package/src/index.ts +795 -723
- package/src/memory.ts +50 -9
- package/src/phase-rules.ts +162 -1
- package/src/policy-globs.ts +28 -4
- package/src/runtime.ts +6 -3
- package/src/training.ts +634 -6
package/src/memory.ts
CHANGED
|
@@ -24,7 +24,19 @@ import { join } from 'node:path'
|
|
|
24
24
|
// P3b: the counters, passed IN rather than read here, so a caller decides where the evidence comes from.
|
|
25
25
|
import { type FeedbackBook, feedbackBonus } from './memory-feedback.ts'
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* ⚠ `training` IS IN THIS LIST BECAUSE IT IS THE KIND THE PLUGIN'S OWN TRAINING PATH WRITES.
|
|
29
|
+
*
|
|
30
|
+
* The phase-8 trigger (`training.ts`) writes `memory/training/<task-type>.md` and advertises that path in
|
|
31
|
+
* `memory/MEMORY.md`; the shipped router (`references/bodies/memory-router.md`) tells an agent to load "the
|
|
32
|
+
* relevant docs under `/.recursive/memory/training/`". With `training` absent from this list the loader
|
|
33
|
+
* COULD NOT SEE THE SHARDS THE TRIGGER WROTE — the writer's own output was unreachable by the reader, so
|
|
34
|
+
* "the plane's next run scores it" (README §10) was not true of exactly the shards training produces.
|
|
35
|
+
*
|
|
36
|
+
* `incidents/` and `archive/` stay out deliberately: `archive/` is historical by the router's own definition,
|
|
37
|
+
* and widening retrieval to `incidents/` is a separate ranking decision that this change does not make.
|
|
38
|
+
*/
|
|
39
|
+
export const MEMORY_KINDS = ['domains', 'patterns', 'episodes', 'training', 'skills'] as const
|
|
28
40
|
|
|
29
41
|
export type MemoryKind = (typeof MEMORY_KINDS)[number]
|
|
30
42
|
|
|
@@ -193,7 +205,29 @@ export function entryAppliesTo(entry: MemoryEntry): string[] {
|
|
|
193
205
|
}
|
|
194
206
|
|
|
195
207
|
/** The registry the loader starts from: the router, not the plane. */
|
|
196
|
-
export const MEMORY_INDEX_FILE = 'memory/MEMORY.md'
|
|
208
|
+
export const MEMORY_INDEX_FILE = '.recursive/memory/MEMORY.md'
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Where the plane may live under a workspace root, in PREFERENCE order.
|
|
212
|
+
*
|
|
213
|
+
* ⚠ THE MEASURED DEFECT THIS FIXES. `defaultMemoryList` used to join `memory/<kind>/` straight onto the
|
|
214
|
+
* root it was handed — i.e. `<root>/memory/` — and that directory EXISTS IN NO REAL WORKSPACE. `bootstrap.ts`
|
|
215
|
+
* scaffolds the plane at `<root>/.recursive/memory/`, `ts-lint.ts` lints it there, and the review bundle reads
|
|
216
|
+
* it there; this loader alone looked beside it. Measured live on a workspace whose `.recursive/memory/` was
|
|
217
|
+
* scaffolded and whose `<root>/memory/` did not exist: `selectMemory` reported "the memory plane is empty"
|
|
218
|
+
* over a plane that was there, every phase of every run — and the training shards `training.ts` writes were
|
|
219
|
+
* therefore unreachable by the loader that is supposed to score them.
|
|
220
|
+
*
|
|
221
|
+
* ⚠ PREFER, THEN FALL BACK — NEVER MERGE. This is the rule `readFeedback` already follows for its own moved
|
|
222
|
+
* sidecar (`memory-feedback.ts`: `FEEDBACK_FILE` then `LEGACY_FEEDBACK_FILE`): the current location wins, and
|
|
223
|
+
* the earlier one is consulted ONLY when the current one yields nothing, because two snapshots of one shard
|
|
224
|
+
* added together would count a shard twice and a duplicated shard would outrank a real one.
|
|
225
|
+
*
|
|
226
|
+
* ⚠ AND IT MAKES THE CALLER'S CONVENTION IRRELEVANT. Passing the workspace root resolves
|
|
227
|
+
* `<root>/.recursive/memory/`; passing `.recursive` itself resolves through the second entry. Both are
|
|
228
|
+
* accepted, which is what README §6 means by "using `.recursive` as the root is selected again".
|
|
229
|
+
*/
|
|
230
|
+
export const MEMORY_PLANE_BASES = ['.recursive/memory', 'memory'] as const
|
|
197
231
|
|
|
198
232
|
/** Progressive disclosure defaults, matching the parent (`--max-docs` 3, `--max-items` 10). */
|
|
199
233
|
export const MAX_MEMORY_DOCS = 3
|
|
@@ -298,12 +332,19 @@ function defaultMemoryRead(path: string): string | null {
|
|
|
298
332
|
}
|
|
299
333
|
|
|
300
334
|
function defaultMemoryList(root: string, kind: string): readonly string[] {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
335
|
+
// Preference order, never merged: see {@link MEMORY_PLANE_BASES}. A kind with no readable shard in the
|
|
336
|
+
// scaffolded plane falls through to the earlier location, which is how a caller that rooted the plane at
|
|
337
|
+
// `memory/` directly (and how the training writer's own call site) keeps working.
|
|
338
|
+
for (const base of MEMORY_PLANE_BASES) {
|
|
339
|
+
const dir = join(root, base, kind)
|
|
340
|
+
try {
|
|
341
|
+
const files = readdirSync(dir)
|
|
342
|
+
.filter((name) => name.endsWith('.md'))
|
|
343
|
+
.map((name) => join(dir, name))
|
|
344
|
+
if (files.length > 0) return files
|
|
345
|
+
} catch {
|
|
346
|
+
// An absent or unreadable directory is a missing advantage, not a failed load.
|
|
347
|
+
}
|
|
308
348
|
}
|
|
349
|
+
return []
|
|
309
350
|
}
|
package/src/phase-rules.ts
CHANGED
|
@@ -227,6 +227,119 @@ const SECTION_MAP: Record<string, string[]> = {
|
|
|
227
227
|
],
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/* -------------------------------------------------------------------------- */
|
|
231
|
+
/* T40 — THE PHASE-8 MEMORY WRITE, AS A RULE */
|
|
232
|
+
/* -------------------------------------------------------------------------- */
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* T40 — `.recursive/memory/`, the plane THIS plugin writes, and the phase-8 step that was prose.
|
|
236
|
+
*
|
|
237
|
+
* ⚠ WHY THIS EXISTS, MEASURED. Three completed runs in a live workspace left `.recursive/memory/`
|
|
238
|
+
* exactly as `bootstrap.ts` scaffolded it: `MEMORY.md` and the skill docs were still the
|
|
239
|
+
* bootstrap-created placeholders, while run 03's `08-memory-impact.md` had its
|
|
240
|
+
* "Write the durable ones to memory, with provenance" box TICKED and its `Inputs` line naming
|
|
241
|
+
* ANOTHER plugin's store (`memory_search` / `memory_status` is dsh-memory, not this plugin). So the
|
|
242
|
+
* phase declared its memory step done against a system this plugin does not own, and the plugin's
|
|
243
|
+
* own learning never activated — the owner's words: *"the agent should write to .recursive/memory/
|
|
244
|
+
* in phase 8, that's a hard requirement that needs to be enforced"*.
|
|
245
|
+
*
|
|
246
|
+
* ⚠ THE FIX IS A FACT, NOT A STRONGER SENTENCE. A prose step is ticked by the agent that would have
|
|
247
|
+
* had to do it, which is exactly what happened. What follows is the same requirement in the form the
|
|
248
|
+
* workflow can CHECK: a path under the plane, declared in the artifact, whose own text on disk
|
|
249
|
+
* carries this run's provenance (`Source-Runs`). "The run wrote its durable memory" then stops being
|
|
250
|
+
* a claim about the run's intentions and becomes a claim about files.
|
|
251
|
+
*
|
|
252
|
+
* ⚠ WHY IT IS NOT IN `SECTION_MAP`, deliberately: that map is byte-parity with the canonical
|
|
253
|
+
* linter's `get_artifact_required_sections` (`tests/phase-rules.parity.spec.ts` pins all twelve
|
|
254
|
+
* lists), and a section added there would be a parity break dressed up as a feature. The requirement
|
|
255
|
+
* rides on a section the canonical template ALREADY scaffolds — `## Affected Memory Docs` — plus a
|
|
256
|
+
* field the plane's own linter already requires, so the artifact shape stays canonical.
|
|
257
|
+
*/
|
|
258
|
+
export const PHASE8_MEMORY_ARTIFACT = '08-memory-impact.md'
|
|
259
|
+
|
|
260
|
+
/** The plane, repo-relative. The trailing slash is what every guard here tests for. */
|
|
261
|
+
export const MEMORY_PLANE_PREFIX = '.recursive/memory/'
|
|
262
|
+
|
|
263
|
+
/** The canonical section the declaration goes in — present in every scaffolded phase-8 artifact. */
|
|
264
|
+
export const PHASE8_MEMORY_SECTION = 'Affected Memory Docs'
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Where a durable doc may be filed, and the `Type` the memory-plane linter requires for it.
|
|
268
|
+
*
|
|
269
|
+
* ⚠ EVERY `dir` IS THE REAL PLANE, `.recursive/memory/…`, AND NOT A `memory/…` RELATIVE FORM. Measured:
|
|
270
|
+
* `bootstrap.ts` scaffolds the plane under `.recursive/`, `ts-lint.ts` lints it there, and the phase-8
|
|
271
|
+
* artifact cites it there — while the training trigger's own write seam joins its `memory/…` paths onto
|
|
272
|
+
* the workspace root, i.e. `<root>/memory/`, a directory that exists in no real workspace (the reader
|
|
273
|
+
* side of that defect was fixed in `memory.ts`; the writer side is `runtime.ts`'s seam and is reported,
|
|
274
|
+
* not edited). This table names the location a WRITE must land in, so it names the plane.
|
|
275
|
+
*
|
|
276
|
+
* ⚠ `skill` SHARES `pattern`'s Type ON PURPOSE: `/.recursive/memory/skills/patterns/` is where a
|
|
277
|
+
* promoted skill lesson ships (`bootstrap.ts` scaffolds three docs there), and the linter's allowed
|
|
278
|
+
* Types are `index|domain|pattern|incident|episode` — there is no `skill` Type to declare.
|
|
279
|
+
*/
|
|
280
|
+
export const MEMORY_DOC_LOCATIONS = {
|
|
281
|
+
domain: { dir: '.recursive/memory/domains', type: 'domain' },
|
|
282
|
+
pattern: { dir: '.recursive/memory/patterns', type: 'pattern' },
|
|
283
|
+
incident: { dir: '.recursive/memory/incidents', type: 'incident' },
|
|
284
|
+
episode: { dir: '.recursive/memory/episodes', type: 'episode' },
|
|
285
|
+
skill: { dir: '.recursive/memory/skills/patterns', type: 'pattern' },
|
|
286
|
+
} as const
|
|
287
|
+
|
|
288
|
+
export type MemoryDocKind = keyof typeof MEMORY_DOC_LOCATIONS
|
|
289
|
+
|
|
290
|
+
/** The field that makes a doc THIS run's. It is the phase-8 gate's entire discriminator. */
|
|
291
|
+
export const MEMORY_PROVENANCE_FIELD = 'Source-Runs'
|
|
292
|
+
|
|
293
|
+
/** Where a run that believes it learned nothing can always record what the run was and what it cost. */
|
|
294
|
+
export const MEMORY_ALWAYS_AVAILABLE = '.recursive/memory/episodes/<run-id>.md'
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The phase-8 obligation, as data — so the pre-step reminder, the `recursive_phase` payload and the
|
|
298
|
+
* lock-time gate all describe ONE rule instead of three paraphrases of it.
|
|
299
|
+
*/
|
|
300
|
+
export interface Phase8MemoryWriteRule {
|
|
301
|
+
/** The artifact that must declare the write. */
|
|
302
|
+
artifact: string
|
|
303
|
+
/** The plane the write must land in, repo-relative. */
|
|
304
|
+
plane: string
|
|
305
|
+
/** The section the declaration goes in. */
|
|
306
|
+
section: string
|
|
307
|
+
/** The field a doc must carry for the write to count as THIS run's. */
|
|
308
|
+
provenanceField: string
|
|
309
|
+
/** The doc a run with nothing else to record can always write. */
|
|
310
|
+
alwaysAvailable: string
|
|
311
|
+
kinds: readonly MemoryDocKind[]
|
|
312
|
+
/** One sentence per line the pre-step reminder injects. */
|
|
313
|
+
summary: string
|
|
314
|
+
/** The full instruction, for a caller that asks for the phase's rules. */
|
|
315
|
+
instruction: string
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export const PHASE8_MEMORY_WRITE_RULE: Phase8MemoryWriteRule = {
|
|
319
|
+
artifact: PHASE8_MEMORY_ARTIFACT,
|
|
320
|
+
plane: MEMORY_PLANE_PREFIX,
|
|
321
|
+
section: PHASE8_MEMORY_SECTION,
|
|
322
|
+
provenanceField: MEMORY_PROVENANCE_FIELD,
|
|
323
|
+
alwaysAvailable: MEMORY_ALWAYS_AVAILABLE,
|
|
324
|
+
kinds: Object.keys(MEMORY_DOC_LOCATIONS) as MemoryDocKind[],
|
|
325
|
+
summary: 'HARD: this run must have WRITTEN at least one doc under ' + MEMORY_PLANE_PREFIX
|
|
326
|
+
+ ' before ' + PHASE8_MEMORY_ARTIFACT + ' locks — ' + MEMORY_ALWAYS_AVAILABLE + ' is always available — declared by path under `## '
|
|
327
|
+
+ PHASE8_MEMORY_SECTION + '` and carrying `' + MEMORY_PROVENANCE_FIELD + ': <this-run-id>`; citing a shard this run did not write does not count.',
|
|
328
|
+
instruction: 'HARD REQUIREMENT, CHECKED AT LOCK: before ' + PHASE8_MEMORY_ARTIFACT + ' locks, this run must have WRITTEN at least one doc under '
|
|
329
|
+
+ MEMORY_PLANE_PREFIX + ' and declared that path under `## ' + PHASE8_MEMORY_SECTION + '`. A declared path counts ONLY when the doc on disk carries `'
|
|
330
|
+
+ MEMORY_PROVENANCE_FIELD + '` naming THIS run, because that is what separates "the run wrote its memory" from "the run cited someone else\'s".'
|
|
331
|
+
+ ' Render the doc with the metadata the memory-plane lint requires (Type, Status, Scope, Owns-Paths, Watch-Paths, ' + MEMORY_PROVENANCE_FIELD
|
|
332
|
+
+ ', Validated-At-Commit, Last-Validated, Tags): ' + MEMORY_ALWAYS_AVAILABLE + ' is always available for a run-local lesson, `.recursive/memory/domains/`,'
|
|
333
|
+
+ ' `.recursive/memory/patterns/` and `.recursive/memory/incidents/` hold generalized knowledge, and `.recursive/memory/skills/patterns/` is where a promoted skill lesson belongs.'
|
|
334
|
+
+ ' A doc missing a required field, or carrying a Type/Status the plane lint rejects, FAILS the memory plane — write it in the canonical shape.'
|
|
335
|
+
+ ' The written path enters the run diff under ' + MEMORY_PLANE_PREFIX + ', which phase 8 OWNS in its Worktree Diff Audit and Requirement Completion Status.',
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The memory-write rule for an artifact, or null when that phase owes no memory write. */
|
|
339
|
+
export function phase8MemoryWriteRuleFor(fileName: string): Phase8MemoryWriteRule | null {
|
|
340
|
+
return fileName === PHASE8_MEMORY_ARTIFACT ? PHASE8_MEMORY_WRITE_RULE : null
|
|
341
|
+
}
|
|
342
|
+
|
|
230
343
|
/**
|
|
231
344
|
* get_artifact_required_sections(file_name, workflow_profile): canonical-parity
|
|
232
345
|
* required section headings for a phase artifact. Defaults to TODO + Coverage
|
|
@@ -275,6 +388,14 @@ export interface PhaseRules {
|
|
|
275
388
|
audited: boolean
|
|
276
389
|
tdd: boolean
|
|
277
390
|
qa: boolean
|
|
391
|
+
/**
|
|
392
|
+
* T40 — the memory-write obligation for this phase, or null when it owes none.
|
|
393
|
+
*
|
|
394
|
+
* ⚠ IT IS PART OF THIS STRUCTURE, not a parallel table, because `runtime.phaseRules` spreads this
|
|
395
|
+
* object straight into the `recursive_phase` payload and the pre-step reminder is built from the
|
|
396
|
+
* same source: a rule that lives here is a rule the agent is actually told, in one place.
|
|
397
|
+
*/
|
|
398
|
+
memoryWrite: Phase8MemoryWriteRule | null
|
|
278
399
|
}
|
|
279
400
|
|
|
280
401
|
export function phaseRulesFor(fileName: string, workflowProfile: string = CURRENT_WORKFLOW_PROFILE): PhaseRules {
|
|
@@ -285,6 +406,7 @@ export function phaseRulesFor(fileName: string, workflowProfile: string = CURREN
|
|
|
285
406
|
audited: AUDITED_PHASE_FILES.has(fileName),
|
|
286
407
|
tdd: fileName === '03-implementation-summary.md',
|
|
287
408
|
qa: fileName === '05-manual-qa.md',
|
|
409
|
+
memoryWrite: phase8MemoryWriteRuleFor(fileName),
|
|
288
410
|
}
|
|
289
411
|
}
|
|
290
412
|
|
|
@@ -304,6 +426,10 @@ export function phaseLintRulesMessage(fileName: string, workflowProfile: string
|
|
|
304
426
|
'Audited phases: end with Audit: PASS before setting Coverage/Approval PASS; record Audit Context and Audit Verdict.',
|
|
305
427
|
'TDD (phase 3): declare TDD Mode: strict|pragmatic; strict requires RED + GREEN evidence paths.',
|
|
306
428
|
'QA (phase 5): declare QA Execution Mode: human|agent-operated|hybrid; human/hybrid need user sign-off.',
|
|
429
|
+
// T40 — the phase-8 memory write is named HERE, once, where every other phase gate is named.
|
|
430
|
+
// Additive on purpose: the message is only ever asserted with `toContain` (r5-parity.spec.ts),
|
|
431
|
+
// so a new phase's obligation cannot be mistaken for a regression in an existing one.
|
|
432
|
+
...(rules.memoryWrite === null ? [] : ['Memory write (phase 8, HARD): ' + rules.memoryWrite.summary]),
|
|
307
433
|
'</system-reminder>',
|
|
308
434
|
]
|
|
309
435
|
return lines.join('\n')
|
|
@@ -427,7 +553,24 @@ export function phaseBaselineRules(fileName: string): ToolPolicyRule[] {
|
|
|
427
553
|
pattern: 'write*',
|
|
428
554
|
verdict: 'deny',
|
|
429
555
|
reason: 'phase ' + phase + ' is a documentation phase: writes outside the run tree are denied (the implementation is frozen)',
|
|
430
|
-
|
|
556
|
+
// ⚠ T40 — PHASE 8 CARRIES ONE CARVE-OUT, AND WITHOUT IT THE HARD REQUIREMENT IS UNSATISFIABLE.
|
|
557
|
+
//
|
|
558
|
+
// `.recursive/memory/**` is OUTSIDE `.recursive/run/<runId>/`, so this rule denied the very
|
|
559
|
+
// write phase 8 exists to make: under the shipped strict default an agent authoring its memory
|
|
560
|
+
// doc was refused as if it were editing the implementation. MEASURED, not assumed — the rule's
|
|
561
|
+
// own predicate answers `deny` for `.recursive/memory/...` in the phase whose whole job is that
|
|
562
|
+
// plane. So phase 8, and only phase 8, admits THIS PLUGIN'S OWN plane:
|
|
563
|
+
// - the source tree stays denied (the implementation is frozen, and that is the rule's point);
|
|
564
|
+
// - `.recursive/DECISIONS.md` / `.recursive/STATE.md` stay denied here — phases 6 and 7 own
|
|
565
|
+
// them, and `writesOwnMemoryPlane` is deliberately narrower than `writesMemoryPlane`;
|
|
566
|
+
// - a target that cannot be placed stays denied, because the carve-out must never fail open.
|
|
567
|
+
// ADVISORY IS UNCHANGED BY THIS: that mode coerces a `deny` into an `ask` and then into an
|
|
568
|
+
// allow-with-warning anyway, so the carve-out only changes what STRICT refuses.
|
|
569
|
+
predicate: (_id, args, ctx) => (
|
|
570
|
+
writesOutsideRunTree(args, ctx) && !(phase === '8' && writesOwnMemoryPlane(args, ctx))
|
|
571
|
+
? { verdict: 'deny' }
|
|
572
|
+
: null
|
|
573
|
+
),
|
|
431
574
|
})
|
|
432
575
|
}
|
|
433
576
|
|
|
@@ -481,6 +624,24 @@ function memoryPlanePath(abs: string): boolean {
|
|
|
481
624
|
return /\/(decisions|state)\.md$/i.test(normalized) || /\/\.recursive\/memory(\/|$)/.test(normalized)
|
|
482
625
|
}
|
|
483
626
|
|
|
627
|
+
/**
|
|
628
|
+
* True when the call writes `.recursive/memory/**` — THIS PLUGIN'S OWN plane, and nothing else.
|
|
629
|
+
*
|
|
630
|
+
* ⚠ DELIBERATELY NARROWER THAN {@link writesMemoryPlane}, which also matches `DECISIONS.md` and
|
|
631
|
+
* `STATE.md`: phase 8's carve-out (T40) is about durable memory, not about handing phase 8 the two
|
|
632
|
+
* planes phases 6-7 own.
|
|
633
|
+
*
|
|
634
|
+
* ⚠ AND AN UNRESOLVABLE TARGET IS `false` HERE — the OPPOSITE of every other fail-closed answer in
|
|
635
|
+
* this file, because this is the PERMISSIVE branch: `true` means "do not deny", so a path the rules
|
|
636
|
+
* cannot place must not be admitted by it. "We could not tell where this lands" is a reason to
|
|
637
|
+
* refuse, never a reason to allow.
|
|
638
|
+
*/
|
|
639
|
+
function writesOwnMemoryPlane(args: Record<string, unknown>, ctx: ToolPolicyContext): boolean {
|
|
640
|
+
const abs = baselineTarget(args, ctx)
|
|
641
|
+
if (abs === 'unresolvable') return false
|
|
642
|
+
return /\/\.recursive\/memory(\/|$)/.test(abs.replace(/\\/g, '/'))
|
|
643
|
+
}
|
|
644
|
+
|
|
484
645
|
/**
|
|
485
646
|
* Resolve a tool-target path to an absolute path. Mirrors enforcement.ts's
|
|
486
647
|
* resolution rules: an absolute path stays as it is; a relative path resolves
|
package/src/policy-globs.ts
CHANGED
|
@@ -76,6 +76,11 @@ export interface Decision {
|
|
|
76
76
|
* Extra facts a rule predicate may need. `args` is always the tool call's
|
|
77
77
|
* arguments; the run coordinates are present only when the caller has them
|
|
78
78
|
* (a guard call from a real session does; a pure policy unit test need not).
|
|
79
|
+
*
|
|
80
|
+
* ⚠ `runId` IS NOT DECORATION: the lock-order rule puts it in the refusal (`[run: <id>]`), so a refusal
|
|
81
|
+
* names the run whose tree it was decided from. It is the run the guard RESOLVED for this call — the run
|
|
82
|
+
* the call named when it named a usable one, otherwise the active run (see `resolveGuardRunId` in
|
|
83
|
+
* `enforcement.ts`) — never a second, independently-derived answer.
|
|
79
84
|
*/
|
|
80
85
|
export interface ToolPolicyContext {
|
|
81
86
|
args: Record<string, unknown>
|
|
@@ -444,15 +449,34 @@ export const LOCK_TOOL_NAMES = new Set(['recursive_lock', 'recursive_lock_phase'
|
|
|
444
449
|
* sentence; the predicate adds the blocking artifact and its status, which is
|
|
445
450
|
* what distinguishes a guard refusal from `lockArtifact`'s own
|
|
446
451
|
* `Prerequisite blockers:` error (`tests/guard-path.spec.ts` asserts both).
|
|
452
|
+
*
|
|
453
|
+
* ⚠ ISSUE 2 (b) — AND IT NAMES THE RUN IT READ, `ctx.runId`, as `[run: <id>]`.
|
|
454
|
+
*
|
|
455
|
+
* The blockers above are read FROM A DIRECTORY (`runDir`), and until this suffix existed the refusal said
|
|
456
|
+
* only "an earlier phase must be locked first 00-requirements.md (DRAFT)" — a sentence with no run in it.
|
|
457
|
+
* That is what let a refusal MIX TWO RUNS in one payload: a call naming run-b could be judged against
|
|
458
|
+
* run-a's tree (the guard resolved the run from the filesystem, the tool from `args.runId`) and the caller
|
|
459
|
+
* was told about `00-requirements.md (DRAFT)` while the guard-decision record said `runId: run-a` and the
|
|
460
|
+
* gate-block ask said `artifact: 01-as-is.md`. The blocking artifact, the artifact the caller named and the
|
|
461
|
+
* run the record attributed it to were three answers to one question.
|
|
462
|
+
*
|
|
463
|
+
* The fix is two-sided: the guard now judges the run the CALL NAMES (see `resolveGuardRunId` in
|
|
464
|
+
* `enforcement.ts`), and this suffix makes the evaluated run part of the sentence, so the payload can be
|
|
465
|
+
* read without cross-referencing the log record — and a reader can SEE which run was read, which is what
|
|
466
|
+
* makes a future mismatch visible instead of silent.
|
|
467
|
+
*
|
|
468
|
+
* `runId` is optional because the pure policy layer may be called with no run context at all (a policy
|
|
469
|
+
* unit test, a preview probe): the sentence is then exactly what it always was, and no run is invented.
|
|
447
470
|
*/
|
|
448
|
-
function lockOrderRule(artifact: unknown, runDir: string | undefined): ToolPolicyPredicateMatch | null {
|
|
471
|
+
function lockOrderRule(artifact: unknown, runDir: string | undefined, runId?: string): ToolPolicyPredicateMatch | null {
|
|
449
472
|
const name = String(artifact ?? '')
|
|
450
473
|
if (!name || !runDir) return null
|
|
451
474
|
const blockers = getPrerequisiteBlockers(runDir, name)
|
|
452
475
|
if (blockers.length === 0) return null
|
|
476
|
+
const where = typeof runId === 'string' && runId.trim() !== '' ? ' [run: ' + runId.trim() + ']' : ''
|
|
453
477
|
return {
|
|
454
478
|
verdict: 'deny',
|
|
455
|
-
detail: blockers.map((b) => b.artifact + ' (' + b.status + ')').join(', '),
|
|
479
|
+
detail: blockers.map((b) => b.artifact + ' (' + b.status + ')').join(', ') + where,
|
|
456
480
|
// THE SAME READ, carried up rather than thrown away: the denial's recovery options are built
|
|
457
481
|
// from these blockers, and re-deriving them one layer higher would be a second filesystem
|
|
458
482
|
// answer to a question this rule has already answered.
|
|
@@ -603,7 +627,7 @@ export function builtInToolPolicyRules(): ToolPolicyRule[] {
|
|
|
603
627
|
verdict: 'deny',
|
|
604
628
|
reason: 'monotonic lock-order: an earlier phase must be locked first',
|
|
605
629
|
label: 'lock-order',
|
|
606
|
-
predicate: (id, args, ctx) => (LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir) : null),
|
|
630
|
+
predicate: (id, args, ctx) => (LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir, ctx.runId) : null),
|
|
607
631
|
},
|
|
608
632
|
]
|
|
609
633
|
for (const name of WRITE_TOOL_NAMES) {
|
|
@@ -662,7 +686,7 @@ export function builtInToolPolicyDefault(): ToolPolicy {
|
|
|
662
686
|
export function attachPolicyPredicate(rule: ToolPolicyRule): ToolPolicyRule {
|
|
663
687
|
if (rule.predicate) return rule
|
|
664
688
|
if (rule.pattern === 'recursive_lock*') {
|
|
665
|
-
return { ...rule, predicate: (id, args, ctx) => (LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir) : null) }
|
|
689
|
+
return { ...rule, predicate: (id, args, ctx) => (LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir, ctx.runId) : null) }
|
|
666
690
|
}
|
|
667
691
|
// ⚠ THE LABEL IS CONSULTED BEFORE THE PATTERN, because the phase-order rule and the
|
|
668
692
|
// locked-artifact rule share EVERY write-tool pattern (see `builtInToolPolicyRules`).
|
package/src/runtime.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { readScratch, writeScratch, appendScratch, type ScratchTarget } from './
|
|
|
32
32
|
import { buildReviewBundle, type ReviewBundleInput } from './review.ts'
|
|
33
33
|
import { readMemoryEntries, retrieveMemory, renderMemorySection, selectMemory } from './memory.ts'
|
|
34
34
|
import { readFeedback, recordInjection, settleInjections } from './memory-feedback.ts'
|
|
35
|
-
import { runPhase8Trigger, resolveExtractor, spawnExtractorRunner } from './training.ts'
|
|
35
|
+
import { runPhase8Trigger, resolveExtractor, spawnExtractorRunner, phase8MemoryLockRefusal } from './training.ts'
|
|
36
36
|
import { buildAskQuestion, GATE_DEFAULT_ARTIFACT, pendingGateFor } from './recursive_ask.tool.ts'
|
|
37
37
|
import { contractDigest } from './policy.ts'
|
|
38
38
|
import type { WorkflowEngineLike } from './workflow-audit.ts'
|
|
@@ -575,14 +575,17 @@ export class RecursiveRuntime extends Service {
|
|
|
575
575
|
// stdio a capture needs, and the response file is the parent's own interface anyway.
|
|
576
576
|
runner: spawnExtractorRunner({ cwd: root, responseFile: join(runDir, 'training-response.json') }),
|
|
577
577
|
write: (relativePath, content) => {
|
|
578
|
-
|
|
578
|
+
// T40 - WRITER/READER AGREEMENT. The shards this trigger writes are memory-plane docs, so they
|
|
579
|
+
// belong under .recursive/memory/ where the plane lint, the registry and the phase-8 artifact all
|
|
580
|
+
// look - the seam previously joined them onto the workspace root, landing them OUTSIDE the plane.
|
|
581
|
+
const target = join(root, '.recursive', relativePath)
|
|
579
582
|
mkdirSync(dirname(target), { recursive: true })
|
|
580
583
|
writeFileSync(target, content, 'utf8')
|
|
581
584
|
return relativePath
|
|
582
585
|
},
|
|
583
586
|
readText: (relativePath) => {
|
|
584
587
|
try {
|
|
585
|
-
return readFileSync(join(root, relativePath), 'utf8')
|
|
588
|
+
return readFileSync(join(root, '.recursive', relativePath), 'utf8')
|
|
586
589
|
} catch {
|
|
587
590
|
return null
|
|
588
591
|
}
|