@try-works/dsh-recursive-mode 0.6.0 → 0.7.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.
@@ -176,8 +176,13 @@ export const DEFAULT_ENFORCEMENT: EnforcementConfig = {
176
176
  * active phase must be locked before proceeding to next phase*: `'lock-order'` refuses
177
177
  * locking ahead, `'phase-order'` refuses WRITING ahead. Two labels rather than one,
178
178
  * because the guard log has to tell the owner which of the two the agent attempted.
179
+ *
180
+ * `'memory-read'` is the third WRITE-side rule and the owner's other requirement, verbatim: *the memory must
181
+ * be read before writing requirements.md*. It rides the same write-tool family as `'phase-order'` (see
182
+ * `memoryReadRule` in `policy-globs.ts`) and is listed here because this union is what the guard's decision
183
+ * record is typed by — a label the record's type does not know is a label the log cannot honestly carry.
179
184
  */
180
- export type GuardRule = 'lock-order' | 'phase-order' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none'
185
+ export type GuardRule = 'lock-order' | 'phase-order' | 'memory-read' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none'
181
186
 
182
187
  /** T15: the transition gate's verdict as attached to a decision (advisory only). */
183
188
  export interface GuardTransition {
@@ -49,6 +49,43 @@ export const LEGACY_FEEDBACK_FILE = 'memory/.feedback.json'
49
49
  /** Where a run records what it was shown. */
50
50
  export const INJECTIONS_FILE = 'memory-injections.json'
51
51
 
52
+ /**
53
+ * THE SUBJECT A READ-RECEIPT CARRIES, AND WHY IT CANNOT COLLIDE WITH A SHARD.
54
+ *
55
+ * ⚠ THE GAP THIS CLOSES, MEASURED. This file used to record only the SHARDS a phase was shown, so a phase
56
+ * whose selection came back empty — the documented, legitimate state of a workspace with no memory plane —
57
+ * left NO trace at all. "A record exists" and "the read happened" were therefore the same sentence, and a
58
+ * gate that required the former would REFUSE FOREVER in a fresh workspace: there would be nothing to
59
+ * record, so nothing would ever be recorded. `selectMemory` says it plainly (`memory.ts`): *"the memory
60
+ * plane is empty, so nothing is injected"* is an ANSWER, not a failure.
61
+ *
62
+ * So the read is recorded as a FACT OF ITS OWN — a receipt saying "at this phase entry, the plane was read,
63
+ * and this is what the read said" — instead of being inferred from what the read returned. An empty plane
64
+ * produces a receipt like any other, which is what makes the gate satisfiable in a new repo.
65
+ *
66
+ * ⚠ AND THE SUBJECT IS A RESERVED NAME. A memory entry's `source` is the shard's own PATH
67
+ * (`selectMemory` -> `loadMemoryIndex` -> `entry.source`), always a `.md` path under the memory plane, so
68
+ * `memory-read:attempt` is not a name any plane entry can hold. That is the property a collision would
69
+ * need to break the gate, and it is asserted in `tests/memory-feedback.spec.ts`.
70
+ */
71
+ export const MEMORY_READ_SOURCE = 'memory-read:attempt'
72
+
73
+ /** The receipt a phase entry leaves for a read that happened at `phase`. */
74
+ export interface MemoryReadRecord extends InjectionRecord {
75
+ /** Always {@link MEMORY_READ_SOURCE}. */
76
+ source: typeof MEMORY_READ_SOURCE
77
+ /** The phase ENTRY that performed the read — an artifact name (`00-requirements.md`), as shards use. */
78
+ phase: string
79
+ /** True when anything was injected; FALSE is a satisfied read, not a missing one. */
80
+ injected: boolean
81
+ /** How many shards were injected. `0` on an empty plane, which is the case this whole record exists for. */
82
+ shards: number
83
+ /** WHY the read returned what it did — `selectMemory`'s own reason, carried verbatim. */
84
+ reason: string
85
+ /** Always `0`: a receipt is not a ranking, so it can never move a counter. */
86
+ score: 0
87
+ }
88
+
52
89
  /** One entry the agent was shown, as the run recorded it. */
53
90
  export interface InjectionRecord {
54
91
  /** The entry's source path, which is also its identity in the counters. */
@@ -116,12 +153,95 @@ export function readInjections(runDir: string, readFile: (path: string) => strin
116
153
  }
117
154
  }
118
155
 
156
+ /**
157
+ * True for the read receipt, false for a shard the phase was shown.
158
+ *
159
+ * ⚠ THE DISCRIMINATOR IS THE SUBJECT **AND** THE SHAPE. The reserved subject alone would be enough if no
160
+ * plane entry could hold it, but a caller can hand-write this file, so a row is a receipt only when it also
161
+ * carries the receipt's own fields. A hand-edited file therefore cannot make a shard row look like a read,
162
+ * and `tests/memory-feedback.spec.ts` asserts the negative case.
163
+ *
164
+ * Accepts `unknown` rather than `InjectionRecord` because its callers hold JSON off disk (an array of
165
+ * whatever the file contains), and a type guard that could only be applied to a value already known to be
166
+ * well-shaped would not be worth having.
167
+ */
168
+ export function isMemoryReadRecord(record: unknown): record is MemoryReadRecord {
169
+ if (record === null || typeof record !== 'object') return false
170
+ const candidate = record as Partial<MemoryReadRecord>
171
+ return candidate.source === MEMORY_READ_SOURCE
172
+ && typeof candidate.phase === 'string'
173
+ && typeof candidate.injected === 'boolean'
174
+ && typeof candidate.shards === 'number'
175
+ && typeof candidate.reason === 'string'
176
+ }
177
+
178
+ /**
179
+ * The read receipts this run holds, in the file's own deterministic order.
180
+ *
181
+ * The reader the gate uses (see `hasMemoryRead` in `policy-globs.ts`): it is a NON-EMPTY answer even when
182
+ * every receipt says `injected: false`, because a receipt is evidence that the read RAN.
183
+ */
184
+ export function readMemoryReads(runDir: string, readFile: (path: string) => string | null = defaultRead): MemoryReadRecord[] {
185
+ return readInjections(runDir, readFile).filter(isMemoryReadRecord)
186
+ }
187
+
188
+ /**
189
+ * Record that a phase entry READ the memory plane, whatever the read returned.
190
+ *
191
+ * ⚠ THIS IS THE ATTEMPT, NOT THE RESULT, AND THE DIFFERENCE IS THE WHOLE POINT. `recordInjection` can only
192
+ * write a row when a shard was selected, so it is silent on an empty plane — and an empty plane is a
193
+ * legitimate state a fresh workspace is in, not a failure to record. A receipt written here says "the plane
194
+ * was read at this phase entry, and the read answered: <reason>", so `injected: false` is a SATISFIED read.
195
+ *
196
+ * ⚠ ONE RECEIPT PER PHASE, REPLACED (not appended). A phase is re-entered while it is still DRAFT — that is
197
+ * the ordinary path, not an edge case — so appending would turn one read per entry into an unbounded log
198
+ * and make the file grow with every reminder. The merge key is the phase, the newest read wins, and the
199
+ * rewrite is byte-identical when the same phase is read twice with the same answer, which is the
200
+ * determinism the lock receipts and the selection output already follow.
201
+ *
202
+ * ⚠ AND IT SHARES THE FILE WITH THE SHARD ROWS rather than living in a second sidecar: `recordInjection`
203
+ * preserves receipts when it rewrites (below), so the two writers cannot erase each other. A separate file
204
+ * would be a second answer to "what did this run read", which is the drift this repo keeps paying for.
205
+ */
206
+ export function recordMemoryRead(
207
+ runDir: string,
208
+ phase: string,
209
+ read: { injected: boolean; shards: number; reason: string },
210
+ write: (path: string, content: string) => void = defaultWrite,
211
+ readFile: (path: string) => string | null = defaultRead,
212
+ ): MemoryReadRecord[] {
213
+ const existing = readMemoryReads(runDir, readFile)
214
+ const byPhase = new Map<string, MemoryReadRecord>()
215
+ for (const record of existing) byPhase.set(record.phase, record)
216
+ byPhase.set(phase, {
217
+ source: MEMORY_READ_SOURCE,
218
+ // The title is the phase, so the row is readable in the file without decoding the receipt.
219
+ title: phase,
220
+ phase,
221
+ score: 0,
222
+ injected: read.injected,
223
+ shards: read.shards,
224
+ reason: read.reason,
225
+ })
226
+ const receipts = [...byPhase.values()].sort(compareRecords)
227
+ // The shard rows are carried through UNTOUCHED — see the note on `recordInjection` for why each writer
228
+ // must preserve the other's rows.
229
+ const shards = readInjections(runDir, readFile).filter((record) => !isMemoryReadRecord(record))
230
+ write(join(runDir, INJECTIONS_FILE), JSON.stringify(sortRecords([...shards, ...receipts]), null, 2) + '\n')
231
+ return receipts
232
+ }
233
+
119
234
  /**
120
235
  * Record what the run was shown, MERGED by (source, title, phase).
121
236
  *
122
237
  * ⚠ MERGED RATHER THAN APPENDED, because a phase can be re-entered while it is still DRAFT and the same
123
238
  * entries are selected again. Appending would count one decision as four, and the counters exist to be
124
239
  * evidence. The highest score seen wins, since that is what the agent was most recently shown.
240
+ *
241
+ * ⚠ AND THE READ RECEIPTS SURVIVE THE REWRITE. This function owns the file, so a version of it that wrote
242
+ * only `merged` would delete the receipt `recordMemoryRead` had just written — the gate would then refuse a
243
+ * write in the same phase entry that satisfied it. The two kinds of row are therefore written together,
244
+ * sorted by the same key, and a receipt is never a candidate for the score merge (it carries no shard).
125
245
  */
126
246
  export function recordInjection(
127
247
  runDir: string,
@@ -132,16 +252,19 @@ export function recordInjection(
132
252
  ): InjectionRecord[] {
133
253
  const existing = readInjections(runDir, readFile)
134
254
  const byKey = new Map<string, InjectionRecord>()
135
- for (const record of existing) byKey.set(keyOf(record), record)
255
+ for (const record of existing) {
256
+ if (isMemoryReadRecord(record)) continue
257
+ byKey.set(keyOf(record), record)
258
+ }
136
259
  for (const entry of entries) {
137
260
  const candidate: InjectionRecord = { source: entry.source, title: entry.title, phase, score: entry.score }
138
261
  const key = keyOf(candidate)
139
262
  const prior = byKey.get(key)
140
263
  byKey.set(key, prior === undefined || candidate.score > prior.score ? candidate : prior)
141
264
  }
265
+ const receipts = existing.filter(isMemoryReadRecord)
142
266
  // Deterministic order, so the file is comparable between runs.
143
- const merged = [...byKey.values()].sort((a, b) => a.phase.localeCompare(b.phase)
144
- || a.source.localeCompare(b.source) || a.title.localeCompare(b.title))
267
+ const merged = sortRecords([...byKey.values(), ...receipts])
145
268
  write(join(runDir, INJECTIONS_FILE), JSON.stringify(merged, null, 2) + '\n')
146
269
  return merged
147
270
  }
@@ -173,6 +296,13 @@ export function settleInjections(
173
296
  // what to do with it; what is missing is an honest SOURCE for it (a re-opened phase), and until there is
174
297
  // one, this function reports only what it can prove.
175
298
  for (const record of injections) {
299
+ // ⚠ A READ RECEIPT IS NOT AN INJECTION, so it can never settle a counter. It carries no shard — its
300
+ // subject is the reserved {@link MEMORY_READ_SOURCE}, which no memory entry can hold — so without this
301
+ // line an empty plane would write a counter row for a source that does not exist and the book would
302
+ // carry evidence about nothing. Filtered HERE rather than at the reader, because `readInjections` is a
303
+ // documented shape (`tests/memory-feedback.spec.ts` pins the row it returns) and a second reader would
304
+ // be a second answer to "what is in this file".
305
+ if (isMemoryReadRecord(record)) continue
176
306
  if (!locked.has(record.phase)) continue
177
307
  const counter = book[record.source] ?? { applied: 0, contradicted: 0 }
178
308
  counter.applied += 1
@@ -206,6 +336,21 @@ function countPhase(injections: readonly InjectionRecord[], phase: string): numb
206
336
  return injections.filter((record) => record.phase === phase).length
207
337
  }
208
338
 
339
+ /**
340
+ * The file's ONE ordering, applied by both writers: phase, then source, then title.
341
+ *
342
+ * Both writers sort through this function rather than each carrying a copy, so two rows for one phase can
343
+ * never end up in an order that depends on which writer ran last — the property that keeps the file
344
+ * comparable between runs, and between a `recursive_phase` call and a `recursive_init` call.
345
+ */
346
+ function compareRecords(a: InjectionRecord, b: InjectionRecord): number {
347
+ return a.phase.localeCompare(b.phase) || a.source.localeCompare(b.source) || a.title.localeCompare(b.title)
348
+ }
349
+
350
+ function sortRecords(records: readonly InjectionRecord[]): InjectionRecord[] {
351
+ return [...records].sort(compareRecords)
352
+ }
353
+
209
354
  function keyOf(record: { source: string; title: string; phase: string }): string {
210
355
  return record.phase + '\u0000' + record.source + '\u0000' + record.title
211
356
  }
package/src/memory.ts CHANGED
@@ -149,12 +149,38 @@ export function readMemoryEntries(
149
149
  return entries
150
150
  }
151
151
 
152
- /** Render the retrieved memory for a review bundle: sections a reviewer can cite by title. */
153
- export function renderMemorySection(entries: readonly MemoryEntry[]): string { if (entries.length === 0) {
154
- return 'No prior-run memory matched this review. Do not assume the absence is conclusive:'
152
+ /**
153
+ * WHERE A RENDERED SECTION IS GOING TO BE READ — the one thing the section's wording depends on.
154
+ *
155
+ * ⚠ WHY THIS IS A PARAMETER AND NOT A SECOND RENDERER. The section is rendered for two readers: a
156
+ * delegated REVIEW bundle (`runtime.ts` `buildReviewBundle`, context `'review'`) and the PHASE-ENTRY
157
+ * payload `recursive_phase` returns (`runtime.ts` `phaseRules`, context `'phase'`). The wording used to
158
+ * be the reviewer's — *"Prior-run memory relevant to this REVIEW"* — and the phase payload inherited it,
159
+ * so an agent that had just been handed prior-run learning at phase entry was told it was a review: the
160
+ * reader the text addressed was not the reader holding it. Copying the renderer to change one sentence
161
+ * would have been two renderers to keep in step, which is how the two answers to one question appear in
162
+ * this plugin; the sentence is instead selected from this context, so there is still ONE renderer.
163
+ */
164
+ export type MemoryRenderContext = 'review' | 'phase'
165
+
166
+ /** Render the retrieved memory: sections a reader can cite by title. See {@link MemoryRenderContext}. */
167
+ export function renderMemorySection(
168
+ entries: readonly MemoryEntry[],
169
+ context: MemoryRenderContext = 'review',
170
+ ): string {
171
+ // The default is `'review'` so every existing caller keeps the sentence it has always rendered — a
172
+ // truthfulness fix must not silently restate what a caller already read (`tests/memory-retrieval.spec.ts`
173
+ // pins that wording). The phase payload opts IN, which is the change the owner's second point asks for.
174
+ const where = context === 'phase' ? 'this phase' : 'this review'
175
+ if (entries.length === 0) {
176
+ return 'No prior-run memory matched ' + where + '. Do not assume the absence is conclusive:'
155
177
  + ' memory is retrieved by relevance to the artifact and phase, not by recency.'
156
178
  }
157
- const lines: string[] = ['Prior-run memory relevant to this review (cite by title if you rely on it):']
179
+ const lines: string[] = [context === 'phase'
180
+ // The phase payload's whole purpose is to put prior-run learning IN FRONT of the work, so the heading
181
+ // says what to do with it — rely on it, and cite it so the reliance is traceable.
182
+ ? 'Prior-run memory injected for this phase (rely on it, and cite by title when you use it):'
183
+ : 'Prior-run memory relevant to this review (cite by title if you rely on it):']
158
184
  for (const entry of entries) {
159
185
  lines.push('')
160
186
  lines.push('### [' + entry.kind + '] ' + entry.title)
@@ -42,6 +42,7 @@
42
42
  import { existsSync, readFileSync } from 'node:fs'
43
43
  import { basename, dirname, join, resolve } from 'node:path'
44
44
  import { getLockStatus, getPrerequisiteBlockers, type PrerequisiteBlocker } from './lock.ts'
45
+ import { readMemoryReads } from './memory-feedback.ts'
45
46
  import { getMdFieldValue } from './status.ts'
46
47
  import { phaseNumberForArtifact, policyTargetPath, resolveFrom } from './phase-rules.ts'
47
48
 
@@ -602,6 +603,96 @@ function phaseOrderRule(target: string | null, ctx: ToolPolicyContext): ToolPoli
602
603
  }
603
604
  }
604
605
 
606
+ /**
607
+ * THE MEMORY-READ GATE — the owner's rule, verbatim: *"the memory must be read before writing requirements.md"*.
608
+ *
609
+ * ⚠ THE ORDERING WAS INSTRUCTED AND NOT ENFORCED, and that is what this predicate changes. `recursive_phase`
610
+ * calls `selectMemory` and hands the run what it found (`runtime.ts` `phaseRules`), so memory reaches the
611
+ * agent at PHASE ENTRY — while `recursive_init` reads no memory at all. A run could therefore author
612
+ * `00-requirements.md`, the artifact that defines what the whole run builds, BEFORE anything from
613
+ * `.recursive/memory/` had reached it, and nothing refused that.
614
+ *
615
+ * ⚠ AND THE TRAP THAT DECIDES THE WHOLE DESIGN: `recordInjection` records the SHARDS, so when the memory
616
+ * plane is EMPTY nothing is written — and a gate keyed on "a record exists" would then REFUSE FOREVER in a
617
+ * fresh workspace. A repo with no memory is a legitimate, expected state (`selectMemory` answers
618
+ * *"the memory plane is empty, so nothing is injected"*), so this predicate reads the READ RECEIPT rather
619
+ * than the shards: `recordMemoryRead` writes one on every phase entry, with the selection's own reason and
620
+ * `injected: false` when nothing matched. An empty plane therefore SATISFIES the gate, a never-entered
621
+ * phase does not, and the two cases cannot be confused because one of them has a row.
622
+ *
623
+ * WHAT IT REFUSES, exactly: a write to a PHASE-0 artifact (`00-requirements.md` / `00-worktree.md` — the two
624
+ * files `recursive_init` scaffolds, which share phase number 0 and which the phase-order rule already treats
625
+ * as one phase) when this run holds no read receipt for phase 0, and the target is not already LOCKED.
626
+ * Everything else abstains, so the rule costs a run nothing it should not pay:
627
+ *
628
+ * - a target that is not a DIRECT CHILD of this run's directory — an ordinary product file, and the run's
629
+ * support files (`evidence/`, `scratch/`, `operations/`, a plain `<run>/notes.md`) — ABSTAINS. This is
630
+ * what keeps the gate off the write path in general: `tests/strict-run-tree.spec.ts` walks every one of
631
+ * those and asserts they stay writable.
632
+ * - a target whose phase number is anything but `0` (a LATER phase, and an EARLIER one, which cannot exist
633
+ * at phase 0) ABSTAINS: the ordering rules own those verdicts. A later phase in particular must keep
634
+ * reporting `phase-order`, which is why this rule is declared LAST of the three that share these
635
+ * patterns (file order within a specificity tier: locked-write, then phase-order, then this rule).
636
+ * - a LOCKED target ABSTAINS: the locked-artifact rule owns it, and a completed run is not re-gated.
637
+ * - a target that does not exist AT ALL does not abstain: `recursive_init` is not the only way to reach
638
+ * phase 0, and a run whose requirements were deleted still has to read before it writes them again.
639
+ * (In practice the refusal that fires there is the phase-order rule — an absent ACTIVE artifact still
640
+ * makes the run phase 0 — so this rule is the second answer, not the first.)
641
+ *
642
+ * ⚠ RESUMED AND EXISTING RUNS. The gate is decided from the receipt alone — never from the artifact's text,
643
+ * which a caller controls and could therefore forge — so the recovery is the same for every run, old or
644
+ * new: read memory for this phase. `recursive_phase` is that call, it costs one call, and a run whose
645
+ * `00-requirements.md` was written before this rule existed keeps every other guarantee it had (a completed
646
+ * or locked phase 0 is untouched above; a run parked at phase 0 simply makes the read it never made). The
647
+ * refusal SAYS that, because a refusal a caller cannot act on is the failure mode this rule must not have.
648
+ *
649
+ * ⚠ MODE SEMANTICS FOLLOW THE EXISTING CONTRACT EXACTLY (`enforcement.ts` `verdictFor`): a policy `deny`
650
+ * under `strict` blocks, and under `advisory` becomes an `ask` that the live path coerces to an
651
+ * allow-WITH-WARNING — never a silent allow, never a block. Like the phase-order rule, this is a `deny`
652
+ * verdict, so the mode decides it and nothing here special-cases the mode.
653
+ *
654
+ * WHERE IT LIVES, AND WHY THIS LAYER RATHER THAN `lockArtifact`. The precedent in `lockArtifact` (the
655
+ * phase-8 memory gate, `runtime.ts`) guards a LOCK: it is a state check on a run whose artifact already
656
+ * exists, placed after quiescence and before lint. This rule guards a WRITE, and its whole subject is that
657
+ * the write must not happen — a check at lock time would be too late by exactly the phase it is about, since
658
+ * the requirements document has by then been authored and every later phase built on it. The repo already
659
+ * has the write-side layer for ordering (`phase-order`), applied to the same write-tool family through
660
+ * `attachPolicyPredicate`; a second copy of the check in `lockArtifact` would be the duplicate this repo has
661
+ * ruled out, so there is exactly one, here.
662
+ */
663
+ function memoryReadRule(target: string | null, ctx: ToolPolicyContext): ToolPolicyPredicateMatch | null {
664
+ if (!target || !ctx.runDir || !ctx.worktreeRoot) return null
665
+ const normalized = target.replace(/\\/g, '/')
666
+ if (!normalized.endsWith('.md')) return null
667
+ const abs = resolveFrom(ctx.worktreeRoot, normalized)
668
+ if (!abs) return null
669
+ const name = directChildName(abs, ctx.runDir)
670
+ if (name === null) return null
671
+ if (phaseNumberForArtifact(name) !== '0') return null
672
+ if (getLockStatus(abs) === 'LOCKED') return null
673
+ if (hasMemoryRead(ctx.runDir)) return null
674
+ return {
675
+ verdict: 'deny',
676
+ detail: name + ': no memory read is recorded for phase 0 of this run - call recursive_phase, which reads'
677
+ + ' the memory plane and records it, then write this artifact (an EMPTY memory plane satisfies this:'
678
+ + ' the read is what is required, not a match)',
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Read the run's read receipts and answer whether PHASE 0 has been read.
684
+ *
685
+ * ⚠ EITHER PHASE-0 ARTIFACT COUNTS, and that is a decision rather than a shortcut: `00-requirements.md` and
686
+ * `00-worktree.md` share phase number 0, `currentPhaseArtifact` reports whichever of the two the directory
687
+ * listing yields first, and `runtime.phaseRules` records the receipt under the artifact `getNextLegalPhase`
688
+ * named — so keying the gate on the ONE name that happened to be active would make the verdict depend on
689
+ * `readdirSync` order. `tests/strict-run-tree.spec.ts` asserts the two are one phase for the ordering rule;
690
+ * this makes the gate agree with it. The phase NUMBER is the key, not the name, for exactly that reason.
691
+ */
692
+ function hasMemoryRead(runDir: string): boolean {
693
+ return readMemoryReads(runDir).some((receipt) => phaseNumberForArtifact(receipt.phase) === '0')
694
+ }
695
+
605
696
  /**
606
697
  * The BUILT-IN default rule list — the pre-T16 guard behaviour expressed as
607
698
  * data:
@@ -653,6 +744,19 @@ export function builtInToolPolicyRules(): ToolPolicyRule[] {
653
744
  predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? phaseOrderRule(policyTargetPath(args), ctx) : null),
654
745
  })
655
746
  }
747
+ // …and a THIRD conditional deny on the same patterns: the memory-read gate. It is LAST of the three
748
+ // deliberately, because it is the narrowest of them and the other two must keep their labels on the
749
+ // cases they already own (an out-of-order write is a phase-order refusal, not a memory one). The engine
750
+ // decides equally specific rules by FILE ORDER, so this position is the precedence.
751
+ for (const name of WRITE_TOOL_NAMES) {
752
+ rules.push({
753
+ pattern: name,
754
+ verdict: 'deny',
755
+ reason: 'memory read gate: memory must be read before the requirements artifact that defines the run is written',
756
+ label: 'memory-read',
757
+ predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? memoryReadRule(policyTargetPath(args), ctx) : null),
758
+ })
759
+ }
656
760
  rules.push({
657
761
  pattern: '*',
658
762
  verdict: 'allow',
@@ -700,6 +804,13 @@ export function attachPolicyPredicate(rule: ToolPolicyRule): ToolPolicyRule {
700
804
  if (rule.label === 'phase-order') {
701
805
  return { ...rule, predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? phaseOrderRule(policyTargetPath(args), ctx) : null) }
702
806
  }
807
+ // …and the memory-read gate is attached the same way, by LABEL. Without this branch the file's rule
808
+ // would take the pattern's condition below (the locked-artifact one) and the gate would exist in the
809
+ // built-in list but never in a repo that ships the policy file — which is this repo, and every repo
810
+ // scaffolded from it.
811
+ if (rule.label === 'memory-read') {
812
+ return { ...rule, predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? memoryReadRule(policyTargetPath(args), ctx) : null) }
813
+ }
703
814
  if (WRITE_TOOL_NAMES.has(rule.pattern)) {
704
815
  return { ...rule, predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null) }
705
816
  }
package/src/policy.ts CHANGED
@@ -114,6 +114,23 @@ export function renderStableContract(config: EnforcementConfig = DEFAULT_ENFORCE
114
114
  '- Phase order binds WRITES as well as locks: only the ACTIVE phase (the lowest-numbered artifact not yet LOCKED) may be written; a write to a LATER phase artifact is denied/asked. Run support files (evidence/, scratch/, addenda/, subagents/, operations/) are not phases.',
115
115
  '- Phase 3 lock requires TDD evidence (strict) or rationale (pragmatic); Phase 5 requires QA evidence.',
116
116
  '- The control-plane root is resolved STRICTLY from this session workspace (never scanned from another).',
117
+ // ⚠ THE CONTRACT NOW SAYS MEMORY IS READ, AND IT HAS TO SAY IT HERE RATHER THAN IN THE TAIL.
118
+ //
119
+ // MEASURED BEFORE THIS LINE EXISTED: `grep -E 'memor|shard|learn' src/policy.ts` returned ZERO hits, so
120
+ // the model-facing contract — the text the agent reads on every turn — never mentioned that prior-run
121
+ // memory is read at phase entry, never said what an empty plane means, and never said to cite what it
122
+ // relies on. The read itself was not missing (`runtime.phaseRules` calls `selectMemory` and returns the
123
+ // section); it was INVISIBLE, which is the same class of defect that produced three live runs with zero
124
+ // locks: a mechanism nothing announces is a mechanism the model has no reason to use.
125
+ //
126
+ // It belongs in the STABLE prefix, not the per-phase tail, because it is true of every phase — the tail
127
+ // is precisely what changes between them. It carries NO selection (no shard, no count, no match), so the
128
+ // prefix stays byte-identical for the whole run, which is the split's only precondition.
129
+ '- Memory is READ AT PHASE ENTRY by recursive_phase: it returns what the memory plane holds for this run'
130
+ + ' and phase (the selected shards, or `memoryReason` saying why nothing was injected). Read it when'
131
+ + ' entering a phase, rely on what it gives you, and cite a shard by title wherever you act on it. An'
132
+ + ' EMPTY plane is a normal result, not a failure: "the memory plane is empty, so nothing is injected"'
133
+ + ' is an answer, and the phase proceeds with what the run itself knows.',
117
134
  ].join('\n')
118
135
  }
119
136
 
@@ -11,6 +11,22 @@ import type { RecursiveRuntime } from './runtime.ts'
11
11
  * reminder, so the agent can re-ask for the rules without re-injecting them on
12
12
  * every step. Returns { error } when no active phase is found.
13
13
  *
14
+ * ⚠ AND IT IS WHERE PRIOR-RUN MEMORY ARRIVES — say so in the DESCRIPTION, which is the only surface a
15
+ * model reads before choosing a tool. The description used to promise rules and instructions only, so
16
+ * nothing in the tool list gave a caller a reason to expect memory here (the owner's own report: *"the
17
+ * memory must be read before writing requirements.md"*, and *"recursive_phase should also read memories"*).
18
+ * The read WAS already happening — `phaseRules` calls `selectMemory` and returns the section plus its
19
+ * reason — so the defect was that the contract was SILENT about it, not that the mechanism was absent.
20
+ * Every sentence below is a claim about what this call actually returns: the payload's `memory` and
21
+ * `memoryReason` fields come from `selectMemory`, `runId`/`phase` from the resolved run, `requiredSections`
22
+ * / `audited` / `tdd` / `qa` / `memoryWrite` from `phaseRulesFor`, and `ask` from `pendingGateFor`. A
23
+ * description that advertised a field the payload does not carry would be worse than the silence it fixes.
24
+ *
25
+ * ⚠ AND AN EMPTY PLANE IS SAID TO BE NORMAL, because that is the honest reading and the one a model needs:
26
+ * `selectMemory` answers *"the memory plane is empty, so nothing is injected"*, which is a RESULT — the
27
+ * plane was read and had nothing to say — not a failure of the call and not a reason to retry it. Without
28
+ * that sentence an agent seeing an empty section could reasonably conclude the read had not happened.
29
+ *
14
30
  * A RUN ID IS A NAME, NOT A PATH HERE TOO, INCLUDING WHEN IT IS OMITTED. Omitted and path-shaped are
15
31
  * different cases and must stay different: omitted means "the latest run by mtime", which `resolveRunDir`
16
32
  * answers by DISCOVERY rather than by joining anything, and that case is untouched below. A path-shaped id,
@@ -22,7 +38,14 @@ import type { RecursiveRuntime } from './runtime.ts'
22
38
  export function createRecursivePhaseTool(recursive: RecursiveRuntime) {
23
39
  return defineTool({
24
40
  name: 'recursive_phase',
25
- description: 'Return the lint rules + instructions for the current recursive-mode phase (required sections, gates, TDD/QA notes). Call once when entering a new phase; the same rules are also auto-injected once per phase transition.',
41
+ description: 'Enter the current recursive-mode phase: returns that phase\'s lint rules and instructions'
42
+ + ' (required sections, gates, TDD/QA notes) AND the prior-run memory selected for this run and phase.'
43
+ + ' `memory` carries the shards to use, each titled so it can be cited, and `memoryReason` says why'
44
+ + ' nothing was injected when it is empty — an EMPTY memory plane is a normal result of a read, never'
45
+ + ' a failure, so do not retry because of it. Nothing from the memory plane reaches a run before this'
46
+ + ' call, so MAKE IT ONCE WHEN ENTERING A PHASE and before authoring that phase\'s artifact'
47
+ + ' (`00-requirements.md` included: a write to it is refused until a read is recorded for the run).'
48
+ + ' The same rules are also auto-injected once per phase transition.',
26
49
  parameters: {
27
50
  runId: { type: 'string', description: 'Optional run id — the NAME of the run directory under .recursive/run/ (e.g. 03-something), never a path: ' + RUN_ID_RULE + '. Omit it for the latest run by mtime.' },
28
51
  },