@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.
@@ -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
- /** Render the retrieved memory for a review bundle: sections a reviewer can cite by title. */
78
- export declare function renderMemorySection(entries: readonly MemoryEntry[]): string;
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.6.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)