@try-works/dsh-recursive-mode 0.4.2 → 0.4.4

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.
@@ -19,7 +19,32 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
19
19
  import { dirname, join } from 'node:path'
20
20
 
21
21
  /** Where the machine-owned counters live — inside the memory plane, but never a shard a person writes. */
22
- export const FEEDBACK_FILE = 'memory/.feedback.json'
22
+ // The path is `.recursive/memory/.feedback.json`, joined onto the REPO ROOT, which is what the
23
+ // doc comment above has always said and what `filterRuntimeChangedFiles` expects: a counter the
24
+ // memory plane writes must live inside the `.recursive` control plane, not in the product tree.
25
+ // Measured before this fix: a completed fixture run ended with four residual FAILs naming
26
+ // `memory/.feedback.json` against phases 03 and 03.5, because the file landed outside
27
+ // `.recursive/run/<runId>/`, survived the runtime-diff filter, and entered the run's diff only
28
+ // after those phases were authored - retro-invalidating them. The counter is machine-owned and
29
+ // disposable per the same doc comment, so a counter left at the OLD path by an earlier version is
30
+ // READ once (see LEGACY_FEEDBACK_FILE) rather than silently dropped.
31
+ export const FEEDBACK_FILE = '.recursive/memory/.feedback.json'
32
+
33
+ /**
34
+ * WHERE THE COUNTERS LIVED BEFORE the constant above carried its `.recursive/` prefix.
35
+ *
36
+ * ⚠ READ, BUT DELIBERATELY NEITHER MOVED NOR DELETED. Read, because evidence a previous run recorded is
37
+ * not the plugin's to discard, and without the fallback the first settle after the move would start the
38
+ * new file from an empty book — a counter lost silently, which is the one outcome ruled out. NOT moved,
39
+ * because a delete is the single action that could re-create the very defect the prefix fixes: a legacy
40
+ * file that is TRACKED and COMMITTED is absent from the run's diff while it is clean, so removing it
41
+ * mid-run puts `D memory/.feedback.json` into the diff of every diff-audited phase authored before the
42
+ * delete, which is the same retro-invalidation. Left alone, an untracked legacy file is in the diff from
43
+ * the run's first phase (and is therefore accounted for), while a committed one stays invisible.
44
+ * `readFeedback` prefers {@link FEEDBACK_FILE}, so the legacy counters are folded forward by the next
45
+ * settle and this file then only sits there; it is machine-owned and disposable, so delete it by hand.
46
+ */
47
+ export const LEGACY_FEEDBACK_FILE = 'memory/.feedback.json'
23
48
 
24
49
  /** Where a run records what it was shown. */
25
50
  export const INJECTIONS_FILE = 'memory-injections.json'
