@try-works/dsh-recursive-mode 0.5.0 → 0.6.1

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.
@@ -90,8 +90,8 @@ Required read behavior:
90
90
  - If relevant prior runs are found, read only the docs needed from those runs to understand the affected codebase areas before writing the new run artifacts.
91
91
  - If no relevant prior runs are identified, skip that step.
92
92
  - After reading `MEMORY.md`, load only the memory docs relevant to the current task. Do not load the entire memory tree by default.
93
- - If the task may benefit from prior experiential learnings, load only the relevant docs under `/.recursive/memory/training/` and `/.recursive/memory/domains/`, preferably by using the training loader with filesystem-backed discovery.
94
- - If the optional `recursive-training` skill is installed, run `/.recursive/scripts/recursive-training-loader.py` after reading `MEMORY.md` and before planning or implementation whenever the task may benefit from experiential memory. If no automatic hook is wired, the agent must still manually load relevant training docs from the memory index when they matter.
93
+ - If the task may benefit from prior experiential learnings, load only the relevant docs under `/.recursive/memory/training/` and `/.recursive/memory/domains/`.
94
+ - The plugin loads those docs itself, **in-process and in TypeScript**: call `recursive_phase` on entering a phase and it returns the memory shards that match the run and the phase in play, with the reason stated when none matched. For an on-demand lookup, `/recursive memory "<task>" [--phase <nn>]` prints the same selection with the score component behind each shard and a reason for each shard it excluded. Nothing is loaded by running a script — this plugin ships no `.recursive/scripts/` helper and no Python — and nothing is ever fabricated: an empty result means continue normally.
95
95
  - If the run plans delegated review, subagent help, review bundles, smoke harness portability work, or other skill-sensitive execution, load `/.recursive/memory/skills/SKILLS.md` and the relevant skill-memory shards before planning or auditing.
96
96
  - Prefer `Status: CURRENT` memory docs for planning/execution.
97
97
  - `Status: SUSPECT` memory docs may be used as leads but must be revalidated before trust.
@@ -559,9 +559,10 @@ Phase 8 — Memory maintenance and impact review
559
559
  - Audit must verify memory updates and status transitions against reviewed final product/worktree paths, touched memory docs, prior memory truth, `STATE.md`, and `DECISIONS.md`
560
560
  - Must include `## Run-Local Skill Usage Capture` with concrete availability / attempted / used / worked-well / issue / recommendation fields whenever skill usage is relevant to the run
561
561
  - Must include `## Skill Memory Promotion Review` explaining what durable lessons were promoted, what stayed run-local, and why
562
- - If the optional `recursive-training` skill is installed, run `/.recursive/scripts/recursive-training-phase8-trigger.py` immediately after `08-memory-impact.md` locks to extract or refresh cross-run experiential learnings.
563
- - `recursive-lock` does not invoke training by itself. After Phase 8 locks, either run the trigger directly or re-run `recursive-closeout --phase 08` (without `--force`) so the helper can call `recursive-training-phase8-trigger.py --auto`.
564
- - Treat trigger/GRPO exit `2` (extractor unavailable) and exit `3` (zero items written) as unsuccessful training; do not claim the memory plane was updated.
562
+ - The training pass runs **in-process**: re-run the closeout for the phase — `recursive_closeout` with `phase 08` for a run whose `08-memory-impact.md` has already been closed out once — and the plugin's phase-8 trigger extracts or refreshes cross-run experiential learnings then.
563
+ - `recursive_lock` does not invoke training by itself, and neither does the FIRST closeout of phase 08: training at the first lock would train the run on itself. The trigger fires on the closeout **re-run**, and it needs at least two runs with a LOCKED `08-memory-impact.md` before it will extract — one run is an anecdote, not evidence. There is no trigger script to run.
564
+ - The extractor is the operator environment variable `RECURSIVE_TRAINING_EXTRACTOR_CMD`, and its answer comes back through a response file rather than a pipe. An unset command is a named failure, never a silent success.
565
+ - Treat trigger exit `2` (extractor unavailable) and exit `3` (nothing usable to extract, including too little evidence) as unsuccessful training; do not claim the memory plane was updated.
565
566
  - **TODO Requirement:** Phase artifact MUST include `## TODO` section with checkable items
