@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.
- package/lib/enforcement.d.ts +6 -1
- package/lib/index.js +597 -198
- package/lib/memory-feedback.d.ts +83 -0
- package/lib/memory.d.ts +15 -2
- package/lib/recursive_phase.tool.d.ts +16 -0
- package/package.json +1 -1
- package/scripts/check-workflow-map-escapes.mjs +103 -0
- package/scripts/check-workflow-map.mjs +423 -0
- package/scripts/gen-workflow-map.mjs +2812 -0
- package/src/enforcement.ts +6 -1
- package/src/memory-feedback.ts +148 -3
- package/src/memory.ts +30 -4
- package/src/policy-globs.ts +111 -0
- package/src/policy.ts +17 -0
- package/src/recursive_phase.tool.ts +24 -1
- package/src/runtime.ts +1975 -1945
package/src/enforcement.ts
CHANGED
|
@@ -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 {
|
package/src/memory-feedback.ts
CHANGED
|
@@ -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)
|
|
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()
|
|
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
|
-
/**
|
|
153
|
-
|
|
154
|
-
|
|
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[] = [
|
|
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)
|
package/src/policy-globs.ts
CHANGED
|
@@ -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: '
|
|
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
|
},
|