@try-works/dsh-recursive-mode 0.4.9 → 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/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
- export const MEMORY_KINDS = ['domains', 'patterns', 'episodes', 'skills'] as const
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
- const dir = join(root, 'memory', kind)
302
- try {
303
- return readdirSync(dir)
304
- .filter((name) => name.endsWith('.md'))
305
- .map((name) => join(dir, name))
306
- } catch {
307
- return []
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
  }
@@ -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
- predicate: (_id, args, ctx) => (writesOutsideRunTree(args, ctx) ? { verdict: 'deny' } : null),
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
@@ -41,7 +41,7 @@
41
41
  */
42
42
  import { existsSync, readFileSync } from 'node:fs'
43
43
  import { basename, dirname, join, resolve } from 'node:path'
44
- import { getLockStatus, getPrerequisiteBlockers } from './lock.ts'
44
+ import { getLockStatus, getPrerequisiteBlockers, type PrerequisiteBlocker } from './lock.ts'
45
45
  import { getMdFieldValue } from './status.ts'
46
46
  import { phaseNumberForArtifact, policyTargetPath, resolveFrom } from './phase-rules.ts'
47
47
 
@@ -58,12 +58,29 @@ export interface Decision {
58
58
  kind: Verdict
59
59
  reason?: string
60
60
  rule?: string
61
+ /**
62
+ * THE FACTS THE PREDICATE DECIDED FROM, when it read any. The lock-order rule resolves the
63
+ * artifact's prerequisites from disk, and the layer that turns this decision into a refusal
64
+ * needs those same facts to build the caller's recovery options. Carrying them is what makes
65
+ * "the guard already has them" true: a second `getPrerequisiteBlockers` call from the denial
66
+ * would re-read a run tree that is on disk and unlocked, and could answer differently.
67
+ *
68
+ * Absent for every rule that decides on its pattern alone, and absent when the predicate read
69
+ * nothing — so its presence means "this refusal was decided from these blockers", not "the
70
+ * policy mentions blockers".
71
+ */
72
+ blockers?: readonly PrerequisiteBlocker[]
61
73
  }
62
74
 
63
75
  /**
64
76
  * Extra facts a rule predicate may need. `args` is always the tool call's
65
77
  * arguments; the run coordinates are present only when the caller has them
66
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.
67
84
  */