566
567
  - **TODO Enforcement:** ALL TODO items must be checked off before locking
567
568
  - **Completion rule:** the run is not fully complete before Phase 8 passes
@@ -752,7 +753,7 @@ Recursive phases are one-way. Iteration is allowed within a phase, but after a p
752
753
  ### DRAFT vs LOCKED
753
754
 
754
755
  - While a phase is in progress, its output artifact status is `DRAFT`. The agent may revise it until both gates pass.
755
- - When both gates pass, the agent must lock the artifact with `.recursive/scripts/recursive-lock.py` or `.recursive/scripts/recursive-lock.ps1`. The lock command is the primary supported path and must:
756
+ - When both gates pass, the agent must lock the artifact with the **`recursive_lock` tool**. It is the primary supported path and it must:
756
757
  1) verify the artifact is lockable,
757
758
  2) set Status to `LOCKED`,
758
759
  3) set `LockedAt`,
@@ -779,11 +780,11 @@ that contains its own hash.
779
780
 
780
781
  #### Preferred: use the lock command
781
782
 
782
- Use `.recursive/scripts/recursive-lock.py` (cross-platform) or `.recursive/scripts/recursive-lock.ps1` (PowerShell) to lock a draft artifact. Those commands validate lockability, write `Status: LOCKED`, write `LockedAt`, and compute `LockHash` using the canonical normalization rules.
783
+ Use the **`recursive_lock`** tool to lock a draft artifact — run **`recursive_lint`** first when you want the artifact machine-checked before you attempt the lock. It validates lockability, writes `Status: LOCKED`, writes `LockedAt`, and computes `LockHash` using the canonical normalization rules; it refuses a lock whose gates or whose earlier phases are unmet. This plugin runs in-process in TypeScript: there is no lock script to call under `.recursive/scripts/`, and no Python in the package at all.
783
784
 
784
785
  #### Secondary: verify an existing lock
785
786
 
786
- Use `.recursive/scripts/verify-locks.py` (cross-platform) or `.recursive/scripts/verify-locks.ps1` (PowerShell) to verify and (optionally) fix mismatched hashes on already locked artifacts.
787
+ Use the **`recursive_status`** tool to verify already locked artifacts: it recomputes every `LockHash` and reports each artifact's state, naming a `Status: LOCKED` artifact whose hash no longer matches as `TAMPERED`. There is no separate verifier to call, and deliberately no fix-up mode — see the tampering action below.
787
788
 
788
789
  #### Manual computation examples
789
790
 
@@ -1763,8 +1764,8 @@ The LockHash is a SHA-256 hash of the normalized artifact content at lock time.
1763
1764
  "How to compute LockHash" above for the canonical normalization rules.
1764
1765
 
1765
1766
  **Preferred:**
1766
- - use `.recursive/scripts/verify-locks.py` for cross-platform verification (and optional fixing)
1767
- - use `.recursive/scripts/verify-locks.ps1` when running in PowerShell environments
1767
+ - use the `recursive_status` tool: it recomputes every `LockHash` under the run and reports the lock state per artifact
1768
+ - use the `recursive_lint` tool to machine-check one artifact against its phase rules before or after locking
1768
1769
 
1769
1770
  **PowerShell:**
1770
1771
  ```powershell
@@ -1796,28 +1797,18 @@ A phase artifact is **lock-valid** only when ALL of the following are true:
1796
1797
 
1797
1798
  ### Automated Verification
1798
1799
 
1799
- Use the provided verifier scripts to verify all locks:
1800
+ Use the plugin's own tools to verify locks. There is no verifier script to run:
1800
1801
 
1801
- ```bash
1802
- # Verify specific run
1803
- python ./.recursive/scripts/verify-locks.py --run-id "<run-id>"
1804
-
1805
- # Scan all runs
1806
- python ./.recursive/scripts/verify-locks.py
1807
-
1808
- # Fix incorrect hashes (use with caution)
1809
- python ./.recursive/scripts/verify-locks.py --run-id "<run-id>" --fix
1810
- ```
1811
-
1812
- ```powershell
1813
- # Verify specific run
1814
- .\.agents\skills\recursive-mode\scripts\verify-locks.ps1 -RunId "<run-id>"
1802
+ ```text
1803
+ # One run: the phase table and per-artifact lock state. A LockHash that no longer
1804
+ # matches its content is reported as TAMPERED, with the receipt it was locked under.
1805
+ recursive_status { "runId": "<run-id>" }
1815
1806
 
1816
- # Scan all runs
1817
- .\.agents\skills\recursive-mode\scripts\verify-locks.ps1
1807
+ # All runs in the workspace, from the slash-command surface
1808
+ /recursive status <run-id>
1818
1809
 
1819
- # Fix incorrect hashes (use with caution)
1820
- .\.agents\skills\recursive-mode\scripts\verify-locks.ps1 -RunId "<run-id>" -Fix
1810
+ # Machine-check a single artifact against its phase rules before locking it
1811
+ recursive_lint { "runId": "<run-id>", "artifact": "<artifact>.md" }
1821
1812
  ```
