@try-works/dsh-recursive-mode 0.5.0 → 0.6.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/README.md +21 -8
- package/lib/enforcement.d.ts +58 -0
- package/lib/index.js +423 -107
- package/lib/memory.d.ts +35 -2
- package/lib/phase-rules.d.ts +102 -0
- package/lib/policy-globs.d.ts +5 -0
- package/lib/training.d.ts +240 -2
- package/package.json +1 -1
- package/references/artifact-template.md +26 -61
- package/references/bodies/claude.md +1 -1
- package/references/bodies/copilot.md +1 -1
- package/references/bodies/cursorrules.md +4 -2
- package/references/bodies/memory-router.md +1 -1
- package/references/bodies/recursive-agents-router.md +4 -3
- package/references/bootstrap/RECURSIVE.md +21 -30
- package/src/bootstrap.ts +30 -14
- package/src/enforcement.ts +89 -5
- package/src/index.ts +795 -723
- package/src/memory.ts +50 -9
- package/src/phase-rules.ts +162 -1
- package/src/policy-globs.ts +28 -4
- package/src/runtime.ts +6 -3
- package/src/training.ts +634 -6
|
@@ -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
|
|
94
|
-
-
|
|
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
|
-
-
|
|
563
|
-
- `
|
|
564
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1767
|
-
- use
|
|
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
|
|
1800
|
+
Use the plugin's own tools to verify locks. There is no verifier script to run:
|
|
1800
1801
|
|
|
1801
|
-
```
|
|
1802
|
-
#
|
|
1803
|
-
|
|
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
|
-
#
|
|
1817
|
-
|
|
1807
|
+
# All runs in the workspace, from the slash-command surface
|
|
1808
|
+
/recursive status <run-id>
|
|
1818
1809
|
|
|
1819
|
-
#
|
|
1820
|
-
|
|
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:
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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-
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
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
|
|
package/src/enforcement.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
/**
|