@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/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
4
4
  import { Service } from "@deepseek-ai/cordis";
5
5
  import { createHash, randomUUID } from "node:crypto";
@@ -1483,15 +1483,34 @@ const LOCK_TOOL_NAMES = /* @__PURE__ */ new Set(["recursive_lock", "recursive_lo
1483
1483
  * sentence; the predicate adds the blocking artifact and its status, which is
1484
1484
  * what distinguishes a guard refusal from `lockArtifact`'s own
1485
1485
  * `Prerequisite blockers:` error (`tests/guard-path.spec.ts` asserts both).
1486
+ *
1487
+ * ⚠ ISSUE 2 (b) — AND IT NAMES THE RUN IT READ, `ctx.runId`, as `[run: <id>]`.
1488
+ *
1489
+ * The blockers above are read FROM A DIRECTORY (`runDir`), and until this suffix existed the refusal said
1490
+ * only "an earlier phase must be locked first 00-requirements.md (DRAFT)" — a sentence with no run in it.
1491
+ * That is what let a refusal MIX TWO RUNS in one payload: a call naming run-b could be judged against
1492
+ * run-a's tree (the guard resolved the run from the filesystem, the tool from `args.runId`) and the caller
1493
+ * was told about `00-requirements.md (DRAFT)` while the guard-decision record said `runId: run-a` and the
1494
+ * gate-block ask said `artifact: 01-as-is.md`. The blocking artifact, the artifact the caller named and the
1495
+ * run the record attributed it to were three answers to one question.
1496
+ *
1497
+ * The fix is two-sided: the guard now judges the run the CALL NAMES (see `resolveGuardRunId` in
1498
+ * `enforcement.ts`), and this suffix makes the evaluated run part of the sentence, so the payload can be
1499
+ * read without cross-referencing the log record — and a reader can SEE which run was read, which is what
1500
+ * makes a future mismatch visible instead of silent.
1501
+ *
1502
+ * `runId` is optional because the pure policy layer may be called with no run context at all (a policy
1503
+ * unit test, a preview probe): the sentence is then exactly what it always was, and no run is invented.
1486
1504
  */
1487
- function lockOrderRule(artifact, runDir) {
1505
+ function lockOrderRule(artifact, runDir, runId) {
1488
1506
  const name = String(artifact ?? "");
1489
1507
  if (!name || !runDir) return null;
1490
1508
  const blockers = getPrerequisiteBlockers(runDir, name);
1491
1509
  if (blockers.length === 0) return null;
1510
+ const where = typeof runId === "string" && runId.trim() !== "" ? " [run: " + runId.trim() + "]" : "";
1492
1511
  return {
1493
1512
  verdict: "deny",
1494
- detail: blockers.map((b) => b.artifact + " (" + b.status + ")").join(", "),
1513
+ detail: blockers.map((b) => b.artifact + " (" + b.status + ")").join(", ") + where,
1495
1514
  blockers
1496
1515
  };
1497
1516
  }
@@ -1622,7 +1641,7 @@ function builtInToolPolicyRules() {
1622
1641
  verdict: "deny",
1623
1642
  reason: "monotonic lock-order: an earlier phase must be locked first",
1624
1643
  label: "lock-order",
1625
- predicate: (id, args, ctx) => LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir) : null
1644
+ predicate: (id, args, ctx) => LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir, ctx.runId) : null
1626
1645
  }];
