@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.
- package/README.md +20 -15
- package/lib/client/index.d.ts +3 -1
- package/lib/client/settings-view.d.ts +97 -0
- package/lib/client/settings.d.ts +25 -1
- package/lib/client.js +530 -11
- package/lib/enforcement.d.ts +37 -0
- package/lib/index.js +123 -18
- package/lib/memory-feedback.d.ts +25 -2
- package/package.json +1 -1
- package/preset/recursive/agent.cordis.yml +5 -4
- package/preset/recursive.patch.yml +5 -4
- package/src/client/index.ts +3 -1
- package/src/client/settings-view.ts +211 -0
- package/src/client/settings.tsx +194 -8
- package/src/client/slots.ts +15 -2
- package/src/client/styles.ts +237 -0
- package/src/enforcement.ts +74 -3
- package/src/index.ts +47 -10
- package/src/memory-feedback.ts +50 -5
- package/src/phase-rules.ts +10 -1
- package/src/policy-globs.ts +25 -3
- package/src/policy-write.ts +1 -1
- package/src/runtime.ts +29 -0
package/src/memory-feedback.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
|
49
|
-
if (
|
|
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
|
-
|
|
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
|
|
package/src/phase-rules.ts
CHANGED
|
@@ -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
|
-
|
|
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). */
|
package/src/policy-globs.ts
CHANGED
|
@@ -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')
|
|
432
|
+
if (!normalized.endsWith('.md')) return null
|
|
428
433
|
const abs = resolveFrom(worktreeRoot, normalized)
|
|
429
|
-
if (!abs
|
|
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
|
|
package/src/policy-write.ts
CHANGED
|
@@ -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
|
|
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'])
|