@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/lib/memory-feedback.d.ts
CHANGED
|
@@ -17,6 +17,41 @@ export declare const FEEDBACK_FILE = ".recursive/memory/.feedback.json";
|
|
|
17
17
|
export declare const LEGACY_FEEDBACK_FILE = "memory/.feedback.json";
|
|
18
18
|
/** Where a run records what it was shown. */
|
|
19
19
|
export declare const INJECTIONS_FILE = "memory-injections.json";
|
|
20
|
+
/**
|
|
21
|
+
* THE SUBJECT A READ-RECEIPT CARRIES, AND WHY IT CANNOT COLLIDE WITH A SHARD.
|
|
22
|
+
*
|
|
23
|
+
* ⚠ THE GAP THIS CLOSES, MEASURED. This file used to record only the SHARDS a phase was shown, so a phase
|
|
24
|
+
* whose selection came back empty — the documented, legitimate state of a workspace with no memory plane —
|
|
25
|
+
* left NO trace at all. "A record exists" and "the read happened" were therefore the same sentence, and a
|
|
26
|
+
* gate that required the former would REFUSE FOREVER in a fresh workspace: there would be nothing to
|
|
27
|
+
* record, so nothing would ever be recorded. `selectMemory` says it plainly (`memory.ts`): *"the memory
|
|
28
|
+
* plane is empty, so nothing is injected"* is an ANSWER, not a failure.
|
|
29
|
+
*
|
|
30
|
+
* So the read is recorded as a FACT OF ITS OWN — a receipt saying "at this phase entry, the plane was read,
|
|
31
|
+
* and this is what the read said" — instead of being inferred from what the read returned. An empty plane
|
|
32
|
+
* produces a receipt like any other, which is what makes the gate satisfiable in a new repo.
|
|
33
|
+
*
|
|
34
|
+
* ⚠ AND THE SUBJECT IS A RESERVED NAME. A memory entry's `source` is the shard's own PATH
|
|
35
|
+
* (`selectMemory` -> `loadMemoryIndex` -> `entry.source`), always a `.md` path under the memory plane, so
|
|
36
|
+
* `memory-read:attempt` is not a name any plane entry can hold. That is the property a collision would
|
|
37
|
+
* need to break the gate, and it is asserted in `tests/memory-feedback.spec.ts`.
|
|
38
|
+
*/
|
|
39
|
+
export declare const MEMORY_READ_SOURCE = "memory-read:attempt";
|
|
40
|
+
/** The receipt a phase entry leaves for a read that happened at `phase`. */
|
|
41
|
+
export interface MemoryReadRecord extends InjectionRecord {
|
|
42
|
+
/** Always {@link MEMORY_READ_SOURCE}. */
|
|
43
|
+
source: typeof MEMORY_READ_SOURCE;
|
|
44
|
+
/** The phase ENTRY that performed the read — an artifact name (`00-requirements.md`), as shards use. */
|
|
45
|
+
phase: string;
|
|
46
|
+
/** True when anything was injected; FALSE is a satisfied read, not a missing one. */
|
|
47
|
+
injected: boolean;
|
|
48
|
+
/** How many shards were injected. `0` on an empty plane, which is the case this whole record exists for. */
|
|
49
|
+
shards: number;
|
|
50
|
+
/** WHY the read returned what it did — `selectMemory`'s own reason, carried verbatim. */
|
|
51
|
+
reason: string;
|
|
52
|
+
/** Always `0`: a receipt is not a ranking, so it can never move a counter. */
|
|
53
|
+
score: 0;
|
|
54
|
+
}
|
|
20
55
|
/** One entry the agent was shown, as the run recorded it. */
|
|
21
56
|
export interface InjectionRecord {
|
|
22
57
|
/** The entry's source path, which is also its identity in the counters. */
|
|
@@ -45,12 +80,60 @@ export type FeedbackBook = Record<string, FeedbackCounter>;
|
|
|
45
80
|
export declare function readFeedback(root: string, readFile?: (path: string) => string | null): FeedbackBook;
|
|
46
81
|
/** Read what a run recorded being shown. */
|
|
47
82
|
export declare function readInjections(runDir: string, readFile?: (path: string) => string | null): InjectionRecord[];
|
|
83
|
+
/**
|
|
84
|
+
* True for the read receipt, false for a shard the phase was shown.
|
|
85
|
+
*
|
|
86
|
+
* ⚠ THE DISCRIMINATOR IS THE SUBJECT **AND** THE SHAPE. The reserved subject alone would be enough if no
|
|
87
|
+
* plane entry could hold it, but a caller can hand-write this file, so a row is a receipt only when it also
|
|
88
|
+
* carries the receipt's own fields. A hand-edited file therefore cannot make a shard row look like a read,
|
|
89
|
+
* and `tests/memory-feedback.spec.ts` asserts the negative case.
|
|
90
|
+
*
|
|
91
|
+
* Accepts `unknown` rather than `InjectionRecord` because its callers hold JSON off disk (an array of
|
|
92
|
+
* whatever the file contains), and a type guard that could only be applied to a value already known to be
|
|
93
|
+
* well-shaped would not be worth having.
|
|
94
|
+
*/
|
|
95
|
+
export declare function isMemoryReadRecord(record: unknown): record is MemoryReadRecord;
|
|
96
|
+
/**
|
|
97
|
+
* The read receipts this run holds, in the file's own deterministic order.
|
|
98
|
+
*
|
|
99
|
+
* The reader the gate uses (see `hasMemoryRead` in `policy-globs.ts`): it is a NON-EMPTY answer even when
|
|
100
|
+
* every receipt says `injected: false`, because a receipt is evidence that the read RAN.
|
|
101
|
+
*/
|
|
102
|
+
export declare function readMemoryReads(runDir: string, readFile?: (path: string) => string | null): MemoryReadRecord[];
|
|
103
|
+
/**
|
|
104
|
+
* Record that a phase entry READ the memory plane, whatever the read returned.
|
|
105
|
+
*
|
|
106
|
+
* ⚠ THIS IS THE ATTEMPT, NOT THE RESULT, AND THE DIFFERENCE IS THE WHOLE POINT. `recordInjection` can only
|
|
107
|
+
* write a row when a shard was selected, so it is silent on an empty plane — and an empty plane is a
|
|
108
|
+
* legitimate state a fresh workspace is in, not a failure to record. A receipt written here says "the plane
|
|
109
|
+
* was read at this phase entry, and the read answered: <reason>", so `injected: false` is a SATISFIED read.
|
|
110
|
+
*
|
|
111
|
+
* ⚠ ONE RECEIPT PER PHASE, REPLACED (not appended). A phase is re-entered while it is still DRAFT — that is
|
|
112
|
+
* the ordinary path, not an edge case — so appending would turn one read per entry into an unbounded log
|
|
113
|
+
* and make the file grow with every reminder. The merge key is the phase, the newest read wins, and the
|
|
114
|
+
* rewrite is byte-identical when the same phase is read twice with the same answer, which is the
|
|
115
|
+
* determinism the lock receipts and the selection output already follow.
|
|
116
|
+
*
|
|
117
|
+
* ⚠ AND IT SHARES THE FILE WITH THE SHARD ROWS rather than living in a second sidecar: `recordInjection`
|
|
118
|
+
* preserves receipts when it rewrites (below), so the two writers cannot erase each other. A separate file
|
|
119
|
+
* would be a second answer to "what did this run read", which is the drift this repo keeps paying for.
|
|
120
|
+
*/
|
|
121
|
+
export declare function recordMemoryRead(runDir: string, phase: string, read: {
|
|
122
|
+
injected: boolean;
|
|
123
|
+
shards: number;
|
|
124
|
+
reason: string;
|
|
125
|
+
}, write?: (path: string, content: string) => void, readFile?: (path: string) => string | null): MemoryReadRecord[];
|
|
48
126
|
/**
|
|
49
127
|
* Record what the run was shown, MERGED by (source, title, phase).
|
|
50
128
|
*
|
|
51
129
|
* ⚠ MERGED RATHER THAN APPENDED, because a phase can be re-entered while it is still DRAFT and the same
|
|
52
130
|
* entries are selected again. Appending would count one decision as four, and the counters exist to be
|
|
53
131
|
* evidence. The highest score seen wins, since that is what the agent was most recently shown.
|
|
132
|
+
*
|
|
133
|
+
* ⚠ AND THE READ RECEIPTS SURVIVE THE REWRITE. This function owns the file, so a version of it that wrote
|
|
134
|
+
* only `merged` would delete the receipt `recordMemoryRead` had just written — the gate would then refuse a
|
|
135
|
+
* write in the same phase entry that satisfied it. The two kinds of row are therefore written together,
|
|
136
|
+
* sorted by the same key, and a receipt is never a candidate for the score merge (it carries no shard).
|
|
54
137
|
*/
|
|
55
138
|
export declare function recordInjection(runDir: string, entries: readonly {
|
|
56
139
|
source: string;
|
package/lib/memory.d.ts
CHANGED
|
@@ -74,8 +74,21 @@ export declare function retrieveMemory(entries: readonly MemoryEntry[], query: s
|
|
|
74
74
|
* a filesystem), which is also how the layout stays in ONE place — `.recursive/memory/<kind>/*.md`.
|
|
75
75
|
*/
|
|
76
76
|
export declare function readMemoryEntries(readFile: (path: string) => string | null, listFiles: (kind: string) => readonly string[], kinds?: readonly string[]): MemoryEntry[];
|
|
77
|
-
/**
|
|
78
|
-
|
|
77
|
+
/**
|
|
78
|
+
* WHERE A RENDERED SECTION IS GOING TO BE READ — the one thing the section's wording depends on.
|
|
79
|
+
*
|
|
80
|
+
* ⚠ WHY THIS IS A PARAMETER AND NOT A SECOND RENDERER. The section is rendered for two readers: a
|
|
81
|
+
* delegated REVIEW bundle (`runtime.ts` `buildReviewBundle`, context `'review'`) and the PHASE-ENTRY
|
|
82
|
+
* payload `recursive_phase` returns (`runtime.ts` `phaseRules`, context `'phase'`). The wording used to
|
|
83
|
+
* be the reviewer's — *"Prior-run memory relevant to this REVIEW"* — and the phase payload inherited it,
|
|
84
|
+
* so an agent that had just been handed prior-run learning at phase entry was told it was a review: the
|
|
85
|
+
* reader the text addressed was not the reader holding it. Copying the renderer to change one sentence
|
|
86
|
+
* would have been two renderers to keep in step, which is how the two answers to one question appear in
|
|
87
|
+
* this plugin; the sentence is instead selected from this context, so there is still ONE renderer.
|
|
88
|
+
*/
|
|
89
|
+
export type MemoryRenderContext = 'review' | 'phase';
|
|
90
|
+
/** Render the retrieved memory: sections a reader can cite by title. See {@link MemoryRenderContext}. */
|
|
91
|
+
export declare function renderMemorySection(entries: readonly MemoryEntry[], context?: MemoryRenderContext): string;
|
|
79
92
|
/**
|
|
80
93
|
* T29 — INJECT MEMORY AT RUN START: the loader.
|
|
81
94
|
*
|
|
@@ -6,6 +6,22 @@ import type { RecursiveRuntime } from './runtime.ts';
|
|
|
6
6
|
* reminder, so the agent can re-ask for the rules without re-injecting them on
|
|
7
7
|
* every step. Returns { error } when no active phase is found.
|
|
8
8
|
*
|
|
9
|
+
* ⚠ AND IT IS WHERE PRIOR-RUN MEMORY ARRIVES — say so in the DESCRIPTION, which is the only surface a
|
|
10
|
+
* model reads before choosing a tool. The description used to promise rules and instructions only, so
|
|
11
|
+
* nothing in the tool list gave a caller a reason to expect memory here (the owner's own report: *"the
|
|
12
|
+
* memory must be read before writing requirements.md"*, and *"recursive_phase should also read memories"*).
|
|
13
|
+
* The read WAS already happening — `phaseRules` calls `selectMemory` and returns the section plus its
|
|
14
|
+
* reason — so the defect was that the contract was SILENT about it, not that the mechanism was absent.
|
|
15
|
+
* Every sentence below is a claim about what this call actually returns: the payload's `memory` and
|
|
16
|
+
* `memoryReason` fields come from `selectMemory`, `runId`/`phase` from the resolved run, `requiredSections`
|
|
17
|
+
* / `audited` / `tdd` / `qa` / `memoryWrite` from `phaseRulesFor`, and `ask` from `pendingGateFor`. A
|
|
18
|
+
* description that advertised a field the payload does not carry would be worse than the silence it fixes.
|
|
19
|
+
*
|
|
20
|
+
* ⚠ AND AN EMPTY PLANE IS SAID TO BE NORMAL, because that is the honest reading and the one a model needs:
|
|
21
|
+
* `selectMemory` answers *"the memory plane is empty, so nothing is injected"*, which is a RESULT — the
|
|
22
|
+
* plane was read and had nothing to say — not a failure of the call and not a reason to retry it. Without
|
|
23
|
+
* that sentence an agent seeing an empty section could reasonably conclude the read had not happened.
|
|
24
|
+
*
|
|
9
25
|
* A RUN ID IS A NAME, NOT A PATH HERE TOO, INCLUDING WHEN IT IS OMITTED. Omitted and path-shaped are
|
|
10
26
|
* different cases and must stay different: omitted means "the latest run by mtime", which `resolveRunDir`
|
|
11
27
|
* answers by DISCOVERY rather than by joining anything, and that case is untouched below. A path-shaped id,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@try-works/dsh-recursive-mode",
|
|
3
3
|
"description": "recursive-mode workflow as a DeepSeek Harness bundle: RecursiveRuntime service + 13 recursive_* tools (recursive_status, recursive_init, recursive_lock, recursive_lint, recursive_closeout, recursive_scratch, recursive_worktree, recursive_phase, recursive_audit_team, recursive_review, recursive_delegate, recursive_ask, recursive_preview)",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-workflow-map-escapes.mjs — adversarial escaping check on the generated page.
|
|
4
|
+
*
|
|
5
|
+
* WHY A THIRD CHECK. `check-workflow-map.mjs` validates structure (ids, references,
|
|
6
|
+
* pairing). Neither it nor the generator's own `--verify` would catch the failure
|
|
7
|
+
* mode that a template-literal renderer actually has: a `${…}` that never
|
|
8
|
+
* interpolated, an `undefined` that reached the page, an unescaped `<` in prose, a
|
|
9
|
+
* bare `&`, or an unbalanced brace in a 22 KB inline stylesheet. Those do not throw
|
|
10
|
+
* — they ship, and they look like content.
|
|
11
|
+
*
|
|
12
|
+
* ⚠ THE SCRIPT AND STYLE BLOCKS ARE EXCLUDED FROM THE MARKUP CHECKS, ON PURPOSE.
|
|
13
|
+
* Inside `<script>` and `<style>`, HTML is RAW TEXT: `index < 0`, `a && b` and `{`
|
|
14
|
+
* are the language, not markup. A naive checker reports them as defects — it did,
|
|
15
|
+
* five times, on a file that was correct. A check that fails on correct input is
|
|
16
|
+
* worse than no check, so the regions are removed before the prose rules run.
|
|
17
|
+
*
|
|
18
|
+
* Usage: node scripts/check-workflow-map-escapes.mjs [path]
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
21
|
+
import { resolve } from 'node:path'
|
|
22
|
+
|
|
23
|
+
const target = resolve(process.argv[2] ?? 'workflow-map/recursive-mode-workflow.html')
|
|
24
|
+
if (!existsSync(target)) {
|
|
25
|
+
console.error('no such file: ' + target)
|
|
26
|
+
process.exit(2)
|
|
27
|
+
}
|
|
28
|
+
const html = readFileSync(target, 'utf8')
|
|
29
|
+
const fails = []
|
|
30
|
+
const passes = []
|
|
31
|
+
|
|
32
|
+
const styleBlock = (html.match(/<style>([\s\S]*?)<\/style>/) || ['', ''])[1]
|
|
33
|
+
const scriptBlock = (html.match(/<script>([\s\S]*?)<\/script>/) || ['', ''])[1]
|
|
34
|
+
/** The document with its raw-text regions removed: what the prose rules apply to. */
|
|
35
|
+
const markup = html.replace(/<style>[\s\S]*?<\/style>/, '').replace(/<script>[\s\S]*?<\/script>/, '')
|
|
36
|
+
|
|
37
|
+
const check = (label, ok, detail = '') => (ok ? passes.push(label) : fails.push(label + (detail ? ' — ' + detail : '')))
|
|
38
|
+
|
|
39
|
+
/* 1. no template or JS leftovers reached the page ------------------------- */
|
|
40
|
+
for (const bad of ['${', '[object Object]']) {
|
|
41
|
+
const n = html.split(bad).length - 1
|
|
42
|
+
check('no leftover ' + JSON.stringify(bad) + ' in the output', n === 0, 'found ' + n)
|
|
43
|
+
}
|
|
44
|
+
// `undefined` / `NaN` as a WORD are leftovers; as part of a longer identifier they
|
|
45
|
+
// are not. A word boundary is the difference between a real finding and noise.
|
|
46
|
+
for (const bad of ['undefined', 'NaN']) {
|
|
47
|
+
const n = (markup.match(new RegExp('\\b' + bad + '\\b', 'g')) || []).length
|
|
48
|
+
check('no literal ' + bad + ' in the rendered content', n === 0, 'found ' + n)
|
|
49
|
+
}
|
|
50
|
+
check('no unresolved citation key reached the page', !/(^|[^a-zA-Z])SRC\.[a-zA-Z]/.test(markup))
|
|
51
|
+
|
|
52
|
+
/* 2. markup escaping ------------------------------------------------------ */
|
|
53
|
+
const strayLt = [...markup.matchAll(/<(?![/!a-zA-Z])/g)]
|
|
54
|
+
check('no stray `<` in the rendered content', strayLt.length === 0, strayLt.length + ' found')
|
|
55
|
+
const bareAmp = [...markup.matchAll(/&(?!(amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g)]
|
|
56
|
+
check('no bare `&` in the rendered content', bareAmp.length === 0, bareAmp.length + ' found')
|
|
57
|
+
const rawLtInTag = [...markup.matchAll(/<[a-zA-Z][^>]*>/g)].filter((m) => m[0].slice(1).includes('<'))
|
|
58
|
+
check('no `<` inside a tag (an unescaped attribute value)', rawLtInTag.length === 0, rawLtInTag.length + ' found')
|
|
59
|
+
|
|
60
|
+
/* 3. balanced raw-text regions ------------------------------------------- */
|
|
61
|
+
const braces = (s) => [(s.match(/{/g) || []).length, (s.match(/}/g) || []).length]
|
|
62
|
+
const [ob, cb] = braces(styleBlock)
|
|
63
|
+
check('the inline CSS has balanced braces', ob === cb, ob + ' open / ' + cb + ' close')
|
|
64
|
+
check('exactly one <style> block', (html.match(/<style>/g) || []).length === 1)
|
|
65
|
+
check('exactly one <script> block', (html.match(/<script>/g) || []).length === 1)
|
|
66
|
+
check('<style> and </style> balance', (html.match(/<style>/g) || []).length === (html.match(/<\/style>/g) || []).length)
|
|
67
|
+
check('<script> and </script> balance', (html.match(/<script>/g) || []).length === (html.match(/<\/script>/g) || []).length)
|
|
68
|
+
check('the script block contains no nested closing script tag', !scriptBlock.includes('</script'))
|
|
69
|
+
|
|
70
|
+
/* 4. the SVG is well formed enough to render ----------------------------- */
|
|
71
|
+
const svg = (html.match(/<svg[\s\S]*?<\/svg>/) || [''])[0]
|
|
72
|
+
check('there is exactly one inline <svg>', (html.match(/<svg/g) || []).length === 1)
|
|
73
|
+
check('the svg has a viewBox', /viewBox="[^"]+"/.test(svg))
|
|
74
|
+
check('the svg is labelled for assistive technology', /role="img"[^>]*aria-label="[^"]+"/.test(html))
|
|
75
|
+
check('svg <text> elements balance', (svg.match(/<text/g) || []).length === (svg.match(/<\/text>/g) || []).length)
|
|
76
|
+
check('every svg <rect> is self-closed', (svg.match(/<rect[^>]*\/>/g) || []).length === (svg.match(/<rect/g) || []).length)
|
|
77
|
+
check('every svg <path> is self-closed', (svg.match(/<path[^>]*\/>/g) || []).length === (svg.match(/<path/g) || []).length)
|
|
78
|
+
check('every svg <polygon> is self-closed', (svg.match(/<polygon[^>]*\/>/g) || []).length === (svg.match(/<polygon/g) || []).length)
|
|
79
|
+
|
|
80
|
+
/* 5. no attribute is left unquoted ---------------------------------------
|
|
81
|
+
Parsed attribute by attribute, not by a scanning regex. A scanning regex reports
|
|
82
|
+
`content="width=device-width, initial-scale=1"` as an unquoted attribute, because
|
|
83
|
+
the text `scale=1` looks exactly like `name=value` — it did, on a correct file.
|
|
84
|
+
So: take each start tag, strip quoted regions first, and only then look for `=` at
|
|
85
|
+
all. That is the same "remove the raw text, then judge" discipline the rest of
|
|
86
|
+
this script uses. */
|
|
87
|
+
const unquoted = []
|
|
88
|
+
for (const tag of markup.matchAll(/<[a-zA-Z][^>]*>/g)) {
|
|
89
|
+
const stripped = tag[0].replace(/"[^"]*"/g, '""').replace(/'[^']*'/g, "''")
|
|
90
|
+
for (const m of stripped.matchAll(/([a-zA-Z-]+)=([^"'\s>][^\s>]*)/g)) unquoted.push(tag[0].slice(0, 70) + ' → ' + m[0])
|
|
91
|
+
for (const m of stripped.matchAll(/([a-zA-Z-]+)=(?=[\s>]|$)/g)) unquoted.push(tag[0].slice(0, 70) + ' → ' + m[0] + ' (no value)')
|
|
92
|
+
}
|
|
93
|
+
check('every attribute value is quoted', unquoted.length === 0, unquoted.slice(0, 3).join(' | '))
|
|
94
|
+
|
|
95
|
+
/* report ------------------------------------------------------------------ */
|
|
96
|
+
console.log('check-workflow-map-escapes — adversarial escaping and structure\n')
|
|
97
|
+
console.log(' file: ' + target)
|
|
98
|
+
console.log(' markup: ' + markup.length.toLocaleString('en-US') + ' bytes · css: '
|
|
99
|
+
+ styleBlock.length.toLocaleString('en-US') + ' · js: ' + scriptBlock.length.toLocaleString('en-US') + '\n')
|
|
100
|
+
for (const p of passes) console.log(' PASS ' + p)
|
|
101
|
+
for (const f of fails) console.log(' FAIL ' + f)
|
|
102
|
+
console.log('\n ' + passes.length + ' passed, ' + fails.length + ' failed')
|
|
103
|
+
process.exit(fails.length === 0 ? 0 : 1)
|