1822
1813
 
1823
1814
  ### Tampering Detection
@@ -1829,7 +1820,7 @@ If LockHash doesn't match the canonical normalized content:
1829
1820
  3. **Line endings changed** (CRLF vs LF)
1830
1821
 
1831
1822
  **Action:**
1832
- - If accidental: Use `verify-locks.py --fix` (or `verify-locks.ps1 -Fix`) to update hash
1823
+ - If accidental: restore the artifact's content. The plugin has **no `--fix` on purpose** — re-hashing a changed artifact would erase the only evidence that it changed, so the mismatch is reported as `TAMPERED` and the decision (restore the content, or record an addendum) stays with you.
1833
1824
  - If intentional modification: This is an anti-pattern. Use addenda instead.
1834
1825
 
1835
1826
  ### Phase Transition Lock Chain
package/src/bootstrap.ts CHANGED
@@ -2,16 +2,18 @@
2
2
  * Idempotent scaffold installer (R3). TS port of install-recursive-mode.py's
3
3
  * core: bootstrap the FULL canonical /.recursive/ control plane + cross-tool
4
4
  * bridges byte-identically (RECURSIVE.md marker-wrapped, AGENTS.md, STATE/
5
- * DECISIONS, memory routers + shards, config/recursive-router.json, .gitignore,
6
- * vendored runtime scripts copied into .recursive/scripts/), plus the
7
- * agent/session-start Stage B (new vs resume) workspace-scoped to the session's
8
- * control-plane root (R1).
5
+ * DECISIONS, memory routers + shards, config/recursive-router.json, .gitignore),
6
+ * plus the agent/session-start Stage B (new vs resume) workspace-scoped to the
7
+ * session's control-plane root (R1).
8
+ *
9
+ * ⚠ NO `.recursive/scripts/` IS CREATED, and a legacy one is removed once it is empty — see the block in
10
+ * the scaffold below for the measurement that decided it.
9
11
  *
10
12
  * Templates + bodies + runtime scripts are SHIPPED package files under
11
13
  * references/ (never inlined TS string literals) and resolved relative to this
12
14
  * module (package install location), never process.cwd().
13
15
  */
14
- import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
16
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync, readFileSync, rmSync, rmdirSync } from 'node:fs'
15
17
  import { join, dirname } from 'node:path'
16
18
  import { fileURLToPath } from 'node:url'
17
19
 
@@ -224,15 +226,21 @@ export function bootstrapScaffold(root: string): BootstrapResult {
224
226
  '.recursive/memory/skills/issues/.gitkeep', '.recursive/memory/skills/patterns/.gitkeep', '.recursive/run/.gitkeep',
225
227
  ]) noteFile(rel, '')
226
228
 