1627
1646
  for (const name of WRITE_TOOL_NAMES) rules.push({
1628
1647
  pattern: name,
@@ -1674,7 +1693,7 @@ function attachPolicyPredicate(rule) {
1674
1693
  if (rule.predicate) return rule;
1675
1694
  if (rule.pattern === "recursive_lock*") return {
1676
1695
  ...rule,
1677
- predicate: (id, args, ctx) => LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir) : null
1696
+ predicate: (id, args, ctx) => LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir, ctx.runId) : null
1678
1697
  };
1679
1698
  if (rule.label === "phase-order") return {
1680
1699
  ...rule,
@@ -2028,6 +2047,66 @@ const SECTION_MAP = {
2028
2047
  ]
2029
2048
  };
2030
2049
  /**
2050
+ * T40 — `.recursive/memory/`, the plane THIS plugin writes, and the phase-8 step that was prose.
2051
+ *
2052
+ * ⚠ WHY THIS EXISTS, MEASURED. Three completed runs in a live workspace left `.recursive/memory/`
2053
+ * exactly as `bootstrap.ts` scaffolded it: `MEMORY.md` and the skill docs were still the
2054
+ * bootstrap-created placeholders, while run 03's `08-memory-impact.md` had its
2055
+ * "Write the durable ones to memory, with provenance" box TICKED and its `Inputs` line naming
2056
+ * ANOTHER plugin's store (`memory_search` / `memory_status` is dsh-memory, not this plugin). So the
2057
+ * phase declared its memory step done against a system this plugin does not own, and the plugin's
2058
+ * own learning never activated — the owner's words: *"the agent should write to .recursive/memory/
2059
+ * in phase 8, that's a hard requirement that needs to be enforced"*.
2060
+ *
2061
+ * ⚠ THE FIX IS A FACT, NOT A STRONGER SENTENCE. A prose step is ticked by the agent that would have
2062
+ * had to do it, which is exactly what happened. What follows is the same requirement in the form the
2063
+ * workflow can CHECK: a path under the plane, declared in the artifact, whose own text on disk
2064
+ * carries this run's provenance (`Source-Runs`). "The run wrote its durable memory" then stops being
2065
+ * a claim about the run's intentions and becomes a claim about files.
2066
+ *
2067
+ * ⚠ WHY IT IS NOT IN `SECTION_MAP`, deliberately: that map is byte-parity with the canonical
2068
+ * linter's `get_artifact_required_sections` (`tests/phase-rules.parity.spec.ts` pins all twelve
2069
+ * lists), and a section added there would be a parity break dressed up as a feature. The requirement
2070
+ * rides on a section the canonical template ALREADY scaffolds — `## Affected Memory Docs` — plus a
2071
+ * field the plane's own linter already requires, so the artifact shape stays canonical.
2072
+ */
2073
+ const PHASE8_MEMORY_ARTIFACT = "08-memory-impact.md";
2074
+ const PHASE8_MEMORY_WRITE_RULE = {
2075
+ artifact: PHASE8_MEMORY_ARTIFACT,
2076
+ plane: ".recursive/memory/",
2077
+ section: "Affected Memory Docs",
2078
+ provenanceField: "Source-Runs",
2079
+ alwaysAvailable: ".recursive/memory/episodes/<run-id>.md",
2080
+ kinds: Object.keys({
2081
+ domain: {
2082
+ dir: ".recursive/memory/domains",
2083
+ type: "domain"
2084
+ },
2085
+ pattern: {
2086
+ dir: ".recursive/memory/patterns",
2087
+ type: "pattern"
2088
+ },
2089
+ incident: {
2090
+ dir: ".recursive/memory/incidents",
2091
+ type: "incident"
2092
+ },
2093
+ episode: {
2094
+ dir: ".recursive/memory/episodes",
2095
+ type: "episode"
2096
+ },
2097
+ skill: {
2098
+ dir: ".recursive/memory/skills/patterns",
2099
+ type: "pattern"
2100
+ }
2101
+ }),
2102
+ summary: "HARD: this run must have WRITTEN at least one doc under .recursive/memory/ before 08-memory-impact.md locks — .recursive/memory/episodes/<run-id>.md is always available — declared by path under `## Affected Memory Docs` and carrying `Source-Runs: <this-run-id>`; citing a shard this run did not write does not count.",
2103
+ instruction: "HARD REQUIREMENT, CHECKED AT LOCK: before 08-memory-impact.md locks, this run must have WRITTEN at least one doc under .recursive/memory/ and declared that path under `## Affected Memory Docs`. A declared path counts ONLY when the doc on disk carries `Source-Runs` naming THIS run, because that is what separates \"the run wrote its memory\" from \"the run cited someone else's\". Render the doc with the metadata the memory-plane lint requires (Type, Status, Scope, Owns-Paths, Watch-Paths, Source-Runs, Validated-At-Commit, Last-Validated, Tags): .recursive/memory/episodes/<run-id>.md is always available for a run-local lesson, `.recursive/memory/domains/`, `.recursive/memory/patterns/` and `.recursive/memory/incidents/` hold generalized knowledge, and `.recursive/memory/skills/patterns/` is where a promoted skill lesson belongs. A doc missing a required field, or carrying a Type/Status the plane lint rejects, FAILS the memory plane — write it in the canonical shape. The written path enters the run diff under .recursive/memory/, which phase 8 OWNS in its Worktree Diff Audit and Requirement Completion Status."
2104
+ };
2105
+ /** The memory-write rule for an artifact, or null when that phase owes no memory write. */
2106
+ function phase8MemoryWriteRuleFor(fileName) {
2107
+ return fileName === "08-memory-impact.md" ? PHASE8_MEMORY_WRITE_RULE : null;
2108
+ }
2109
+ /**
2031
2110
  * get_artifact_required_sections(file_name, workflow_profile): canonical-parity
2032
2111
  * required section headings for a phase artifact. Defaults to TODO + Coverage
2033
2112
  * Gate + Approval Gate for unknown files. Audited phases in strict profiles get
@@ -2069,7 +2148,8 @@ function phaseRulesFor(fileName, workflowProfile = CURRENT_WORKFLOW_PROFILE) {
2069
2148
  requiredSections: getArtifactRequiredSections(fileName, workflowProfile),
2070
2149
  audited: AUDITED_PHASE_FILES$1.has(fileName),
2071
2150
  tdd: fileName === "03-implementation-summary.md",
2072
- qa: fileName === "05-manual-qa.md"
2151
+ qa: fileName === "05-manual-qa.md",
2152
+ memoryWrite: phase8MemoryWriteRuleFor(fileName)
2073
2153
  };
2074
2154
  }
2075
2155
  /**
@@ -2088,6 +2168,7 @@ function phaseLintRulesMessage(fileName, workflowProfile = CURRENT_WORKFLOW_PROF
2088
2168
  "Audited phases: end with Audit: PASS before setting Coverage/Approval PASS; record Audit Context and Audit Verdict.",
2089
2169
  "TDD (phase 3): declare TDD Mode: strict|pragmatic; strict requires RED + GREEN evidence paths.",
2090
2170
  "QA (phase 5): declare QA Execution Mode: human|agent-operated|hybrid; human/hybrid need user sign-off.",
2171
+ ...rules.memoryWrite === null ? [] : ["Memory write (phase 8, HARD): " + rules.memoryWrite.summary],
2091
2172
  "</system-reminder>"
2092
2173
  ].join("\n");
2093
2174
  }
@@ -2184,7 +2265,7 @@ function phaseBaselineRules(fileName) {
2184
2265
  pattern: "write*",
2185
2266
  verdict: "deny",
2186
2267
  reason: "phase " + phase + " is a documentation phase: writes outside the run tree are denied (the implementation is frozen)",
2187
- predicate: (_id, args, ctx) => writesOutsideRunTree(args, ctx) ? { verdict: "deny" } : null
2268
+ predicate: (_id, args, ctx) => writesOutsideRunTree(args, ctx) && !(phase === "8" && writesOwnMemoryPlane(args, ctx)) ? { verdict: "deny" } : null
2188
2269
  });
2189
2270
  if (phase === "6" || phase === "7") rules.push({
2190
2271
  pattern: "write*",
@@ -2222,6 +2303,23 @@ function memoryPlanePath(abs) {
2222
2303
  return /\/(decisions|state)\.md$/i.test(normalized) || /\/\.recursive\/memory(\/|$)/.test(normalized);
2223
2304
  }
2224
2305
  /**
2306
+ * True when the call writes `.recursive/memory/**` — THIS PLUGIN'S OWN plane, and nothing else.
2307
+ *
2308
+ * ⚠ DELIBERATELY NARROWER THAN {@link writesMemoryPlane}, which also matches `DECISIONS.md` and
2309
+ * `STATE.md`: phase 8's carve-out (T40) is about durable memory, not about handing phase 8 the two
2310
+ * planes phases 6-7 own.
2311
+ *
2312
+ * ⚠ AND AN UNRESOLVABLE TARGET IS `false` HERE — the OPPOSITE of every other fail-closed answer in
2313
+ * this file, because this is the PERMISSIVE branch: `true` means "do not deny", so a path the rules
2314
+ * cannot place must not be admitted by it. "We could not tell where this lands" is a reason to
2315
+ * refuse, never a reason to allow.
2316
+ */
2317
+ function writesOwnMemoryPlane(args, ctx) {
2318
+ const abs = baselineTarget(args, ctx);
2319
+ if (abs === "unresolvable") return false;
2320
+ return /\/\.recursive\/memory(\/|$)/.test(abs.replace(/\\/g, "/"));
2321
+ }
2322
+ /**
2225
2323
  * Resolve a tool-target path to an absolute path. Mirrors enforcement.ts's
2226
2324
  * resolution rules: an absolute path stays as it is; a relative path resolves
2227
2325
  * against the worktree root. `null` when the value cannot be a path at all.
@@ -6326,10 +6424,23 @@ function defaultWrite(path, content) {
6326
6424
  * so the result is reproducible from the inputs alone.
6327
6425
  */
6328
6426
  /** The kinds this plugin's memory layer holds, in the order the scaffold creates them. */
6427
+ /**
6428
+ * ⚠ `training` IS IN THIS LIST BECAUSE IT IS THE KIND THE PLUGIN'S OWN TRAINING PATH WRITES.
6429
+ *
6430
+ * The phase-8 trigger (`training.ts`) writes `memory/training/<task-type>.md` and advertises that path in
6431
+ * `memory/MEMORY.md`; the shipped router (`references/bodies/memory-router.md`) tells an agent to load "the
6432
+ * relevant docs under `/.recursive/memory/training/`". With `training` absent from this list the loader
6433
+ * COULD NOT SEE THE SHARDS THE TRIGGER WROTE — the writer's own output was unreachable by the reader, so
6434
+ * "the plane's next run scores it" (README §10) was not true of exactly the shards training produces.
6435
+ *
6436
+ * `incidents/` and `archive/` stay out deliberately: `archive/` is historical by the router's own definition,
6437
+ * and widening retrieval to `incidents/` is a separate ranking decision that this change does not make.
6438
+ */
6329
6439
  const MEMORY_KINDS = [
6330
6440
  "domains",
6331
6441
  "patterns",
6332
6442
  "episodes",
6443
+ "training",
6333
6444
  "skills"
6334
6445
  ];
6335
6446
  /**
@@ -6454,6 +6565,27 @@ function entryAppliesTo(entry) {
6454
6565
  if (!match || match[1] === void 0) return [];
6455
6566
  return match[1].split(",").map((part) => part.replace(/[^0-9.]/g, "")).filter((part) => part !== "");
6456
6567
  }
6568
+ /**
6569
+ * Where the plane may live under a workspace root, in PREFERENCE order.
6570
+ *
6571
+ * ⚠ THE MEASURED DEFECT THIS FIXES. `defaultMemoryList` used to join `memory/<kind>/` straight onto the
6572
+ * root it was handed — i.e. `<root>/memory/` — and that directory EXISTS IN NO REAL WORKSPACE. `bootstrap.ts`
6573
+ * scaffolds the plane at `<root>/.recursive/memory/`, `ts-lint.ts` lints it there, and the review bundle reads
6574
+ * it there; this loader alone looked beside it. Measured live on a workspace whose `.recursive/memory/` was
6575
+ * scaffolded and whose `<root>/memory/` did not exist: `selectMemory` reported "the memory plane is empty"
6576
+ * over a plane that was there, every phase of every run — and the training shards `training.ts` writes were
6577
+ * therefore unreachable by the loader that is supposed to score them.
6578
+ *
6579
+ * ⚠ PREFER, THEN FALL BACK — NEVER MERGE. This is the rule `readFeedback` already follows for its own moved
6580
+ * sidecar (`memory-feedback.ts`: `FEEDBACK_FILE` then `LEGACY_FEEDBACK_FILE`): the current location wins, and
6581
+ * the earlier one is consulted ONLY when the current one yields nothing, because two snapshots of one shard
6582
+ * added together would count a shard twice and a duplicated shard would outrank a real one.
6583
+ *
6584
+ * ⚠ AND IT MAKES THE CALLER'S CONVENTION IRRELEVANT. Passing the workspace root resolves
6585
+ * `<root>/.recursive/memory/`; passing `.recursive` itself resolves through the second entry. Both are
6586
+ * accepted, which is what README §6 means by "using `.recursive` as the root is selected again".
6587
+ */
6588
+ const MEMORY_PLANE_BASES = [".recursive/memory", "memory"];
6457
6589
  /** Read the whole plane, minus nothing: filtering is the SELECTOR's job, not the reader's. */
6458
6590
  function loadMemoryIndex(root, readFile = defaultMemoryRead, listFiles = (kind) => defaultMemoryList(root, kind)) {
6459
6591
  return readMemoryEntries(readFile, listFiles);
@@ -6517,12 +6649,14 @@ function defaultMemoryRead(path) {
6517
6649
  }
6518
6650
  }
6519
6651
  function defaultMemoryList(root, kind) {
6520
- const dir = join(root, "memory", kind);
6521
- try {
6522
- return readdirSync(dir).filter((name) => name.endsWith(".md")).map((name) => join(dir, name));
6523
- } catch {
6524
- return [];
6652
+ for (const base of MEMORY_PLANE_BASES) {
6653
+ const dir = join(root, base, kind);
6654
+ try {
6655
+ const files = readdirSync(dir).filter((name) => name.endsWith(".md")).map((name) => join(dir, name));
6656
+ if (files.length > 0) return files;
6657
+ } catch {}
6525
6658
  }
6659
+ return [];
6526
6660
  }
6527
6661
  //#endregion
6528
6662
  //#region src/training.ts
@@ -6551,7 +6685,7 @@ function defaultMemoryList(root, kind) {
6551
6685
  * history of a learning stays readable; and a PINNED entry is untouchable by every automatic path.
6552
6686
  */
6553
6687
  /** The artifact whose lock marks a run as complete enough to learn from. */
6554
- const PHASE8_ARTIFACT = "08-memory-impact.md";
6688
+ const PHASE8_ARTIFACT = PHASE8_MEMORY_ARTIFACT;
6555
6689
  /** The parent's exit codes, kept as names so a caller cannot mistake one failure for the other. */
6556
6690
  const TRAINING_EXIT = {
6557
6691
  /** The extractor could not be reached or run. */
@@ -6742,13 +6876,30 @@ function runPhase8Trigger(root, runId, options = {}) {
6742
6876
  *
6743
6877
  * ⚠ ONE ITEM PER RUN IS NAMED, so a reader can trace a learning back to the run that produced it —
6744
6878
  * and the group is never presented as more evidence than it is.
6879
+ *
6880
+ * ⚠ T40 — AND IT NOW CARRIES THE PLANE'S METADATA HEADER, which it did not before. `memory/domains/
6881
+ * <subsystem>.md` is a doc the memory-plane lint validates like any other, and this renderer wrote a
6882
+ * bare `# Learnings:` heading — so the plugin's own cross-run extraction produced a doc its own
6883
+ * `lint_memory_plane` FAILS for nine missing fields. The extraction was right and its output shape was
6884
+ * wrong, which is exactly the kind of defect a write surface exists to prevent.
6745
6885
  */
6746
- function renderGroupShard(group) {
6886
+ function renderGroupShard(group, options = {}) {
6887
+ const runs = [...new Set(group.items.map((item) => item.runId))];
6747
6888
  const lines = [
6889
+ ...renderMemoryMetadata({
6890
+ type: "domain",
6891
+ status: "CURRENT",
6892
+ scope: "Learnings extracted for subsystem " + group.subsystem + " (" + group.mode + ") from " + runs.length + " run(s).",
6893
+ sourceRuns: runs,
6894
+ validatedAtCommit: "extracted-at-run-close",
6895
+ lastValidated: options.lastValidated ?? isoSeconds(),
6896
+ tags: [group.subsystem, group.mode]
6897
+ }).trimEnd().split("\n"),
6898
+ "",
6748
6899
  "# Learnings: " + group.subsystem,
6749
6900
  "",
6750
6901
  "- Mode: " + group.mode,
6751
- "- Runs: " + group.runs + " (" + [...new Set(group.items.map((item) => item.runId))].join(", ") + ")",
6902
+ "- Runs: " + group.runs + " (" + runs.join(", ") + ")",
6752
6903
  ""
6753
6904
  ];
6754
6905
  for (const item of group.items) lines.push("- [" + item.runId + "] " + item.text);
@@ -6909,8 +7060,20 @@ function updateMemoryRegistry(existing, entries) {
6909
7060
  function taskTypeShardPath(mode) {
6910
7061
  return "memory/training/" + mode + ".md";
6911
7062
  }
6912
- function renderTaskTypeShard(groups) {
7063
+ /** T40: same metadata-header reason as {@link renderGroupShard} — see the note there. */
7064
+ function renderTaskTypeShard(groups, options = {}) {
7065
+ const runs = [...new Set(groups.flatMap((group) => group.items.map((item) => item.runId)))];
6913
7066
  const lines = [
7067
+ ...renderMemoryMetadata({
7068
+ type: "pattern",
7069
+ status: "CURRENT",
7070
+ scope: "Training shards extracted under mode " + groups[0].mode + ", one section per subsystem group.",
7071
+ sourceRuns: runs,
7072
+ validatedAtCommit: "extracted-at-run-close",
7073
+ lastValidated: options.lastValidated ?? isoSeconds(),
7074
+ tags: ["training", groups[0].mode]
7075
+ }).trimEnd().split("\n"),
7076
+ "",
6914
7077
  "# Training shards: " + groups[0].mode,
6915
7078
  "",
6916
7079
  "Groups extracted under this mode, one section each. Learning happens through files, not model mutation.",
@@ -6975,6 +7138,41 @@ function extractAndGroup(runner, env, options = {}) {
6975
7138
  groups: groupLearnings(items, options.isWinner ?? (() => true))
6976
7139
  };
6977
7140
  }
7141
+ /** `2026-10-10T08:39:59Z` — the lock fields' own timestamp shape, and the docs' `Last-Validated` one. */
7142
+ function isoSeconds(now = /* @__PURE__ */ new Date()) {
7143
+ return now.toISOString().replace(/\.\d{3}Z$/, "Z");
7144
+ }
7145
+ function bullets(values) {
7146
+ return (values ?? []).map((value) => "- `" + value.replace(/`/g, "'") + "`");
7147
+ }
7148
+ /**
7149
+ * The metadata header every durable doc carries: the nine fields `lint_memory_doc` requires, in the
7150
+ * order the SHIPPED docs use them (`Owns-Paths:` / `Watch-Paths:` / `Tags:` stand bare when empty,
7151
+ * which is what the workspace's own promoted docs do and what `has_header_field` accepts).
7152
+ *
7153
+ * ⚠ A BACKTICK INSIDE A FIELD VALUE IS REPLACED, NOT ESCAPED, because these values are read back by
7154
+ * a line-based field reader: a stray backtick would end the value early and leave the rest of the
7155
+ * sentence in the doc as if it were a field.
7156
+ */
7157
+ function renderMemoryMetadata(input) {
7158
+ const lines = [
7159
+ "Type: `" + input.type + "`",
7160
+ "Status: `" + input.status + "`",
7161
+ "Scope: `" + input.scope.replace(/`/g, "'") + "`",
7162
+ "Owns-Paths:",
7163
+ ...bullets(input.ownsPaths),
7164
+ "Watch-Paths:",
7165
+ ...bullets(input.watchPaths),
7166
+ "Source-Runs:",
7167
+ ...bullets(input.sourceRuns),
7168
+ "Validated-At-Commit: `" + input.validatedAtCommit.replace(/`/g, "'") + "`",
7169
+ "Last-Validated: `" + input.lastValidated.replace(/`/g, "'") + "`",
7170
+ "Tags:",
7171
+ ...bullets(input.tags)
7172
+ ];
7173
+ if (input.parent !== void 0 && input.parent.trim() !== "") lines.push("Parent: `" + input.parent.replace(/`/g, "'") + "`");
7174
+ return lines.join("\n") + "\n";
7175
+ }
6978
7176
  //#endregion
6979
7177
  //#region src/run-spec.ts
6980
7178
  /** The named evidence classes, so a reader can tell a placeholder from an unmet gate. */
@@ -7724,6 +7922,75 @@ async function askRunStartDirectly(channel, exec) {
7724
7922
  }
7725
7923
  }
7726
7924
  //#endregion
7925
+ //#region src/run-id.ts
7926
+ /**
7927
+ * A RUN ID IS A NAME, NOT A PATH.
7928
+ *
7929
+ * WHY THIS MODULE EXISTS. Every consumer of a run id JOINS it onto a directory
7930
+ * that already carries the meaning "the run layer":
7931
+ *
7932
+ * join(root, '.recursive', 'run', runId) // runtime.ts, run.ts, handoff.ts, scratch.ts
7933
+ * join(repoRoot, '.worktrees', runId) // worktree.ts (a linked worktree)
7934
+ * 'recursive/' + runId // worktree.ts (the run's git branch)
7935
+ *
7936
+ * `join` is a PATH operation: absolute paths, drive specifiers and `..` segments
7937
+ * are all legal input to it, and each one silently changes what the call means.
7938
+ * A caller who passes `E:\tmp\rm-live-diagnostics\01-calculator-lib` is asking
7939
+ * for a run "on another drive"; what they get is a `mkdir` of
7940
+ *
7941
+ * <workspace>\.recursive\run\E:\tmp\rm-live-diagnostics\01-calculator-lib
7942
+ *
7943
+ * which is not drive-qualified at all — on POSIX and Windows alike the colon is
7944
+ * just another character in a relative component. The result is a bogus nested
7945
+ * folder INSIDE the workspace, created before anything can refuse it, surfacing
7946
+ * far away as an ENOENT-shaped runtime failure (RM5501) with the operator's
7947
+ * filesystem already dirty.
7948
+ *
7949
+ * SO THE RULE IS ENFORCED WHERE THE NAME ENTERS, and NOT by teaching the runtime
7950
+ * to accept a path. The joins in `runtime.ts` are CORRECT for a name; what was
7951
+ * missing was a gate on the name. Do not "fix" this back: a run on another drive
7952
+ * or in a worktree is reached through the session's control-plane root
7953
+ * (`recursive_worktree`, `00-worktree.md`) — the run layer is never relocated by
7954
+ * smuggling a path into the id.
7955
+ *
7956
+ * The charset below is deliberately the SAME one the read path already uses
7957
+ * (`live-route.ts` `DOC_SAFE_RE`) so a name this gate accepts is a name that
7958
+ * route can serve.
7959
+ */
7960
+ /**
7961
+ * The accepted shape, as prose that can be embedded in a model-facing parameter
7962
+ * description and in a refusal detail, so the rule is stated once.
7963
+ */
7964
+ const RUN_ID_RULE = "letters, digits, dot, underscore or dash only, no leading or trailing dot, no path separator, no drive specifier and no \"..\" segment";
7965
+ /** Directory-name charset — the read path's `DOC_SAFE_RE`, verbatim. */
7966
+ const RUN_ID_CHARS = /^[A-Za-z0-9._-]+$/;
7967
+ /**
7968
+ * Why a run id is refused, or `null` when it is a usable NAME.
7969
+ *
7970
+ * The returned string is the SPECIFIC problem (which rule the id broke), with no
7971
+ * trailing punctuation and no sentence of its own, so a caller can hand it to
7972
+ * `toolError('BAD_RUN_ID', …)` as the detail. `RUN_ID_RULE` states the shape.
7973
+ *
7974
+ * The order of the checks is part of the message quality: a Windows absolute
7975
+ * path is reported as a drive-qualified path (what the caller passed) rather
7976
+ * than as a separator complaint (what that path is made of).
7977
+ */
7978
+ function runIdProblem(raw) {
7979
+ if (raw === "") return "runId is empty";
7980
+ if (raw.length > 100) return "runId is " + raw.length + " characters, over the 100 allowed";
7981
+ if (/^[A-Za-z]:/.test(raw)) return "runId is a Windows drive-qualified path, starting with \"" + raw.slice(0, 2) + "\"";
7982
+ if (raw.includes("/") || raw.includes("\\")) return "runId contains the path separator \"" + (raw.includes("/") ? "/" : "\\") + "\"";
7983
+ if (raw.includes(":")) return "runId contains a colon (\":\"), which is a drive and stream separator on Windows";
7984
+ if (raw.includes("..")) return "runId contains a \"..\" segment, which escapes the run directory";
7985
+ if (raw.startsWith(".")) return "runId starts with \".\", which makes it a hidden name or a relative path segment";
7986
+ if (raw.endsWith(".")) return "runId ends with \".\"";
7987
+ if (!RUN_ID_CHARS.test(raw)) {
7988
+ if (/\s/.test(raw)) return "runId contains a space or other whitespace character inside the name";
7989
+ return "runId contains a character outside the allowed set";
7990
+ }
7991
+ return null;
7992
+ }
7993
+ //#endregion
7727
7994
  //#region src/lifecycle.ts
7728
7995
  /**
7729
7996
  * Transition gate validation + goal coupling (Phase C R1/R2/R6, PROPOSAL 8.4).
@@ -7982,6 +8249,64 @@ function currentPhaseArtifact(worktreeRoot, runId) {
7982
8249
  return inForce !== "" ? inForce : best;
7983
8250
  }
7984
8251
  /**
8252
+ * ISSUE 2 (a) — THE RUN A GUARD CALL IS ABOUT, and the one whose tree it may read.
8253
+ *
8254
+ * THE DEFECT THIS ANSWERS, measured before the fix: `recursive_lock {runId: 'run-b', artifact:
8255
+ * '01-as-is.md'}` was REFUSED with `monotonic lock-order: … 00-requirements.md (DRAFT)` — run-A's blocker —
8256
+ * while `run-b` had `00-requirements.md` LOCKED and `01-as-is.md` DRAFT, so locking it in run-b was LEGAL.
8257
+ * The guard resolved the run from the FILESYSTEM (`resolveRunDir`, i.e. the active/newest run) while the
8258
+ * tool resolves it from `args.runId`, so the guard judged a DIFFERENT RUN than the call was about. Under
8259
+ * `advisory` the deny was coerced to an allow-with-warning and the tool refused on its own terms, which is
8260
+ * why the strict default is what made it bite.
8261
+ *
8262
+ * SO THE RULE IS: for a LOCK call that NAMES a run, the guard judges THAT RUN. It is the same choice the
8263
+ * tool makes, so the two layers cannot disagree about which tree the ordering rule is a property of. A
8264
+ * caller that names nothing (every real `write`, and a lock that relies on the active run) is unaffected:
8265
+ * the active run still governs, which is what the write-side rules rely on.
8266
+ *
8267
+ * ⚠ THIS IS SCOPED TO THE LOCK TOOLS DELIBERATELY, and the scope is per rule, not per convenience:
8268
+ *
8269
+ * - `lock-order` (`recursive_lock*`) — the caller's run WINS. The tool acts on `args.runId`, and the
8270
+ * rule is about THAT run's prerequisites, so the guard must not answer for another run. This is the
8271
+ * measured defect.
8272
+ * - `locked-write` (the write-tool family) — NOT APPLICABLE, by construction: the rule resolves no run
8273
+ * at all. It reads the target file's own `Status:` through the path the caller named, so there is no
8274
+ * run to prefer and nothing could disagree.
8275
+ * - `phase-order` (the write-tool family) — the ACTIVE run KEEPS WINNING, and this function does not
8276
+ * touch it. Two reasons, both deliberate: (1) a `write` call carries no run id — no write tool declares
8277
+ * one — so consulting `args.runId` here would hand a caller a way to ESCAPE the active run's ordering
8278
+ * by naming some other run in an argument the tool ignores; and (2) the rule's declared scope is the
8279
+ * run being worked in (it abstains for another run's tree, documented in `phaseOrderRule`), and moving
8280
+ * that scope would be a new refusal, not a consistency fix.
8281
+ *
8282
+ * ⚠ A CALLER-SUPPLIED ID IS A NAME, NEVER A PATH, and it is validated before it can point the guard at
8283
+ * anything: the id is trimmed the way `recursive_lock` trims it, then put through `runIdProblem` — the
8284
+ * SAME gate the run-id-shaped tools use, which refuses separators, drive specifiers, `..`, a colon, a
8285
+ * leading/trailing dot and an over-long name — and finally the resolved directory must sit UNDER this
8286
+ * worktree's `<root>/.recursive/run`, the containment rule `runtime.ts` applies to a run directory.
8287
+ *
8288
+ * An id that fails any of those is NOT USED: the guard falls back to the active run, exactly as it behaved
8289
+ * before this change. Falling back (rather than denying) is deliberate: an unusable id is a caller mistake
8290
+ * the tool itself refuses (`BAD_RUN_ID` / `Artifact not found`), and inventing a new guard refusal for it
8291
+ * would be a second, competing answer to a question `runIdProblem` already owns.
8292
+ *
8293
+ * A usable id does NOT have to name an EXISTING run: a run with no tree has no unlocked prerequisites, so
8294
+ * the ordering rule abstains and the LOCK TOOL still refuses the lock (it checks the artifact exists before
8295
+ * anything else). Requiring existence would instead re-introduce the defect in its ugliest form — a refusal
8296
+ * built from ANOTHER run's blockers.
8297
+ */
8298
+ function resolveGuardRunId(name, args, worktreeRoot, activeRunId) {
8299
+ if (!LOCK_TOOL_NAMES.has(name) || !worktreeRoot) return activeRunId;
8300
+ const raw = args.runId;
8301
+ if (typeof raw !== "string") return activeRunId;
8302
+ const declared = raw.trim();
8303
+ if (declared === "" || runIdProblem(declared) !== null) return activeRunId;
8304
+ const runRoot = resolve(worktreeRoot, ".recursive", "run");
8305
+ const prefix = runRoot.endsWith(sep) ? runRoot : runRoot + sep;
8306
+ if (!resolve(join(runRoot, declared)).startsWith(prefix)) return activeRunId;
8307
+ return declared;
8308
+ }
8309
+ /**
7985
8310
  * `mode` is the gate's configured posture. Its parameter default FOLLOWS the config
7986
8311
  * default by REFERENCE (`DEFAULT_ENFORCEMENT.toolGuards`) rather than repeating the
7987
8312
  * literal: a bare call is "the caller had no mode to hand", and the answer to that must
@@ -7996,17 +8321,20 @@ function currentPhaseArtifact(worktreeRoot, runId) {
7996
8321
  function evaluateToolGuard(exec, worktreeRoot, activeRunId, mode = DEFAULT_ENFORCEMENT.toolGuards) {
7997
8322
  const name = exec.name;
7998
8323
  const args = exec.arguments ?? {};
7999
- const runId = typeof activeRunId === "string" ? activeRunId.trim() : "";
8324
+ const runId = resolveGuardRunId(name, args, worktreeRoot, typeof activeRunId === "string" ? activeRunId.trim() : "");
8000
8325
  const runDir = join(worktreeRoot, ".recursive", "run", runId);
8001
8326
  const transition = consultTransitionGate(name, args, worktreeRoot, runId);
8002
8327
  const activePhaseArtifact = currentPhaseArtifact(worktreeRoot, runId);
8003
- return advisory(verdictFor(mode, evaluateToolPolicy(resolveToolPolicyForGuard(worktreeRoot, runId, activePhaseArtifact), name, args, {
8004
- args,
8005
- runDir,
8006
- runId,
8007
- worktreeRoot,
8008
- activePhaseArtifact
8009
- }), String(args.artifact ?? "")), transition);
8328
+ return {
8329
+ ...advisory(verdictFor(mode, evaluateToolPolicy(resolveToolPolicyForGuard(worktreeRoot, runId, activePhaseArtifact), name, args, {
8330
+ args,
8331
+ runDir,
8332
+ runId,
8333
+ worktreeRoot,
8334
+ activePhaseArtifact
8335
+ }), String(args.artifact ?? "")), transition),
8336
+ runId
8337
+ };
8010
8338
  }
8011
8339
  /**
8012
8340
  * Map the policy's verdict onto the guard's decision kind: `strict` denies,
@@ -10751,14 +11079,14 @@ var RecursiveRuntime = class extends Service {
10751
11079
  responseFile: join(runDir, "training-response.json")
10752
11080
  }),
10753
11081
  write: (relativePath, content) => {
10754
- const target = join(root, relativePath);
11082
+ const target = join(root, ".recursive", relativePath);
10755
11083
  mkdirSync(dirname(target), { recursive: true });
10756
11084
  writeFileSync(target, content, "utf8");
10757
11085
  return relativePath;
10758
11086
  },
10759
11087
  readText: (relativePath) => {
10760
11088
  try {
10761
- return readFileSync(join(root, relativePath), "utf8");
11089
+ return readFileSync(join(root, ".recursive", relativePath), "utf8");
10762
11090
  } catch {
10763
11091
  return null;
10764
11092
  }
@@ -11874,75 +12202,6 @@ function createRecursiveStatusTool(recursive) {
11874
12202
  });
11875
12203
  }
11876
12204
  //#endregion
11877
- //#region src/run-id.ts
11878
- /**
11879
- * A RUN ID IS A NAME, NOT A PATH.
11880
- *
11881
- * WHY THIS MODULE EXISTS. Every consumer of a run id JOINS it onto a directory
11882
- * that already carries the meaning "the run layer":
11883
- *
11884
- * join(root, '.recursive', 'run', runId) // runtime.ts, run.ts, handoff.ts, scratch.ts
11885
- * join(repoRoot, '.worktrees', runId) // worktree.ts (a linked worktree)
11886
- * 'recursive/' + runId // worktree.ts (the run's git branch)
11887
- *
11888
- * `join` is a PATH operation: absolute paths, drive specifiers and `..` segments
11889
- * are all legal input to it, and each one silently changes what the call means.
11890
- * A caller who passes `E:\tmp\rm-live-diagnostics\01-calculator-lib` is asking
11891
- * for a run "on another drive"; what they get is a `mkdir` of
11892
- *
11893
- * <workspace>\.recursive\run\E:\tmp\rm-live-diagnostics\01-calculator-lib
11894
- *
11895
- * which is not drive-qualified at all — on POSIX and Windows alike the colon is
11896
- * just another character in a relative component. The result is a bogus nested
11897
- * folder INSIDE the workspace, created before anything can refuse it, surfacing
11898
- * far away as an ENOENT-shaped runtime failure (RM5501) with the operator's
11899
- * filesystem already dirty.
11900
- *
11901
- * SO THE RULE IS ENFORCED WHERE THE NAME ENTERS, and NOT by teaching the runtime
11902
- * to accept a path. The joins in `runtime.ts` are CORRECT for a name; what was
11903
- * missing was a gate on the name. Do not "fix" this back: a run on another drive
11904
- * or in a worktree is reached through the session's control-plane root
11905
- * (`recursive_worktree`, `00-worktree.md`) — the run layer is never relocated by
11906
- * smuggling a path into the id.
11907
- *
11908
- * The charset below is deliberately the SAME one the read path already uses
11909
- * (`live-route.ts` `DOC_SAFE_RE`) so a name this gate accepts is a name that
11910
- * route can serve.
11911
- */
11912
- /**
11913
- * The accepted shape, as prose that can be embedded in a model-facing parameter
11914
- * description and in a refusal detail, so the rule is stated once.
11915
- */
11916
- const RUN_ID_RULE = "letters, digits, dot, underscore or dash only, no leading or trailing dot, no path separator, no drive specifier and no \"..\" segment";
11917
- /** Directory-name charset — the read path's `DOC_SAFE_RE`, verbatim. */
11918
- const RUN_ID_CHARS = /^[A-Za-z0-9._-]+$/;
11919
- /**
11920
- * Why a run id is refused, or `null` when it is a usable NAME.
11921
- *
11922
- * The returned string is the SPECIFIC problem (which rule the id broke), with no
11923
- * trailing punctuation and no sentence of its own, so a caller can hand it to
11924
- * `toolError('BAD_RUN_ID', …)` as the detail. `RUN_ID_RULE` states the shape.
11925
- *
11926
- * The order of the checks is part of the message quality: a Windows absolute
11927
- * path is reported as a drive-qualified path (what the caller passed) rather
11928
- * than as a separator complaint (what that path is made of).
11929
- */
11930
- function runIdProblem(raw) {
11931
- if (raw === "") return "runId is empty";
11932
- if (raw.length > 100) return "runId is " + raw.length + " characters, over the 100 allowed";
11933
- if (/^[A-Za-z]:/.test(raw)) return "runId is a Windows drive-qualified path, starting with \"" + raw.slice(0, 2) + "\"";
11934
- if (raw.includes("/") || raw.includes("\\")) return "runId contains the path separator \"" + (raw.includes("/") ? "/" : "\\") + "\"";
11935
- if (raw.includes(":")) return "runId contains a colon (\":\"), which is a drive and stream separator on Windows";
11936
- if (raw.includes("..")) return "runId contains a \"..\" segment, which escapes the run directory";
11937
- if (raw.startsWith(".")) return "runId starts with \".\", which makes it a hidden name or a relative path segment";
11938
- if (raw.endsWith(".")) return "runId ends with \".\"";
11939
- if (!RUN_ID_CHARS.test(raw)) {
11940
- if (/\s/.test(raw)) return "runId contains a space or other whitespace character inside the name";
11941
- return "runId contains a character outside the allowed set";
11942
- }
11943
- return null;
11944
- }
11945
- //#endregion
11946
12205
  //#region src/recursive_init.tool.ts
11947
12206
  /**
11948
12207
  * PHASE 0 — SCAFFOLDING IS NOT STARTING, AND THE TOOL SAYS SO AT THE MOMENT IT MATTERS.
@@ -13245,10 +13504,12 @@ function createRecursivePreviewTool(recursive) {
13245
13504
  * Idempotent scaffold installer (R3). TS port of install-recursive-mode.py's
13246
13505
  * core: bootstrap the FULL canonical /.recursive/ control plane + cross-tool
13247
13506
  * bridges byte-identically (RECURSIVE.md marker-wrapped, AGENTS.md, STATE/
13248
- * DECISIONS, memory routers + shards, config/recursive-router.json, .gitignore,
13249
- * vendored runtime scripts copied into .recursive/scripts/), plus the
13250
- * agent/session-start Stage B (new vs resume) workspace-scoped to the session's
13251
- * control-plane root (R1).
13507
+ * DECISIONS, memory routers + shards, config/recursive-router.json, .gitignore),
13508
+ * plus the agent/session-start Stage B (new vs resume) workspace-scoped to the
13509
+ * session's control-plane root (R1).
13510
+ *
13511
+ * ⚠ NO `.recursive/scripts/` IS CREATED, and a legacy one is removed once it is empty — see the block in
13512
+ * the scaffold below for the measurement that decided it.
13252
13513
  *
13253
13514
  * Templates + bodies + runtime scripts are SHIPPED package files under
13254
13515
  * references/ (never inlined TS string literals) and resolved relative to this
@@ -13485,11 +13746,13 @@ function bootstrapScaffold(root) {
13485
13746
  ".recursive/memory/skills/patterns/.gitkeep",
13486
13747
  ".recursive/run/.gitkeep"
13487
13748
  ]) noteFile(rel, "");
13488
- noteDir(".recursive/scripts");
13489
13749
  {
13490
13750
  const scriptsDir = join(recursiveRoot, "scripts");
13491
13751
  if (existsSync(scriptsDir)) {
13492
13752
  for (const name of readdirSync(scriptsDir)) if (name.endsWith(".py") || name.endsWith(".ps1")) rmSync(join(scriptsDir, name), { force: true });
13753
+ try {
13754
+ if (readdirSync(scriptsDir).length === 0) rmdirSync(scriptsDir);
13755
+ } catch {}
13493
13756
  }
13494
13757
  }
13495
13758
  noteFile(".recursive/RECURSIVE.md", "# RECURSIVE.md\n");
@@ -14735,15 +14998,17 @@ function runToolGuard(recursive, exec, root, runId) {
14735
14998
  const guardMode = recursive.enforcementConfig.toolGuards;
14736
14999
  const decision = evaluateToolGuard(exec, root, runId, guardMode);
14737
15000
  const coerced = coerceAskToDecision(decision, guardMode);
15001
+ const evaluatedRunId = decision.runId ?? runId;
14738
15002
  const final = coerced === decision ? decision : {
14739
15003
  ...coerced,
14740
15004
  rule: decision.rule,
14741
- transition: decision.transition
15005
+ transition: decision.transition,
15006
+ runId: evaluatedRunId
14742
15007
  };
14743
15008
  if (root) {
14744
15009
  const record = {
14745
15010
  at: (/* @__PURE__ */ new Date()).toISOString(),
14746
- runId,
15011
+ runId: evaluatedRunId,
14747
15012
  tool: exec?.name ?? "",
14748
15013
  kind: final.kind,
14749
15014
  rule: final.rule ?? "none"
@@ -14757,6 +15022,54 @@ function runToolGuard(recursive, exec, root, runId) {
14757
15022
  }
14758
15023
  return final;
14759
15024
  }
15025
+ /**
15026
+ * ISSUE 1 — THE GOAL BLOCK, FROM THE LAYER THAT REFUSED.
15027
+ *
15028
+ * WHERE THIS BELONGS, decided from the code rather than assumed: the GUARD CANNOT DO THIS ITSELF.
15029
+ * `evaluateToolGuard` is a pure policy layer — it takes an exec, a worktree root, a run id and a mode, and
15030
+ * it has no goals service, no live agent and no runtime handle; it is also called from a dry-run preview
15031
+ * (`src/recursive_preview.tool.ts`), where a side effect would be a lie about a call that never happened.
15032
+ * The ONE place where a guard refusal becomes real is the `tools/pre-execute` listener below: it holds the
15033
+ * runtime (which owns `blockRunToGoal` and the late-attached goals service), the live agent from the exec
15034
+ * payload, and the decision itself — and it is the same boundary that already renders the refusal's ask
15035
+ * into the caller's text (FU-7). So this is called there, and nowhere else.
15036
+ *
15037
+ * WHY IT IS NEEDED AT ALL. `lockArtifact` blocks the run's goal when its OWN ordering check refuses
15038
+ * (`runtime.ts`, the `Prerequisite blockers:` branch). Under the strict default the guard refuses an
15039
+ * out-of-order lock BEFORE DISPATCH, so `lockArtifact` never runs, its block never happens, and the run was
15040
+ * told it was blocked while the goal machinery was not — the goal stayed armed and kept driving rounds
15041
+ * through a refused gate.
15042
+ *
15043
+ * ⚠ WHY THIS CANNOT DOUBLE-BLOCK. The two block sites are on MUTUALLY EXCLUSIVE branches of one call:
15044
+ * this one runs only when the guard DENIED (so the tool is never dispatched), and the tool's own block runs
15045
+ * only when the guard let the call through to `lockArtifact`. One refusal, one dispatch decision, one
15046
+ * block. A repeat of the SAME refused call re-enters this branch, and the second block is refused by the
15047
+ * goal service itself (`block` requires an ACTIVE goal; an already-blocked goal is not active), which is
15048
+ * swallowed here exactly as the tool path swallows it — the goal stays blocked, and it is not blocked
15049
+ * twice.
15050
+ *
15051
+ * ⚠ THE TRIGGER IS THE ASK, NOT THE RULE LABEL — the same trigger FU-7 uses, for the same reason: an ask is
15052
+ * present exactly when the refusal was DECIDED FROM REAL ORDERING BLOCKERS (`PolicyDecision.blockers` read
15053
+ * from disk), which includes a policy FILE whose `recursive_lock*` deny carries no label (its `rule` reads
15054
+ * `none`). Gating on the label instead would silently skip the goal block in every repo that ships a policy
15055
+ * file — the shipped default here.
15056
+ *
15057
+ * ⚠ AND IT IS THE LOCK ORDERING REFUSAL ONLY. The phase-order WRITE rule refuses a write ahead of the
15058
+ * active phase, and it has NO tool-layer counterpart that blocks a goal — `lockArtifact` is the only
15059
+ * tool-side blocker in the plugin. Blocking a goal on it would be a NEW behaviour, not the consistency this
15060
+ * fix is for: the defect was one refusal with two layers disagreeing, not a rule that should start
15061
+ * blocking.
15062
+ */
15063
+ function blockGoalOnGuardRefusal(recursive, exec, decision, activeRunId) {
15064
+ if (decision.kind !== "deny" || decision.ask === void 0) return;
15065
+ const agent = exec?.agent ?? null;
15066
+ try {
15067
+ recursive.blockRunToGoal(agent, decision.runId ?? activeRunId, {
15068
+ code: "prerequisite-blockers",
15069
+ message: decision.reason ?? "the lock was refused: its prerequisites are unmet"
15070
+ });
15071
+ } catch {}
15072
+ }
14760
15073
  function apply(ctx, config) {
14761
15074
  if (config?.shellOnly) return;
14762
15075
  ctx.effect(function* () {
@@ -14907,10 +15220,13 @@ function apply(ctx, config) {
14907
15220
  kind: "deny",
14908
15221
  reason: "the tool guard produced no decision"
14909
15222
  };
14910
- if (final.kind === "deny") return final.ask === void 0 ? final : {
14911
- ...final,
14912
- reason: final.reason + " " + renderGateBlockAsk(final.ask)
14913
- };
15223
+ if (final.kind === "deny") {
15224
+ blockGoalOnGuardRefusal(recursive, exec, final, runId);
15225
+ return final.ask === void 0 ? final : {
15226
+ ...final,
15227
+ reason: final.reason + " " + renderGateBlockAsk(final.ask)
15228
+ };
15229
+ }
14914
15230
  if (final.kind === "allow" && final.warn) console.warn("[recursive] tool guard (" + recursive.enforcementConfig.toolGuards + ") allowed this call: " + final.warn);
14915
15231
  return typeof next === "function" ? next() : { kind: "allow" };
14916
15232
  }));
@@ -15028,4 +15344,4 @@ function apply(ctx, config) {
15028
15344
  });
15029
15345
  }
15030
15346
  //#endregion
15031
- export { Config, DEFAULT_BUDGETS, DEFAULT_ENFORCEMENT, DEFAULT_ENFORCEMENT_MODE, OPTIONAL_PHASES, PHASES, PHASE_POSITIONS, PHASE_SEQUENCE, RECURSIVE_API_PREFIX, RUN_ARTIFACT_SEQUENCE, RUN_STATES, RecursiveRuntime, actionRecordStatus, apply, auditToPass, buildDelegationPrompt, buildReviewBundle, buildWorkSlice, builtInToolPolicy, capabilityProbe, childScratchPath, coerceAskToDecision, contentSha256, contractDigest, coupleGateBlockToGoal, createChildBrief, createHandoff, createRecursiveCloseoutTool, createRecursiveInitTool, createRecursiveLintTool, createRecursiveLockTool, createRecursivePhaseTool, createRecursiveScratchTool, createRecursiveStatusTool, createRecursiveWorktreeTool, currentPhaseArtifact, defaultReviewToolFilter, delegate, delegateContinuable, delegationDecisionBasis, delegationError, detectTamper, discoverRuns, drainContinuableChildren, drainContinuableDescendants, escapeRegExp, evaluateDelegationResult, evaluateToolGuard, foldDiagnostics, foldRun, foldRunCard, getAllStaleReceipts, getArtifactState, getGateStatus, getLatestRunDirectory, getLockStatus, getMdFieldValue, getNextLegalPhase, getPrerequisiteBlockers, getPrerequisites, getStaleDownstreamPhases, getTodoStats, getWorkflowProfile, inject, interruptContinuable, invalidateReceipt, isCoreArtifact, isTaskClaimedBy, loadRouterPolicy, lockHashFromContent, makeRecursiveRoutes, mountRecursiveRoutesOnce, name, normalizeForLockHash, parseReplyVerdict, pendingWork, phaseIndex, phasePosition, probeCapabilities, readReceipt, readRepairFromReply, readRepairFromStructured, readVerdictFromReply, readVerdictFromStructured, receiptPath, referencesFromResult, registerRecursiveSkill, remainingDepthFor, renderPhaseTail, renderRecursivePolicy, renderStableContract, renderTaskHistory, replyPath, resetFoldCache, resolveEnforcementConfig, resolveRole, resolveRunDir, resolveToolPolicyForGuard, reviewBundleDir, reviewOutputSchema, routerPolicyPath, snapshotWorkspace, tamperCandidatePath, trimMdValue, validateChain, validateReferences, validateTransition, writeActionRecord, writeReceipt };
15347
+ export { Config, DEFAULT_BUDGETS, DEFAULT_ENFORCEMENT, DEFAULT_ENFORCEMENT_MODE, OPTIONAL_PHASES, PHASES, PHASE_POSITIONS, PHASE_SEQUENCE, RECURSIVE_API_PREFIX, RUN_ARTIFACT_SEQUENCE, RUN_STATES, RecursiveRuntime, actionRecordStatus, apply, auditToPass, buildDelegationPrompt, buildReviewBundle, buildWorkSlice, builtInToolPolicy, capabilityProbe, childScratchPath, coerceAskToDecision, contentSha256, contractDigest, coupleGateBlockToGoal, createChildBrief, createHandoff, createRecursiveCloseoutTool, createRecursiveInitTool, createRecursiveLintTool, createRecursiveLockTool, createRecursivePhaseTool, createRecursiveScratchTool, createRecursiveStatusTool, createRecursiveWorktreeTool, currentPhaseArtifact, defaultReviewToolFilter, delegate, delegateContinuable, delegationDecisionBasis, delegationError, detectTamper, discoverRuns, drainContinuableChildren, drainContinuableDescendants, escapeRegExp, evaluateDelegationResult, evaluateToolGuard, foldDiagnostics, foldRun, foldRunCard, getAllStaleReceipts, getArtifactState, getGateStatus, getLatestRunDirectory, getLockStatus, getMdFieldValue, getNextLegalPhase, getPrerequisiteBlockers, getPrerequisites, getStaleDownstreamPhases, getTodoStats, getWorkflowProfile, inject, interruptContinuable, invalidateReceipt, isCoreArtifact, isTaskClaimedBy, loadRouterPolicy, lockHashFromContent, makeRecursiveRoutes, mountRecursiveRoutesOnce, name, normalizeForLockHash, parseReplyVerdict, pendingWork, phaseIndex, phasePosition, probeCapabilities, readReceipt, readRepairFromReply, readRepairFromStructured, readVerdictFromReply, readVerdictFromStructured, receiptPath, referencesFromResult, registerRecursiveSkill, remainingDepthFor, renderPhaseTail, renderRecursivePolicy, renderStableContract, renderTaskHistory, replyPath, resetFoldCache, resolveEnforcementConfig, resolveGuardRunId, resolveRole, resolveRunDir, resolveToolPolicyForGuard, reviewBundleDir, reviewOutputSchema, routerPolicyPath, snapshotWorkspace, tamperCandidatePath, trimMdValue, validateChain, validateReferences, validateTransition, writeActionRecord, writeReceipt };