68
85
  export interface ToolPolicyContext {
69
86
  args: Record<string, unknown>
@@ -99,6 +116,12 @@ export interface ToolPolicyContext {
99
116
  export interface ToolPolicyPredicateMatch {
100
117
  verdict: Verdict
101
118
  detail?: string
119
+ /**
120
+ * The blockers the predicate READ, when it read any (see `Decision.blockers`). Optional and
121
+ * additive: a predicate that decided from something else returns none, and the engine's
122
+ * verdict is unchanged either way.
123
+ */
124
+ blockers?: readonly PrerequisiteBlocker[]
102
125
  }
103
126
 
104
127
  export type ToolPolicyPredicate = (
@@ -338,7 +361,7 @@ export function evaluateToolPolicy(
338
361
  // Abstention: this rule does not govern this call, so it does not
339
362
  // participate and the next rule in precedence order decides.
340
363
  if (match === null) continue
341
- return decide(rule, match.verdict, match.detail ? rule.reason + ' ' + match.detail : rule.reason)
364
+ return decide(rule, match.verdict, match.detail ? rule.reason + ' ' + match.detail : rule.reason, match.blockers)
342
365
  }
343
366
  return decide(rule, rule.verdict, rule.reason)
344
367
  }
@@ -347,10 +370,17 @@ export function evaluateToolPolicy(
347
370
  return { kind: 'ask', reason: 'no policy rule matches ' + id + ' - ask is the no-match default' }
348
371
  }
349
372
 
350
- /** One place where a rule's verdict becomes a decision, so `label` cannot drift. */
351
- function decide(rule: ToolPolicyRule, kind: Verdict, reason: string): Decision {
373
+ /**
374
+ * One place where a rule's verdict becomes a decision, so `label` cannot drift.
375
+ *
376
+ * `blockers` rides along untouched when the predicate supplied any (see `Decision.blockers`);
377
+ * an EMPTY list is dropped rather than carried, so `blockers` on a decision always means "there
378
+ * were blockers", never "the rule looked and found none".
379
+ */
380
+ function decide(rule: ToolPolicyRule, kind: Verdict, reason: string, blockers?: readonly PrerequisiteBlocker[]): Decision {
352
381
  const decision: Decision = { kind, reason }
353
382
  if (rule.label) decision.rule = rule.label
383
+ if (blockers !== undefined && blockers.length > 0) decision.blockers = blockers
354
384
  return decision
355
385
  }
356
386
 
@@ -419,13 +449,39 @@ export const LOCK_TOOL_NAMES = new Set(['recursive_lock', 'recursive_lock_phase'
419
449
  * sentence; the predicate adds the blocking artifact and its status, which is
420
450
  * what distinguishes a guard refusal from `lockArtifact`'s own
421
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.
422
470
  */
423
- function lockOrderRule(artifact: unknown, runDir: string | undefined): ToolPolicyPredicateMatch | null {
471
+ function lockOrderRule(artifact: unknown, runDir: string | undefined, runId?: string): ToolPolicyPredicateMatch | null {
424
472
  const name = String(artifact ?? '')
425
473
  if (!name || !runDir) return null
426
474
  const blockers = getPrerequisiteBlockers(runDir, name)
427
475
  if (blockers.length === 0) return null
428
- return { verdict: 'deny', detail: blockers.map((b) => b.artifact + ' (' + b.status + ')').join(', ') }
476
+ const where = typeof runId === 'string' && runId.trim() !== '' ? ' [run: ' + runId.trim() + ']' : ''
477
+ return {
478
+ verdict: 'deny',
479
+ detail: blockers.map((b) => b.artifact + ' (' + b.status + ')').join(', ') + where,
480
+ // THE SAME READ, carried up rather than thrown away: the denial's recovery options are built
481
+ // from these blockers, and re-deriving them one layer higher would be a second filesystem
482
+ // answer to a question this rule has already answered.
483
+ blockers,
484
+ }
429
485
  }
430
486
 
431
487
  /**
@@ -571,7 +627,7 @@ export function builtInToolPolicyRules(): ToolPolicyRule[] {
571
627
  verdict: 'deny',
572
628
  reason: 'monotonic lock-order: an earlier phase must be locked first',
573
629
  label: 'lock-order',
574
- 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),
575
631
  },
576
632
  ]
577
633
  for (const name of WRITE_TOOL_NAMES) {
@@ -630,7 +686,7 @@ export function builtInToolPolicyDefault(): ToolPolicy {
630
686
  export function attachPolicyPredicate(rule: ToolPolicyRule): ToolPolicyRule {
631
687
  if (rule.predicate) return rule
632
688
  if (rule.pattern === 'recursive_lock*') {
633
- 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) }
634
690
  }
635
691
  // ⚠ THE LABEL IS CONSULTED BEFORE THE PATTERN, because the phase-order rule and the
636
692
  // locked-artifact rule share EVERY write-tool pattern (see `builtInToolPolicyRules`).
@@ -170,6 +170,54 @@ export function buildAskQuestion(gateId: AskGateId): AskQuestion {
170
170
  })
171
171
  }
172
172
 
173
+ /**
174
+ * FU-7 — THE GATE-BLOCK REFUSAL PAYLOAD, BUILT IN ONE PLACE.
175
+ *
176
+ * A blocked lock is refused by TWO layers. The TOOL refuses when `lockArtifact` throws
177
+ * `Prerequisite blockers:`; the GUARD refuses pre-dispatch when the lock-order rule fires
178
+ * (`monotonic lock-order`), and under the strict default that is the layer the caller meets
179
+ * first. Both must hand the caller the SAME choice, so both build it HERE.
180
+ *
181
+ * ⚠ WHY NOT TWO LITERALS. `fix | reopen | abandon` is the human's way out of a blocked lock,
182
+ * and it existed in exactly one call site (`recursive_lock.tool.ts`). The guard's refusal moved
183
+ * to the default path, and a second hand-written copy of the options there would be a second
184
+ * answer to "what can a person do about this?": the day the options change, one of the two
185
+ * refusals keeps offering the old set and nothing fails. One builder, called from both.
186
+ *
187
+ * `blocked` is the refusal's OWN sentence — the guard's rule text or the tool's exception
188
+ * message — carried as data so the payload says what it is about without a reader having to
189
+ * match it against the text beside it.
190
+ */
191
+ export interface GateBlockAsk extends AskQuestion {
192
+ gate: 'gate-block'
193
+ artifact: string
194
+ blocked: string
195
+ }
196
+
197
+ /** The payload, as the refusal carries it. Validated through `buildAskQuestion`. */
198
+ export function buildGateBlockAsk(artifact: string, blocked: string): GateBlockAsk {
199
+ return { gate: 'gate-block', ...buildAskQuestion('gate-block'), artifact, blocked }
200
+ }
201
+
202
+ /**
203
+ * FU-7 — THE OPTIONS AS TEXT, DERIVED FROM THE PAYLOAD rather than restated.
204
+ *
205
+ * WHY A RENDERER IS NEEDED AT ALL: the harness renders a `tools/pre-execute` denial as
206
+ * `Error: <reason>` and drops every other field of the decision (measured in
207
+ * `packages/core/tools`: `content: [{ type: 'text', text: 'Error: ' + denialReason }]`), so an
208
+ * ask that rode along as a SIBLING field would reach the model as nothing at all — which is
209
+ * exactly how a strict-by-default guard made the recovery path unreachable. The refusal
210
+ * therefore renders the payload into the text it hands back, and it renders THIS object, so
211
+ * the visible sentence and the structured payload cannot disagree.
212
+ */
213
+ export function renderGateBlockAsk(ask: GateBlockAsk): string {
214
+ const options = ask.options
215
+ .map((option) => option.label + (option.description === undefined ? '' : ' (' + option.description + ')'))
216
+ .join(' ')
217
+ const target = ask.artifact === '' ? 'recursive_ask gate=gate-block' : 'recursive_ask gate=gate-block artifact=' + ask.artifact
218
+ return ask.header + ': ' + ask.question + ' Options: ' + options + ' Answer with ' + target + '.'
219
+ }
220
+
173
221
  /**
174
222
  * PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
175
223
  *
@@ -1,6 +1,6 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import { codeRuntimeRefusal, toolError } from './errors.ts'
3
- import { buildAskQuestion } from './recursive_ask.tool.ts'
3
+ import { buildGateBlockAsk } from './recursive_ask.tool.ts'
4
4
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
5
5
  import type { RecursiveRuntime } from './runtime.ts'
6
6
 
@@ -37,10 +37,16 @@ export function createRecursiveLockTool(recursive: RecursiveRuntime) {
37
37
  // ⚠ IT IS ATTACHED TO THE ORDERING REFUSAL SPECIFICALLY (`Prerequisite blockers:`), because that
38
38
  // is the one a human resolves. A missing run id or an already-locked artifact is a caller mistake
39
39
  // with a mechanical fix, and offering "reopen / abandon the run" for those would be noise.
40
+ //
41
+ // ⚠ AND THE PAYLOAD COMES FROM `buildGateBlockAsk`, NOT FROM A LITERAL HERE. The GUARD refuses
42
+ // the same ordering violation pre-dispatch (it resolves the same blockers from the same run
43
+ // tree) and attaches this same payload; one builder is what keeps the two refusals offering the
44
+ // same options. This branch still fires whenever the guard ABSTAINS — most visibly when the call
45
+ // names a run other than the active one, because the guard resolves the run from the filesystem.
40
46
  if (message.startsWith('Prerequisite blockers:')) {
41
47
  return {
42
48
  error: refusal,
43
- ask: { gate: 'gate-block', ...buildAskQuestion('gate-block'), artifact: args.artifact ?? '', blocked: message },
49
+ ask: buildGateBlockAsk(args.artifact ?? '', message),
44
50
  } as unknown as JsonValue
45
51
  }
46
52
  return { error: refusal } as const
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
- const target = join(root, relativePath)
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
  }
@@ -1881,7 +1884,7 @@ export class RecursiveRuntime extends Service {
1881
1884
  return coupleGateBlockToGoal(goalService as never, agent, ref, reason)
1882
1885
  }
1883
1886
 
1884
- /** Phase C R7: resolve the enforcement config (strict|advisory, default advisory). */
1887
+ /** Phase C R7: resolve the enforcement config (strict|advisory, default strict). */
1885
1888
  get enforcementConfig(): EnforcementConfig {
1886
1889
  return this._enforcementConfig ?? DEFAULT_ENFORCEMENT
1887
1890
  }