227
- // Runtime scripts: TS-only scaffold (R2). No .py/.ps1 are vendored; the
228
- // plugin's lint/lock/status/init run in-process via TS tools, so the
229
- // scaffold carries an empty .recursive/scripts/ dir (kept for canonical
230
- // tree-shape parity with the golden fixture).
231
- noteDir('.recursive/scripts')
232
- // R3 (run 09): TS-only repair — drop python-era artifacts from an EXISTING
233
- // workspace's .recursive/scripts/ (the pre-0.1.6 scaffold vendored 29 .py +
234
- // .ps1 wrappers; those are no longer shipped or needed). Deletes only
235
- // .py/.ps1 under scripts/; never touches user content elsewhere.
229
+ // Runtime scripts: TS-ONLY, AND THE EMPTY DIRECTORY IS NO LONGER CREATED.
230
+ //
231
+ // ⚠ WHY IT GONE. It used to be scaffolded EMPTY — no .py/.ps1 is vendored, because lint/lock/status/
232
+ // closeout all run in-process as TS tools — while two shipped documents pointed INTO it: the `CLAUDE.md`
233
+ // memory pointers at `.recursive/scripts/recursive-training-loader.py`, and the canonical `RECURSIVE.md`
234
+ // at `.recursive/scripts/recursive-lock.py` / `verify-locks.py`. An empty directory that shipped documents
235
+ // send an agent into is a TRAP, not tree-shape parity: the agent follows the documented path, finds
236
+ // nothing, and the memory plane those documents promised never loads. Measured in three live runs — the
237
+ // directory was empty in every one, and no run ever locked a phase. Nothing in this package reads or
238
+ // executes anything from it; the only code that ever touched it is the legacy cleanup below.
239
+ //
240
+ // ⚠ THE CLEANUP STAYS, AND NOW FINISHES THE JOB. The pre-0.1.6 scaffold vendored 29 .py + .ps1 wrappers
241
+ // into real workspaces, so those are still deleted wherever they are found — and the directory is then
242
+ // removed when it is EMPTY, because leaving it behind is the same trap for the next agent. A directory
243
+ // still holding anything else is that user's and is left untouched.
236
244
  {
237
245
  const scriptsDir = join(recursiveRoot, 'scripts')
238
246
  if (existsSync(scriptsDir)) {
@@ -241,6 +249,14 @@ export function bootstrapScaffold(root: string): BootstrapResult {
241
249
  rmSync(join(scriptsDir, name), { force: true })
242
250
  }
243
251
  }
252
+ // ⚠ `rmdirSync`, NOT `rmSync({ recursive: false })`: measured — the latter throws `ERR_FS_EISDIR`
253
+ // on a directory, and a silently caught error here would leave exactly the empty directory this
254
+ // block exists to remove. A spec asserts the directory is GONE, so the wrong call cannot hide.
255
+ try {
256
+ if (readdirSync(scriptsDir).length === 0) rmdirSync(scriptsDir)
257
+ } catch {
258
+ // A directory that cannot be removed is not a scaffold failure, and never a reason to stop.
259
+ }
244
260
  }
245
261
  }
246
262
 
@@ -8,6 +8,7 @@
8
8
  import { existsSync, readdirSync } from 'node:fs'
9
9
  import { join, isAbsolute, resolve, sep } from 'node:path'
10
10
  import { getLockStatus } from './lock.ts'
11
+ import { runIdProblem } from './run-id.ts'
11
12
  import { validateTransition, type GateCheckResult } from './lifecycle.ts'