@@ -43,10 +68,24 @@ export interface FeedbackCounter {
43
68
 
44
69
  export type FeedbackBook = Record<string, FeedbackCounter>
45
70
 
46
- /** Read the counters. A missing or unreadable file is an empty book, never an error. */
71
+ /**
72
+ * Read the counters. A missing or unreadable file is an empty book, never an error.
73
+ *
74
+ * ⚠ THE LEGACY PATH IS A FALLBACK AND ONLY A FALLBACK: it is consulted when — and only when — there is
75
+ * no usable file at {@link FEEDBACK_FILE} yet, which is exactly the first run after the path moved. The
76
+ * two are never merged, because they are two SNAPSHOTS of one counter and adding them would count a run
77
+ * twice. A file that exists at the current path but does not parse stays an empty book, which is what
78
+ * this function has always promised: a corrupt sidecar is not an invitation to read a different file.
79
+ */
47
80
  export function readFeedback(root: string, readFile: (path: string) => string | null = defaultRead): FeedbackBook {
48
- const text = readFile(join(root, FEEDBACK_FILE))
49
- if (text === null || text.trim() === '') return {}
81
+ const current = readFile(join(root, FEEDBACK_FILE))
82
+ if (current !== null && current.trim() !== '') return parseBook(current)
83
+ const legacy = readFile(join(root, LEGACY_FEEDBACK_FILE))
84
+ return legacy === null ? {} : parseBook(legacy)
85
+ }
86
+
87
+ /** One counters file as a book: anything unreadable or unshaped is `{}`, never an error. */
88
+ function parseBook(text: string): FeedbackBook {
50
89
  try {
51
90
  const parsed = JSON.parse(text) as unknown
52
91
  if (typeof parsed !== 'object' || parsed === null) return {}
@@ -140,7 +179,13 @@ export function settleInjections(
140
179
  book[record.source] = counter
141
180
  }
142
181
 
143
- write(join(root, FEEDBACK_FILE), JSON.stringify(sortBook(book), null, 2) + '\n')
182
+ const feedbackPath = join(root, FEEDBACK_FILE)
183
+ // The counter now lives under the `.recursive` control plane, whose memory directory a fresh
184
+ // checkout or a scratch fixture may not have yet. Creating it here rather than assuming it
185
+ // exists is what a plugin owning its own control plane should do; before this, the write
186
+ // threw ENOENT when nothing else had already made the directory.
187
+ mkdirSync(dirname(feedbackPath), { recursive: true })
188
+ write(feedbackPath, JSON.stringify(sortBook(book), null, 2) + '\n')
144
189
  return book
145
190
  }
146
191
 
@@ -490,7 +490,16 @@ export function resolveFrom(worktreeRoot: string, target: string): string | null
490
490
  const normalized = target.replace(/\\/g, '/').trim()
491
491
  if (!normalized) return null
492
492
  if (/^[A-Za-z]:\//.test(normalized) || normalized.startsWith('/')) return resolve(normalized)
493
- return resolve(join(worktreeRoot, normalized.replace(/^\.?\/?/, '')))
493
+ // ⚠ FIX 2 — THIS USED TO STRIP A LEADING DOT, WHICH IS NOT THE SAME AS STRIPPING `./`.
494
+ //
495
+ // The regex was `/^\.?\/?/`: an optional dot followed by an optional slash. Its intent was plainly to normalise a
496
+ // `./relative` path, but on a DOTFILE path it removed the dot and kept the name — so `.recursive/run/<id>/01.md`
497
+ // resolved to `<root>/recursive/run/<id>/01.md`, OUTSIDE the run tree it names. Under strict enforcement the phase
498
+ // guard denies writes outside the run tree, and a live verification pass recorded five denials out of five while
499
+ // trying to author phase artifacts — the model could not write the very files the workflow is about.
500
+ //
501
+ // Only `./` is a relative-path prefix. A bare leading dot is part of the name.
502
+ return resolve(join(worktreeRoot, normalized.replace(/^\.\//, '')))
494
503
  }
495
504
 
496
505
  /** The tool-target path of a call (same key order enforcement.ts uses). */
@@ -419,14 +419,36 @@ function lockOrderRule(artifact: unknown, runDir: string | undefined): ToolPolic
419
419
  /**
420
420
  * Locked-artifact write rule: a denial when the target carries
421
421
  * `Status: LOCKED`, `null` otherwise. Only a run-tree `*.md` is a candidate —
422
- * the same admission test the pre-T16 branch used.
422
+ * the same admission test the pre-T16 branch used, now asked of the RESOLVED
423
+ * path as well (see the ADMISSION note below).
424
+ *
425
+ * A caller with no `worktreeRoot` still gets `null` for every target, absolute
426
+ * ones included: that is the pre-existing behaviour and it is left alone here —
427
+ * the guard always carries a root, so nothing that reaches it changes.
423
428
  */
424
429
  function lockedWriteRule(target: string | null, worktreeRoot: string | undefined): ToolPolicyPredicateMatch | null {
425
430
  if (!target || !worktreeRoot) return null
426
431
  const normalized = target.replace(/\\/g, '/')
427
- if (!normalized.endsWith('.md') || !normalized.includes('/.recursive/run/')) return null
432
+ if (!normalized.endsWith('.md')) return null
428
433
  const abs = resolveFrom(worktreeRoot, normalized)
429
- if (!abs || getLockStatus(abs) !== 'LOCKED') return null
434
+ if (!abs) return null
435
+ // ADMISSION — a target is a candidate when it NAMES the run tree, and the marker is
436
+ // looked for on the path the target RESOLVES to as well as on the string as written.
437
+ //
438
+ // The string test alone requires a separator BEFORE `.recursive`, so it admitted an
439
+ // ABSOLUTE target and missed a REPO-RELATIVE one (`.recursive/run/<id>/00-requirements.md`,
440
+ // the form a model actually types, and its backslash spelling too). The rule then
441
+ // ABSTAINED, the phase baseline saw a write INSIDE the run tree — allowed by design — and
442
+ // the catch-all allowed a write to an artifact whose `Status:` is LOCKED. The resolved test
443
+ // is the fix, and it is the same question asked of the path the string names; `getLockStatus`
444
+ // below already used `abs`, so the two halves of this rule now agree on one path.
445
+ //
446
+ // The string test is KEPT rather than replaced, so that no absolute spelling denied today
447
+ // becomes allowed: a literal target that carries the marker but resolves away from it
448
+ // (`…/.recursive/run/../…`) is still admitted, exactly as before.
449
+ const resolved = abs.replace(/\\/g, '/')
450
+ if (!normalized.includes('/.recursive/run/') && !resolved.includes('/.recursive/run/')) return null
451
+ if (getLockStatus(abs) !== 'LOCKED') return null
430
452
  return { verdict: 'deny', detail: normalized + ' carries Status: LOCKED (reopen explicitly to edit)' }
431
453
  }
432
454
 
@@ -18,7 +18,7 @@
18
18
  * present-but-empty value would stop the deferral and pin the choice to nothing, which is a different state
19
19
  * from "unconfigured" and not one a user can see. Absence is the honest representation of "I have no opinion".
20
20
  *
21
- * 3. **WRITE ATOMICALLY.** A temp file and a rename, the same pattern the preset installer uses. A policy file
21
+ * 3. **WRITE ATOMICALLY.** A temp file and a rename, the same pattern the preset installer used, before it was retired. A policy file
22
22
  * half-written because a process died mid-write would make `loadRouterPolicy` fall back to the built-in
23
23
  * self-audit policy — and it does that SILENTLY, on purpose (it never throws). So a torn write here would look
24
24
  * exactly like "the user configured nothing", for every role, until someone read the file.
package/src/runtime.ts CHANGED
@@ -1470,6 +1470,35 @@ export class RecursiveRuntime extends Service {
1470
1470
  if (inFlight.length > 0) {
1471
1471
  throw new Error(toolError('PENDING_WORK', inFlight.map((p) => p.detail).join('; ')))
1472
1472
  }
1473
+ // README §4.1 — THE STANDARD GATE. `recursive_lock` promises that it "refuses
1474
+ // if the artifact does not meet the standard", and until this gate existed it
1475
+ // checked existence, re-lock, lock ORDER and quiescence and never consulted the
1476
+ // linter, so a 14-FAIL artifact locked cleanly and its receipt certified work no
1477
+ // check had accepted. The linter is the authority on the standard, so the same
1478
+ // entry point the `recursive_lint` tool calls is consulted here.
1479
+ //
1480
+ // PLACED LAST among the refusals, immediately before the LOCKED-fields mutation:
1481
+ // every cheaper refusal keeps its existing precedence, and in particular LOCK
1482
+ // ORDER STAYS FIRST — a run that is both out of order AND below standard still
1483
+ // reports ordering, exactly as it did before this gate. The already-LOCKED check
1484
+ // and the `reopen` branch precede this point too, so nothing previously locked is
1485
+ // disturbed and reopen does not suddenly demand a standard it never had.
1486
+ //
1487
+ // A plain `Error`, in the same style as the refusals above, rather than a new
1488
+ // `toolError` code: no registry entry describes "below the phase standard", and
1489
+ // inventing one would add a code `tests/errors.spec.ts` has to be taught, for a
1490
+ // refusal whose remedy is the FAIL list it already carries.
1491
+ //
1492
+ // CONSERVATIVE WHEN THE LINT CANNOT RUN: a lint whose job was killed or failed
1493
+ // returns `passed: false` with the reason in `errors`, so an artifact that could
1494
+ // not be checked is refused rather than waved through — "not measured" is not
1495
+ // "meets the standard".
1496
+ const lint = await this.lintArtifact(runId, artifact, agent)
1497
+ if (!lint.passed) {
1498
+ throw new Error(
1499
+ 'Artifact ' + artifact + ' does not meet the phase standard, so it was not locked: ' + lint.errors.join('; '),
1500
+ )
1501
+ }
1473
1502
  let content = readFileSync(artifactPath, 'utf8')
1474
1503
  const lockedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
1475
1504
  content = setOrInsertField(content, 'Status', 'LOCKED', ['Phase'])