12
13
  import {
13
14
  evaluateToolPolicy, loadToolPolicyFile,
@@ -199,11 +200,18 @@ export interface GuardTransition {
199
200
  * and the payload is built by `buildGateBlockAsk` — the SAME builder the lock tool uses, so the two
200
201
  * refusals cannot offer different options. It is absent on every decision that is not a lock-order
201
202
  * refusal decided from real blockers, which is why every reader must treat it as optional.
203
+ *
204
+ * ⚠ ISSUE 2 (b): `runId` IS THE RUN THE GUARD RESOLVED AND READ — optional and additive for the same
205
+ * reason. `evaluateToolGuard` sets it on EVERY decision, allow included, and `index.ts` logs it instead of
206
+ * the filesystem's active run: a record whose `runId` came from one resolution while the rule read another
207
+ * is exactly the two-answers-in-one-payload defect this pairs with. It stays OPTIONAL so a hand-built
208
+ * decision (and `coerceAskToDecision`'s key-frozen input/output) is unaffected; a reader falls back to the
209
+ * active run when it is absent, which is the pre-existing behaviour.
202
210
  */
203
211
  export type ToolGuardDecision =
204
- | { kind: 'allow'; warn?: string; rule?: GuardRule; transition?: GuardTransition }
205
- | { kind: 'deny'; reason: string; rule?: GuardRule; transition?: GuardTransition; ask?: GateBlockAsk }
206
- | { kind: 'ask'; reason?: string; rule?: GuardRule; transition?: GuardTransition }
212
+ | { kind: 'allow'; warn?: string; rule?: GuardRule; transition?: GuardTransition; runId?: string }
213
+ | { kind: 'deny'; reason: string; rule?: GuardRule; transition?: GuardTransition; ask?: GateBlockAsk; runId?: string }
214
+ | { kind: 'ask'; reason?: string; rule?: GuardRule; transition?: GuardTransition; runId?: string }
207
215
 
208
216
  export interface ToolExecLike {
209
217
  name: string
@@ -295,6 +303,75 @@ export function currentPhaseArtifact(worktreeRoot: string, runId: string): strin
295
303
  return inForce !== '' ? inForce : best
296
304
  }
297
305
 
306
+ /**
307
+ * ISSUE 2 (a) — THE RUN A GUARD CALL IS ABOUT, and the one whose tree it may read.
308
+ *
309
+ * THE DEFECT THIS ANSWERS, measured before the fix: `recursive_lock {runId: 'run-b', artifact:
310
+ * '01-as-is.md'}` was REFUSED with `monotonic lock-order: … 00-requirements.md (DRAFT)` — run-A's blocker —
311
+ * while `run-b` had `00-requirements.md` LOCKED and `01-as-is.md` DRAFT, so locking it in run-b was LEGAL.
312
+ * The guard resolved the run from the FILESYSTEM (`resolveRunDir`, i.e. the active/newest run) while the
313
+ * tool resolves it from `args.runId`, so the guard judged a DIFFERENT RUN than the call was about. Under
314
+ * `advisory` the deny was coerced to an allow-with-warning and the tool refused on its own terms, which is
315
+ * why the strict default is what made it bite.
316
+ *
317
+ * SO THE RULE IS: for a LOCK call that NAMES a run, the guard judges THAT RUN. It is the same choice the
318
+ * tool makes, so the two layers cannot disagree about which tree the ordering rule is a property of. A
319
+ * caller that names nothing (every real `write`, and a lock that relies on the active run) is unaffected:
320
+ * the active run still governs, which is what the write-side rules rely on.
321
+ *
322
+ * ⚠ THIS IS SCOPED TO THE LOCK TOOLS DELIBERATELY, and the scope is per rule, not per convenience:
323
+ *
324
+ * - `lock-order` (`recursive_lock*`) — the caller's run WINS. The tool acts on `args.runId`, and the
325
+ * rule is about THAT run's prerequisites, so the guard must not answer for another run. This is the
326
+ * measured defect.
327
+ * - `locked-write` (the write-tool family) — NOT APPLICABLE, by construction: the rule resolves no run
328
+ * at all. It reads the target file's own `Status:` through the path the caller named, so there is no
329
+ * run to prefer and nothing could disagree.
330
+ * - `phase-order` (the write-tool family) — the ACTIVE run KEEPS WINNING, and this function does not
331
+ * touch it. Two reasons, both deliberate: (1) a `write` call carries no run id — no write tool declares
332
+ * one — so consulting `args.runId` here would hand a caller a way to ESCAPE the active run's ordering
333
+ * by naming some other run in an argument the tool ignores; and (2) the rule's declared scope is the
334
+ * run being worked in (it abstains for another run's tree, documented in `phaseOrderRule`), and moving
335
+ * that scope would be a new refusal, not a consistency fix.
336
+ *
337
+ * ⚠ A CALLER-SUPPLIED ID IS A NAME, NEVER A PATH, and it is validated before it can point the guard at
338
+ * anything: the id is trimmed the way `recursive_lock` trims it, then put through `runIdProblem` — the
339
+ * SAME gate the run-id-shaped tools use, which refuses separators, drive specifiers, `..`, a colon, a
340
+ * leading/trailing dot and an over-long name — and finally the resolved directory must sit UNDER this
341
+ * worktree's `<root>/.recursive/run`, the containment rule `runtime.ts` applies to a run directory.
342
+ *
343
+ * An id that fails any of those is NOT USED: the guard falls back to the active run, exactly as it behaved
344
+ * before this change. Falling back (rather than denying) is deliberate: an unusable id is a caller mistake
345
+ * the tool itself refuses (`BAD_RUN_ID` / `Artifact not found`), and inventing a new guard refusal for it
346
+ * would be a second, competing answer to a question `runIdProblem` already owns.
347
+ *
348
+ * A usable id does NOT have to name an EXISTING run: a run with no tree has no unlocked prerequisites, so
349
+ * the ordering rule abstains and the LOCK TOOL still refuses the lock (it checks the artifact exists before
350
+ * anything else). Requiring existence would instead re-introduce the defect in its ugliest form — a refusal
351
+ * built from ANOTHER run's blockers.
352
+ */
353
+ export function resolveGuardRunId(
354
+ name: string,
355
+ args: Record<string, unknown>,
356
+ worktreeRoot: string,
357
+ activeRunId: string,
358
+ ): string {
359
+ if (!LOCK_TOOL_NAMES.has(name) || !worktreeRoot) return activeRunId
360
+ const raw = args.runId
361
+ if (typeof raw !== 'string') return activeRunId
362
+ // The lock tool's own normalisation (`args.runId.trim()`), applied before the shape gate so a padded id
363
+ // is judged as the name the tool will act on, not as the padded string the guard happened to receive.
364
+ const declared = raw.trim()
365
+ if (declared === '' || runIdProblem(declared) !== null) return activeRunId
366
+ // CONTAINMENT. With a shape-valid name the join cannot escape, but the rule is asserted rather than
367
+ // assumed: a future change to the id grammar must not be able to move the guard's read outside the run
368
+ // layer, where the "prerequisites" it found would belong to something else entirely.
369
+ const runRoot = resolve(worktreeRoot, '.recursive', 'run')
370
+ const prefix = runRoot.endsWith(sep) ? runRoot : runRoot + sep
371
+ if (!resolve(join(runRoot, declared)).startsWith(prefix)) return activeRunId
372
+ return declared
373
+ }
374
+
298
375
  /**
299
376
  * `mode` is the gate's configured posture. Its parameter default FOLLOWS the config
300
377
  * default by REFERENCE (`DEFAULT_ENFORCEMENT.toolGuards`) rather than repeating the
@@ -315,7 +392,11 @@ export function evaluateToolGuard(
315
392
  ): ToolGuardDecision {
316
393
  const name = exec.name
317
394
  const args = (exec.arguments ?? {}) as Record<string, unknown>
318
- const runId = typeof activeRunId === 'string' ? activeRunId.trim() : ''
395
+ // ⚠ ISSUE 2 — THE RUN IS RESOLVED ONCE, HERE, for the whole call: the policy's phase baseline, the
396
+ // lock-order rule's subject and the decision's own `runId` all read this one answer, so a refusal cannot
397
+ // describe a run the rule did not read. See `resolveGuardRunId` for which rule prefers the caller's run
398
+ // and why the others do not.
399
+ const runId = resolveGuardRunId(name, args, worktreeRoot, typeof activeRunId === 'string' ? activeRunId.trim() : '')
319
400
  const runDir = join(worktreeRoot, '.recursive', 'run', runId)
320
401
 
321
402
  // T15: the transition gate is consulted BEFORE the verdict so its result can
@@ -334,7 +415,10 @@ export function evaluateToolGuard(
334
415
  const policy = resolveToolPolicyForGuard(worktreeRoot, runId, activePhaseArtifact)
335
416
  const context: ToolPolicyContext = { args, runDir, runId, worktreeRoot, activePhaseArtifact }
336
417
  const decision = evaluateToolPolicy(policy, name, args, context)
337
- return advisory(verdictFor(mode, decision, String(args.artifact ?? '')), transition)
418
+ // The resolved run rides on EVERY decision, including an allow: the guard-decision log records which run
419
+ // a decision was about, and a record that named the ACTIVE run while the rule read another one is exactly
420
+ // the two-answers-in-one-payload defect this pairs with (see `[run: <id>]` in `lockOrderRule`).
421
+ return { ...advisory(verdictFor(mode, decision, String(args.artifact ?? '')), transition), runId }
338
422
  }
339
423
 
340
424
  /**