@try-works/dsh-recursive-mode 0.4.9 → 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/config.d.ts +14 -0
- package/lib/enforcement.d.ts +118 -0
- package/lib/guard-log.d.ts +7 -0
- package/lib/index.js +568 -133
- package/lib/memory.d.ts +35 -2
- package/lib/phase-rules.d.ts +102 -0
- package/lib/policy-globs.d.ts +24 -0
- package/lib/recursive_ask.tool.d.ts +37 -0
- package/lib/runtime.d.ts +1 -1
- 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/scripts/test-recursive-mode-smoke.ts +11 -1
- package/src/bootstrap.ts +30 -14
- package/src/config.ts +11 -4
- package/src/enforcement.ts +202 -15
- package/src/guard-log.ts +7 -0
- package/src/index.ts +795 -700
- package/src/memory.ts +50 -9
- package/src/phase-rules.ts +162 -1
- package/src/policy-globs.ts +64 -8
- package/src/recursive_ask.tool.ts +48 -0
- package/src/recursive_lock.tool.ts +8 -2
- package/src/runtime.ts +7 -4
- package/src/training.ts +634 -6
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";
|
|
@@ -1409,7 +1409,7 @@ function evaluateToolPolicy(policy, id, args = {}, ctx) {
|
|
|
1409
1409
|
if (rule.predicate) {
|
|
1410
1410
|
const match = rule.predicate(id, args, context);
|
|
1411
1411
|
if (match === null) continue;
|
|
1412
|
-
return decide(rule, match.verdict, match.detail ? rule.reason + " " + match.detail : rule.reason);
|
|
1412
|
+
return decide(rule, match.verdict, match.detail ? rule.reason + " " + match.detail : rule.reason, match.blockers);
|
|
1413
1413
|
}
|
|
1414
1414
|
return decide(rule, rule.verdict, rule.reason);
|
|
1415
1415
|
}
|
|
@@ -1418,13 +1418,20 @@ function evaluateToolPolicy(policy, id, args = {}, ctx) {
|
|
|
1418
1418
|
reason: "no policy rule matches " + id + " - ask is the no-match default"
|
|
1419
1419
|
};
|
|
1420
1420
|
}
|
|
1421
|
-
/**
|
|
1422
|
-
|
|
1421
|
+
/**
|
|
1422
|
+
* One place where a rule's verdict becomes a decision, so `label` cannot drift.
|
|
1423
|
+
*
|
|
1424
|
+
* `blockers` rides along untouched when the predicate supplied any (see `Decision.blockers`);
|
|
1425
|
+
* an EMPTY list is dropped rather than carried, so `blockers` on a decision always means "there
|
|
1426
|
+
* were blockers", never "the rule looked and found none".
|
|
1427
|
+
*/
|
|
1428
|
+
function decide(rule, kind, reason, blockers) {
|
|
1423
1429
|
const decision = {
|
|
1424
1430
|
kind,
|
|
1425
1431
|
reason
|
|
1426
1432
|
};
|
|
1427
1433
|
if (rule.label) decision.rule = rule.label;
|
|
1434
|
+
if (blockers !== void 0 && blockers.length > 0) decision.blockers = blockers;
|
|
1428
1435
|
return decision;
|
|
1429
1436
|
}
|
|
1430
1437
|
/**
|
|
@@ -1476,15 +1483,35 @@ const LOCK_TOOL_NAMES = /* @__PURE__ */ new Set(["recursive_lock", "recursive_lo
|
|
|
1476
1483
|
* sentence; the predicate adds the blocking artifact and its status, which is
|
|
1477
1484
|
* what distinguishes a guard refusal from `lockArtifact`'s own
|
|
1478
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.
|
|
1479
1504
|
*/
|
|
1480
|
-
function lockOrderRule(artifact, runDir) {
|
|
1505
|
+
function lockOrderRule(artifact, runDir, runId) {
|
|
1481
1506
|
const name = String(artifact ?? "");
|
|
1482
1507
|
if (!name || !runDir) return null;
|
|
1483
1508
|
const blockers = getPrerequisiteBlockers(runDir, name);
|
|
1484
1509
|
if (blockers.length === 0) return null;
|
|
1510
|
+
const where = typeof runId === "string" && runId.trim() !== "" ? " [run: " + runId.trim() + "]" : "";
|
|
1485
1511
|
return {
|
|
1486
1512
|
verdict: "deny",
|
|
1487
|
-
detail: blockers.map((b) => b.artifact + " (" + b.status + ")").join(", ")
|
|
1513
|
+
detail: blockers.map((b) => b.artifact + " (" + b.status + ")").join(", ") + where,
|
|
1514
|
+
blockers
|
|
1488
1515
|
};
|
|
1489
1516
|
}
|
|
1490
1517
|
/**
|
|
@@ -1614,7 +1641,7 @@ function builtInToolPolicyRules() {
|
|
|
1614
1641
|
verdict: "deny",
|
|
1615
1642
|
reason: "monotonic lock-order: an earlier phase must be locked first",
|
|
1616
1643
|
label: "lock-order",
|
|
1617
|
-
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
|
|
1618
1645
|
}];
|
|
1619
1646
|
for (const name of WRITE_TOOL_NAMES) rules.push({
|
|
1620
1647
|
pattern: name,
|
|
@@ -1666,7 +1693,7 @@ function attachPolicyPredicate(rule) {
|
|
|
1666
1693
|
if (rule.predicate) return rule;
|
|
1667
1694
|
if (rule.pattern === "recursive_lock*") return {
|
|
1668
1695
|
...rule,
|
|
1669
|
-
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
|
|
1670
1697
|
};
|
|
1671
1698
|
if (rule.label === "phase-order") return {
|
|
1672
1699
|
...rule,
|
|
@@ -2020,6 +2047,66 @@ const SECTION_MAP = {
|
|
|
2020
2047
|
]
|
|
2021
2048
|
};
|
|
2022
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
|
+
/**
|
|
2023
2110
|
* get_artifact_required_sections(file_name, workflow_profile): canonical-parity
|
|
2024
2111
|
* required section headings for a phase artifact. Defaults to TODO + Coverage
|
|
2025
2112
|
* Gate + Approval Gate for unknown files. Audited phases in strict profiles get
|
|
@@ -2061,7 +2148,8 @@ function phaseRulesFor(fileName, workflowProfile = CURRENT_WORKFLOW_PROFILE) {
|
|
|
2061
2148
|
requiredSections: getArtifactRequiredSections(fileName, workflowProfile),
|
|
2062
2149
|
audited: AUDITED_PHASE_FILES$1.has(fileName),
|
|
2063
2150
|
tdd: fileName === "03-implementation-summary.md",
|
|
2064
|
-
qa: fileName === "05-manual-qa.md"
|
|
2151
|
+
qa: fileName === "05-manual-qa.md",
|
|
2152
|
+
memoryWrite: phase8MemoryWriteRuleFor(fileName)
|
|
2065
2153
|
};
|
|
2066
2154
|
}
|
|
2067
2155
|
/**
|
|
@@ -2080,6 +2168,7 @@ function phaseLintRulesMessage(fileName, workflowProfile = CURRENT_WORKFLOW_PROF
|
|
|
2080
2168
|
"Audited phases: end with Audit: PASS before setting Coverage/Approval PASS; record Audit Context and Audit Verdict.",
|
|
2081
2169
|
"TDD (phase 3): declare TDD Mode: strict|pragmatic; strict requires RED + GREEN evidence paths.",
|
|
2082
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],
|
|
2083
2172
|
"</system-reminder>"
|
|
2084
2173
|
].join("\n");
|
|
2085
2174
|
}
|
|
@@ -2176,7 +2265,7 @@ function phaseBaselineRules(fileName) {
|
|
|
2176
2265
|
pattern: "write*",
|
|
2177
2266
|
verdict: "deny",
|
|
2178
2267
|
reason: "phase " + phase + " is a documentation phase: writes outside the run tree are denied (the implementation is frozen)",
|
|
2179
|
-
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
|
|
2180
2269
|
});
|
|
2181
2270
|
if (phase === "6" || phase === "7") rules.push({
|
|
2182
2271
|
pattern: "write*",
|
|
@@ -2214,6 +2303,23 @@ function memoryPlanePath(abs) {
|
|
|
2214
2303
|
return /\/(decisions|state)\.md$/i.test(normalized) || /\/\.recursive\/memory(\/|$)/.test(normalized);
|
|
2215
2304
|
}
|
|
2216
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
|
+
/**
|
|
2217
2323
|
* Resolve a tool-target path to an absolute path. Mirrors enforcement.ts's
|
|
2218
2324
|
* resolution rules: an absolute path stays as it is; a relative path resolves
|
|
2219
2325
|
* against the worktree root. `null` when the value cannot be a path at all.
|
|
@@ -6318,10 +6424,23 @@ function defaultWrite(path, content) {
|
|
|
6318
6424
|
* so the result is reproducible from the inputs alone.
|
|
6319
6425
|
*/
|
|
6320
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
|
+
*/
|
|
6321
6439
|
const MEMORY_KINDS = [
|
|
6322
6440
|
"domains",
|
|
6323
6441
|
"patterns",
|
|
6324
6442
|
"episodes",
|
|
6443
|
+
"training",
|
|
6325
6444
|
"skills"
|
|
6326
6445
|
];
|
|
6327
6446
|
/**
|
|
@@ -6446,6 +6565,27 @@ function entryAppliesTo(entry) {
|
|
|
6446
6565
|
if (!match || match[1] === void 0) return [];
|
|
6447
6566
|
return match[1].split(",").map((part) => part.replace(/[^0-9.]/g, "")).filter((part) => part !== "");
|
|
6448
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"];
|
|
6449
6589
|
/** Read the whole plane, minus nothing: filtering is the SELECTOR's job, not the reader's. */
|
|
6450
6590
|
function loadMemoryIndex(root, readFile = defaultMemoryRead, listFiles = (kind) => defaultMemoryList(root, kind)) {
|
|
6451
6591
|
return readMemoryEntries(readFile, listFiles);
|
|
@@ -6509,12 +6649,14 @@ function defaultMemoryRead(path) {
|
|
|
6509
6649
|
}
|
|
6510
6650
|
}
|
|
6511
6651
|
function defaultMemoryList(root, kind) {
|
|
6512
|
-
const
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
|
|
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 {}
|
|
6517
6658
|
}
|
|
6659
|
+
return [];
|
|
6518
6660
|
}
|
|
6519
6661
|
//#endregion
|
|
6520
6662
|
//#region src/training.ts
|
|
@@ -6543,7 +6685,7 @@ function defaultMemoryList(root, kind) {
|
|
|
6543
6685
|
* history of a learning stays readable; and a PINNED entry is untouchable by every automatic path.
|
|
6544
6686
|
*/
|
|
6545
6687
|
/** The artifact whose lock marks a run as complete enough to learn from. */
|
|
6546
|
-
const PHASE8_ARTIFACT =
|
|
6688
|
+
const PHASE8_ARTIFACT = PHASE8_MEMORY_ARTIFACT;
|
|
6547
6689
|
/** The parent's exit codes, kept as names so a caller cannot mistake one failure for the other. */
|
|
6548
6690
|
const TRAINING_EXIT = {
|
|
6549
6691
|
/** The extractor could not be reached or run. */
|
|
@@ -6734,13 +6876,30 @@ function runPhase8Trigger(root, runId, options = {}) {
|
|
|
6734
6876
|
*
|
|
6735
6877
|
* ⚠ ONE ITEM PER RUN IS NAMED, so a reader can trace a learning back to the run that produced it —
|
|
6736
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.
|
|
6737
6885
|
*/
|
|
6738
|
-
function renderGroupShard(group) {
|
|
6886
|
+
function renderGroupShard(group, options = {}) {
|
|
6887
|
+
const runs = [...new Set(group.items.map((item) => item.runId))];
|
|
6739
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
|
+
"",
|
|
6740
6899
|
"# Learnings: " + group.subsystem,
|
|
6741
6900
|
"",
|
|
6742
6901
|
"- Mode: " + group.mode,
|
|
6743
|
-
"- Runs: " + group.runs + " (" +
|
|
6902
|
+
"- Runs: " + group.runs + " (" + runs.join(", ") + ")",
|
|
6744
6903
|
""
|
|
6745
6904
|
];
|
|
6746
6905
|
for (const item of group.items) lines.push("- [" + item.runId + "] " + item.text);
|
|
@@ -6901,8 +7060,20 @@ function updateMemoryRegistry(existing, entries) {
|
|
|
6901
7060
|
function taskTypeShardPath(mode) {
|
|
6902
7061
|
return "memory/training/" + mode + ".md";
|
|
6903
7062
|
}
|
|
6904
|
-
|
|
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)))];
|
|
6905
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
|
+
"",
|
|
6906
7077
|
"# Training shards: " + groups[0].mode,
|
|
6907
7078
|
"",
|
|
6908
7079
|
"Groups extracted under this mode, one section each. Learning happens through files, not model mutation.",
|
|
@@ -6967,6 +7138,41 @@ function extractAndGroup(runner, env, options = {}) {
|
|
|
6967
7138
|
groups: groupLearnings(items, options.isWinner ?? (() => true))
|
|
6968
7139
|
};
|
|
6969
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
|
+
}
|
|
6970
7176
|
//#endregion
|
|
6971
7177
|
//#region src/run-spec.ts
|
|
6972
7178
|
/** The named evidence classes, so a reader can tell a placeholder from an unmet gate. */
|
|
@@ -7328,6 +7534,31 @@ function buildAskQuestion(gateId) {
|
|
|
7328
7534
|
options: gate.options.map((option) => ({ ...option }))
|
|
7329
7535
|
});
|
|
7330
7536
|
}
|
|
7537
|
+
/** The payload, as the refusal carries it. Validated through `buildAskQuestion`. */
|
|
7538
|
+
function buildGateBlockAsk(artifact, blocked) {
|
|
7539
|
+
return {
|
|
7540
|
+
gate: "gate-block",
|
|
7541
|
+
...buildAskQuestion("gate-block"),
|
|
7542
|
+
artifact,
|
|
7543
|
+
blocked
|
|
7544
|
+
};
|
|
7545
|
+
}
|
|
7546
|
+
/**
|
|
7547
|
+
* FU-7 — THE OPTIONS AS TEXT, DERIVED FROM THE PAYLOAD rather than restated.
|
|
7548
|
+
*
|
|
7549
|
+
* WHY A RENDERER IS NEEDED AT ALL: the harness renders a `tools/pre-execute` denial as
|
|
7550
|
+
* `Error: <reason>` and drops every other field of the decision (measured in
|
|
7551
|
+
* `packages/core/tools`: `content: [{ type: 'text', text: 'Error: ' + denialReason }]`), so an
|
|
7552
|
+
* ask that rode along as a SIBLING field would reach the model as nothing at all — which is
|
|
7553
|
+
* exactly how a strict-by-default guard made the recovery path unreachable. The refusal
|
|
7554
|
+
* therefore renders the payload into the text it hands back, and it renders THIS object, so
|
|
7555
|
+
* the visible sentence and the structured payload cannot disagree.
|
|
7556
|
+
*/
|
|
7557
|
+
function renderGateBlockAsk(ask) {
|
|
7558
|
+
const options = ask.options.map((option) => option.label + (option.description === void 0 ? "" : " (" + option.description + ")")).join(" ");
|
|
7559
|
+
const target = ask.artifact === "" ? "recursive_ask gate=gate-block" : "recursive_ask gate=gate-block artifact=" + ask.artifact;
|
|
7560
|
+
return ask.header + ": " + ask.question + " Options: " + options + " Answer with " + target + ".";
|
|
7561
|
+
}
|
|
7331
7562
|
/**
|
|
7332
7563
|
* PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
|
|
7333
7564
|
*
|
|
@@ -7691,6 +7922,75 @@ async function askRunStartDirectly(channel, exec) {
|
|
|
7691
7922
|
}
|
|
7692
7923
|
}
|
|
7693
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
|
|
7694
7994
|
//#region src/lifecycle.ts
|
|
7695
7995
|
/**
|
|
7696
7996
|
* Transition gate validation + goal coupling (Phase C R1/R2/R6, PROPOSAL 8.4).
|
|
@@ -7792,7 +8092,7 @@ function coupleGateBlockToGoal(goalService, agent, ref, reason) {
|
|
|
7792
8092
|
* (Phase C R3/R4/R7/R8, PROPOSAL 8.4/8.6/13.5).
|
|
7793
8093
|
*
|
|
7794
8094
|
* Layer 2 (tool guards) and Layer 8 (tamper) are the remaining enforcement
|
|
7795
|
-
* layers. Configurable strict|advisory per gate (default
|
|
8095
|
+
* layers. Configurable strict|advisory per gate (default strict).
|
|
7796
8096
|
*/
|
|
7797
8097
|
/**
|
|
7798
8098
|
* The BUILT-IN default rule list (T16) is defined in `src/policy-globs.ts`,
|
|
@@ -7824,6 +8124,28 @@ const BUDGET_KEYS = [
|
|
|
7824
8124
|
"maxResultBytes"
|
|
7825
8125
|
];
|
|
7826
8126
|
/**
|
|
8127
|
+
* THE DEFAULT POSTURE: STRICT, on all three gates — and this const is the ONE literal.
|
|
8128
|
+
*
|
|
8129
|
+
* The owner's rule is *only one phase may be active at a time, and the phases should be
|
|
8130
|
+
* sequential and the active phase must be locked before proceeding to next phase*. In
|
|
8131
|
+
* `advisory` that rule is only WARNED about, and a live run showed what that costs: the
|
|
8132
|
+
* run ignored the lock chain for over an hour, wrote phase 8 before phase 1.5 and locked
|
|
8133
|
+
* nothing (twelve DRAFT artifacts, one operations entry). Strict was previously unsafe as
|
|
8134
|
+
* a default because it also refused the run's OWN artifacts — a false positive. That was
|
|
8135
|
+
* fixed, and `tests/strict-run-tree.spec.ts` now walks all twelve phases asserting the
|
|
8136
|
+
* active artifact stays writable while a later one is refused. Strict therefore refuses
|
|
8137
|
+
* exactly the ordering violations it is meant to refuse, so the default is the enforcing
|
|
8138
|
+
* posture rather than a warning nobody has to act on.
|
|
8139
|
+
*
|
|
8140
|
+
* ⚠ WHY IT IS A NAMED CONST AND NOT THREE LITERALS. A default restated per site is this
|
|
8141
|
+
* project's recurring failure: the same value exists in the Config schema, in
|
|
8142
|
+
* `DEFAULT_ENFORCEMENT`, in an omitted config section, and in the parameter defaults of
|
|
8143
|
+
* the helpers below, and moving only some of them leaves a caller that "still gets
|
|
8144
|
+
* advisory". Every one of those sites now reads THIS const, so a revert is a one-line
|
|
8145
|
+
* change and nothing can drift from it.
|
|
8146
|
+
*/
|
|
8147
|
+
const DEFAULT_ENFORCEMENT_MODE = "strict";
|
|
8148
|
+
/**
|
|
7827
8149
|
* Validate the enforcement config shape (unknown keys fail at plugin load).
|
|
7828
8150
|
*
|
|
7829
8151
|
* A budget must be a POSITIVE INTEGER. Zero and negatives are rejected rather than
|
|
@@ -7835,7 +8157,7 @@ function resolveEnforcementConfig(config) {
|
|
|
7835
8157
|
const raw = config ?? {};
|
|
7836
8158
|
const unknown = Object.keys(raw).filter((k) => !CONFIG_KEYS.includes(k));
|
|
7837
8159
|
if (unknown.length > 0) throw new Error("EnforcementConfig has unknown key(s) " + unknown.join(", ") + " - config is { preStep, toolGuards, tamper, budgets }");
|
|
7838
|
-
const mode = (value) => value === "strict" ? "strict" : "advisory";
|
|
8160
|
+
const mode = (value) => value === "strict" ? "strict" : value === "advisory" ? "advisory" : DEFAULT_ENFORCEMENT_MODE;
|
|
7839
8161
|
const rawBudgets = raw.budgets ?? {};
|
|
7840
8162
|
if (typeof raw.budgets !== "undefined" && (raw.budgets === null || typeof raw.budgets !== "object")) throw new Error("EnforcementConfig budgets must be an object of caps");
|
|
7841
8163
|
const unknownBudget = Object.keys(rawBudgets).filter((k) => !BUDGET_KEYS.includes(k));
|
|
@@ -7854,10 +8176,16 @@ function resolveEnforcementConfig(config) {
|
|
|
7854
8176
|
budgets
|
|
7855
8177
|
};
|
|
7856
8178
|
}
|
|
8179
|
+
/**
|
|
8180
|
+
* The runtime default: what a caller gets when it supplies no `enforcement` section at all
|
|
8181
|
+
* (a profile mounting this plugin with no config, e.g. `preset/recursive.patch.yml`). It is
|
|
8182
|
+
* `DEFAULT_ENFORCEMENT_MODE` per gate, so this object and the resolver cannot disagree —
|
|
8183
|
+
* see that const for WHY the default is strict.
|
|
8184
|
+
*/
|
|
7857
8185
|
const DEFAULT_ENFORCEMENT = {
|
|
7858
|
-
preStep:
|
|
7859
|
-
toolGuards:
|
|
7860
|
-
tamper:
|
|
8186
|
+
preStep: DEFAULT_ENFORCEMENT_MODE,
|
|
8187
|
+
toolGuards: DEFAULT_ENFORCEMENT_MODE,
|
|
8188
|
+
tamper: DEFAULT_ENFORCEMENT_MODE,
|
|
7861
8189
|
budgets: DEFAULT_BUDGETS
|
|
7862
8190
|
};
|
|
7863
8191
|
/**
|
|
@@ -7920,43 +8248,144 @@ function currentPhaseArtifact(worktreeRoot, runId) {
|
|
|
7920
8248
|
}
|
|
7921
8249
|
return inForce !== "" ? inForce : best;
|
|
7922
8250
|
}
|
|
7923
|
-
|
|
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
|
+
/**
|
|
8310
|
+
* `mode` is the gate's configured posture. Its parameter default FOLLOWS the config
|
|
8311
|
+
* default by REFERENCE (`DEFAULT_ENFORCEMENT.toolGuards`) rather than repeating the
|
|
8312
|
+
* literal: a bare call is "the caller had no mode to hand", and the answer to that must
|
|
8313
|
+
* be the same posture the config would have produced. Two literals are two defaults, and
|
|
8314
|
+
* a helper left on the old `advisory` literal while the config moved to `strict` is
|
|
8315
|
+
* exactly the twin-default hole this change closes — a caller that forgot the argument
|
|
8316
|
+
* would silently get the permissive branch, which no config could then undo. Every
|
|
8317
|
+
* production call site passes the mode explicitly (`index.ts` `runToolGuard`,
|
|
8318
|
+
* `runtime.ts` `guardTool`, the preview tool); this default serves bare callers, and a
|
|
8319
|
+
* bare caller must not be the one place enforcement quietly turns itself off.
|
|
8320
|
+
*/
|
|
8321
|
+
function evaluateToolGuard(exec, worktreeRoot, activeRunId, mode = DEFAULT_ENFORCEMENT.toolGuards) {
|
|
7924
8322
|
const name = exec.name;
|
|
7925
8323
|
const args = exec.arguments ?? {};
|
|
7926
|
-
const runId = typeof activeRunId === "string" ? activeRunId.trim() : "";
|
|
8324
|
+
const runId = resolveGuardRunId(name, args, worktreeRoot, typeof activeRunId === "string" ? activeRunId.trim() : "");
|
|
7927
8325
|
const runDir = join(worktreeRoot, ".recursive", "run", runId);
|
|
7928
8326
|
const transition = consultTransitionGate(name, args, worktreeRoot, runId);
|
|
7929
8327
|
const activePhaseArtifact = currentPhaseArtifact(worktreeRoot, runId);
|
|
7930
|
-
return
|
|
7931
|
-
args,
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
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
|
+
};
|
|
7937
8338
|
}
|
|
7938
8339
|
/**
|
|
7939
8340
|
* Map the policy's verdict onto the guard's decision kind: `strict` denies,
|
|
7940
8341
|
* `advisory` asks (the pre-T16 wording, unchanged), `allow` stays an allow. The
|
|
7941
8342
|
* decision's `rule` is the label of the rule that decided it, so a policy
|
|
7942
8343
|
* verdict is traceable to an auditable line in the policy file.
|
|
7943
|
-
|
|
7944
|
-
|
|
8344
|
+
*
|
|
8345
|
+
* ⚠ FU-7 — THE ORDERING REFUSAL CARRIES THE HUMAN'S CHOICE. `fix | reopen | abandon` is how a
|
|
8346
|
+
* person unblocks a lock, and before this the ask was attached ONLY by `recursive_lock`'s own
|
|
8347
|
+
* catch — the branch that runs when the guard ABSTAINS. Under the strict default the guard
|
|
8348
|
+
* refuses a lock ahead of its prerequisites BEFORE dispatch, so that branch never ran on the
|
|
8349
|
+
* default path and the caller got a bare sentence: the recovery options existed in the code and
|
|
8350
|
+
* were unreachable in the product, which is worse than the advisory posture they replaced (an
|
|
8351
|
+
* advisory `ask` at least surfaced the reason).
|
|
8352
|
+
*
|
|
8353
|
+
* THE TRIGGER IS THE BLOCKERS, NOT THE LABEL. `PolicyDecision.blockers` is present exactly when a
|
|
8354
|
+
* predicate read prerequisite blockers from disk and they were non-empty, so gating on it means
|
|
8355
|
+
* "this refusal was decided from an ordering violation" — including a policy FILE whose
|
|
8356
|
+
* `recursive_lock*` deny carries no label (the file-authored rule is given the same condition by
|
|
8357
|
+
* `attachPolicyPredicate`, and its `rule` would otherwise read `none`). Nothing is recomputed
|
|
8358
|
+
* here: the blockers arrive from the rule that already resolved them.
|
|
8359
|
+
*
|
|
8360
|
+
* IT IS ATTACHED TO THE REFUSAL ONLY. Under `advisory` the same verdict becomes an `ask` that the
|
|
8361
|
+
* live path coerces to an allow-with-warning, and the tool then refuses with its OWN payload when
|
|
8362
|
+
* `lockArtifact` throws — so an ask attached here would be a claim about a refusal that this layer
|
|
8363
|
+
* did not make. One refusal, one ask.
|
|
8364
|
+
*/
|
|
8365
|
+
function verdictFor(mode, decision, artifact) {
|
|
7945
8366
|
const rule = decision.rule ?? "none";
|
|
7946
8367
|
if (decision.kind === "allow") return {
|
|
7947
8368
|
kind: "allow",
|
|
7948
8369
|
rule
|
|
7949
8370
|
};
|
|
7950
8371
|
const reason = decision.reason ?? "tool policy denied this call";
|
|
7951
|
-
|
|
7952
|
-
kind: "
|
|
8372
|
+
if (mode !== "strict") return {
|
|
8373
|
+
kind: "ask",
|
|
7953
8374
|
reason,
|
|
7954
8375
|
rule
|
|
7955
|
-
}
|
|
7956
|
-
|
|
8376
|
+
};
|
|
8377
|
+
const blocked = decision.blockers;
|
|
8378
|
+
if (blocked === void 0 || blocked.length === 0) return {
|
|
8379
|
+
kind: "deny",
|
|
7957
8380
|
reason,
|
|
7958
8381
|
rule
|
|
7959
8382
|
};
|
|
8383
|
+
return {
|
|
8384
|
+
kind: "deny",
|
|
8385
|
+
reason,
|
|
8386
|
+
rule,
|
|
8387
|
+
ask: buildGateBlockAsk(artifact, reason)
|
|
8388
|
+
};
|
|
7960
8389
|
}
|
|
7961
8390
|
/**
|
|
7962
8391
|
* T15 — consult the transition gate (`validateTransition`) from the guard,
|
|
@@ -8008,7 +8437,7 @@ function advisory(decision, transition) {
|
|
|
8008
8437
|
return {
|
|
8009
8438
|
...decision,
|
|
8010
8439
|
rule: "transition",
|
|
8011
|
-
warn: "transition gate (
|
|
8440
|
+
warn: "transition gate (report-only) failed: " + transition.failures.join("; "),
|
|
8012
8441
|
transition
|
|
8013
8442
|
};
|
|
8014
8443
|
}
|
|
@@ -8017,8 +8446,20 @@ function advisory(decision, transition) {
|
|
|
8017
8446
|
* allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
|
|
8018
8447
|
* but flags a `warn` so the caller never lets it through unlogged. Non-ask
|
|
8019
8448
|
* decisions pass through unchanged.
|
|
8020
|
-
|
|
8021
|
-
|
|
8449
|
+
*
|
|
8450
|
+
* ⚠ THE `mode` DEFAULT IS DELIBERATE, and it is NOT a neutral fallback — there is no
|
|
8451
|
+
* neutral branch here. The domain is two postures, one of which ALLOWS the call, so
|
|
8452
|
+
* "unspecified" has to be resolved rather than left open, and this codebase's rule for an
|
|
8453
|
+
* undecidable path is to fail CLOSED (`index.ts`: *"we could not decide" is not
|
|
8454
|
+
* permission*). It therefore FOLLOWS the config default by REFERENCE
|
|
8455
|
+
* (`DEFAULT_ENFORCEMENT.toolGuards`), for the same reason as `evaluateToolGuard`'s: an
|
|
8456
|
+
* `advisory` literal here would be a second, hidden copy of the old default inside the
|
|
8457
|
+
* very module this change moves, and a future caller that omitted the argument would
|
|
8458
|
+
* re-open the permissive path with no config able to close it. The production call site
|
|
8459
|
+
* (`index.ts` `runToolGuard`) always passes the configured mode, so this changes no live
|
|
8460
|
+
* behaviour — it removes the last place where "we were not told" meant "allow".
|
|
8461
|
+
*/
|
|
8462
|
+
function coerceAskToDecision(decision, mode = DEFAULT_ENFORCEMENT.toolGuards) {
|
|
8022
8463
|
if (decision.kind !== "ask") return decision;
|
|
8023
8464
|
if (mode === "strict") return {
|
|
8024
8465
|
kind: "deny",
|
|
@@ -10638,14 +11079,14 @@ var RecursiveRuntime = class extends Service {
|
|
|
10638
11079
|
responseFile: join(runDir, "training-response.json")
|
|
10639
11080
|
}),
|
|
10640
11081
|
write: (relativePath, content) => {
|
|
10641
|
-
const target = join(root, relativePath);
|
|
11082
|
+
const target = join(root, ".recursive", relativePath);
|
|
10642
11083
|
mkdirSync(dirname(target), { recursive: true });
|
|
10643
11084
|
writeFileSync(target, content, "utf8");
|
|
10644
11085
|
return relativePath;
|
|
10645
11086
|
},
|
|
10646
11087
|
readText: (relativePath) => {
|
|
10647
11088
|
try {
|
|
10648
|
-
return readFileSync(join(root, relativePath), "utf8");
|
|
11089
|
+
return readFileSync(join(root, ".recursive", relativePath), "utf8");
|
|
10649
11090
|
} catch {
|
|
10650
11091
|
return null;
|
|
10651
11092
|
}
|
|
@@ -11516,7 +11957,7 @@ var RecursiveRuntime = class extends Service {
|
|
|
11516
11957
|
coupleGateBlockToGoal(goalService, agent, ref, reason) {
|
|
11517
11958
|
return coupleGateBlockToGoal(goalService, agent, ref, reason);
|
|
11518
11959
|
}
|
|
11519
|
-
/** Phase C R7: resolve the enforcement config (strict|advisory, default
|
|
11960
|
+
/** Phase C R7: resolve the enforcement config (strict|advisory, default strict). */
|
|
11520
11961
|
get enforcementConfig() {
|
|
11521
11962
|
return this._enforcementConfig ?? DEFAULT_ENFORCEMENT;
|
|
11522
11963
|
}
|
|
@@ -11761,75 +12202,6 @@ function createRecursiveStatusTool(recursive) {
|
|
|
11761
12202
|
});
|
|
11762
12203
|
}
|
|
11763
12204
|
//#endregion
|
|
11764
|
-
//#region src/run-id.ts
|
|
11765
|
-
/**
|
|
11766
|
-
* A RUN ID IS A NAME, NOT A PATH.
|
|
11767
|
-
*
|
|
11768
|
-
* WHY THIS MODULE EXISTS. Every consumer of a run id JOINS it onto a directory
|
|
11769
|
-
* that already carries the meaning "the run layer":
|
|
11770
|
-
*
|
|
11771
|
-
* join(root, '.recursive', 'run', runId) // runtime.ts, run.ts, handoff.ts, scratch.ts
|
|
11772
|
-
* join(repoRoot, '.worktrees', runId) // worktree.ts (a linked worktree)
|
|
11773
|
-
* 'recursive/' + runId // worktree.ts (the run's git branch)
|
|
11774
|
-
*
|
|
11775
|
-
* `join` is a PATH operation: absolute paths, drive specifiers and `..` segments
|
|
11776
|
-
* are all legal input to it, and each one silently changes what the call means.
|
|
11777
|
-
* A caller who passes `E:\tmp\rm-live-diagnostics\01-calculator-lib` is asking
|
|
11778
|
-
* for a run "on another drive"; what they get is a `mkdir` of
|
|
11779
|
-
*
|
|
11780
|
-
* <workspace>\.recursive\run\E:\tmp\rm-live-diagnostics\01-calculator-lib
|
|
11781
|
-
*
|
|
11782
|
-
* which is not drive-qualified at all — on POSIX and Windows alike the colon is
|
|
11783
|
-
* just another character in a relative component. The result is a bogus nested
|
|
11784
|
-
* folder INSIDE the workspace, created before anything can refuse it, surfacing
|
|
11785
|
-
* far away as an ENOENT-shaped runtime failure (RM5501) with the operator's
|
|
11786
|
-
* filesystem already dirty.
|
|
11787
|
-
*
|
|
11788
|
-
* SO THE RULE IS ENFORCED WHERE THE NAME ENTERS, and NOT by teaching the runtime
|
|
11789
|
-
* to accept a path. The joins in `runtime.ts` are CORRECT for a name; what was
|
|
11790
|
-
* missing was a gate on the name. Do not "fix" this back: a run on another drive
|
|
11791
|
-
* or in a worktree is reached through the session's control-plane root
|
|
11792
|
-
* (`recursive_worktree`, `00-worktree.md`) — the run layer is never relocated by
|
|
11793
|
-
* smuggling a path into the id.
|
|
11794
|
-
*
|
|
11795
|
-
* The charset below is deliberately the SAME one the read path already uses
|
|
11796
|
-
* (`live-route.ts` `DOC_SAFE_RE`) so a name this gate accepts is a name that
|
|
11797
|
-
* route can serve.
|
|
11798
|
-
*/
|
|
11799
|
-
/**
|
|
11800
|
-
* The accepted shape, as prose that can be embedded in a model-facing parameter
|
|
11801
|
-
* description and in a refusal detail, so the rule is stated once.
|
|
11802
|
-
*/
|
|
11803
|
-
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";
|
|
11804
|
-
/** Directory-name charset — the read path's `DOC_SAFE_RE`, verbatim. */
|
|
11805
|
-
const RUN_ID_CHARS = /^[A-Za-z0-9._-]+$/;
|
|
11806
|
-
/**
|
|
11807
|
-
* Why a run id is refused, or `null` when it is a usable NAME.
|
|
11808
|
-
*
|
|
11809
|
-
* The returned string is the SPECIFIC problem (which rule the id broke), with no
|
|
11810
|
-
* trailing punctuation and no sentence of its own, so a caller can hand it to
|
|
11811
|
-
* `toolError('BAD_RUN_ID', …)` as the detail. `RUN_ID_RULE` states the shape.
|
|
11812
|
-
*
|
|
11813
|
-
* The order of the checks is part of the message quality: a Windows absolute
|
|
11814
|
-
* path is reported as a drive-qualified path (what the caller passed) rather
|
|
11815
|
-
* than as a separator complaint (what that path is made of).
|
|
11816
|
-
*/
|
|
11817
|
-
function runIdProblem(raw) {
|
|
11818
|
-
if (raw === "") return "runId is empty";
|
|
11819
|
-
if (raw.length > 100) return "runId is " + raw.length + " characters, over the 100 allowed";
|
|
11820
|
-
if (/^[A-Za-z]:/.test(raw)) return "runId is a Windows drive-qualified path, starting with \"" + raw.slice(0, 2) + "\"";
|
|
11821
|
-
if (raw.includes("/") || raw.includes("\\")) return "runId contains the path separator \"" + (raw.includes("/") ? "/" : "\\") + "\"";
|
|
11822
|
-
if (raw.includes(":")) return "runId contains a colon (\":\"), which is a drive and stream separator on Windows";
|
|
11823
|
-
if (raw.includes("..")) return "runId contains a \"..\" segment, which escapes the run directory";
|
|
11824
|
-
if (raw.startsWith(".")) return "runId starts with \".\", which makes it a hidden name or a relative path segment";
|
|
11825
|
-
if (raw.endsWith(".")) return "runId ends with \".\"";
|
|
11826
|
-
if (!RUN_ID_CHARS.test(raw)) {
|
|
11827
|
-
if (/\s/.test(raw)) return "runId contains a space or other whitespace character inside the name";
|
|
11828
|
-
return "runId contains a character outside the allowed set";
|
|
11829
|
-
}
|
|
11830
|
-
return null;
|
|
11831
|
-
}
|
|
11832
|
-
//#endregion
|
|
11833
12205
|
//#region src/recursive_init.tool.ts
|
|
11834
12206
|
/**
|
|
11835
12207
|
* PHASE 0 — SCAFFOLDING IS NOT STARTING, AND THE TOOL SAYS SO AT THE MOMENT IT MATTERS.
|
|
@@ -11932,12 +12304,7 @@ function createRecursiveLockTool(recursive) {
|
|
|
11932
12304
|
const refusal = codeRuntimeRefusal(message);
|
|
11933
12305
|
if (message.startsWith("Prerequisite blockers:")) return {
|
|
11934
12306
|
error: refusal,
|
|
11935
|
-
ask:
|
|
11936
|
-
gate: "gate-block",
|
|
11937
|
-
...buildAskQuestion("gate-block"),
|
|
11938
|
-
artifact: args.artifact ?? "",
|
|
11939
|
-
blocked: message
|
|
11940
|
-
}
|
|
12307
|
+
ask: buildGateBlockAsk(args.artifact ?? "", message)
|
|
11941
12308
|
};
|
|
11942
12309
|
return { error: refusal };
|
|
11943
12310
|
}
|
|
@@ -13137,10 +13504,12 @@ function createRecursivePreviewTool(recursive) {
|
|
|
13137
13504
|
* Idempotent scaffold installer (R3). TS port of install-recursive-mode.py's
|
|
13138
13505
|
* core: bootstrap the FULL canonical /.recursive/ control plane + cross-tool
|
|
13139
13506
|
* bridges byte-identically (RECURSIVE.md marker-wrapped, AGENTS.md, STATE/
|
|
13140
|
-
* DECISIONS, memory routers + shards, config/recursive-router.json, .gitignore,
|
|
13141
|
-
*
|
|
13142
|
-
*
|
|
13143
|
-
*
|
|
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.
|
|
13144
13513
|
*
|
|
13145
13514
|
* Templates + bodies + runtime scripts are SHIPPED package files under
|
|
13146
13515
|
* references/ (never inlined TS string literals) and resolved relative to this
|
|
@@ -13377,11 +13746,13 @@ function bootstrapScaffold(root) {
|
|
|
13377
13746
|
".recursive/memory/skills/patterns/.gitkeep",
|
|
13378
13747
|
".recursive/run/.gitkeep"
|
|
13379
13748
|
]) noteFile(rel, "");
|
|
13380
|
-
noteDir(".recursive/scripts");
|
|
13381
13749
|
{
|
|
13382
13750
|
const scriptsDir = join(recursiveRoot, "scripts");
|
|
13383
13751
|
if (existsSync(scriptsDir)) {
|
|
13384
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 {}
|
|
13385
13756
|
}
|
|
13386
13757
|
}
|
|
13387
13758
|
noteFile(".recursive/RECURSIVE.md", "# RECURSIVE.md\n");
|
|
@@ -14554,10 +14925,17 @@ const enforcementMode = z.union([z.const("strict"), z.const("advisory")]);
|
|
|
14554
14925
|
const Config = z.object({
|
|
14555
14926
|
shellOnly: z.boolean().default(false).description("Client-shell only: registers nothing on the server (no tools, no command, no projection)."),
|
|
14556
14927
|
repoRoot: z.string().description("Control-plane root. Defaults to the process working directory when unset."),
|
|
14928
|
+
/**
|
|
14929
|
+
* ⚠ THE THREE MODE DEFAULTS READ `DEFAULT_ENFORCEMENT_MODE` FROM `enforcement.ts`, and
|
|
14930
|
+
* that is deliberate: the schema default and the runtime default are the SAME value, and
|
|
14931
|
+
* two literals here would be two defaults. A caller that omits the section gets
|
|
14932
|
+
* `DEFAULT_ENFORCEMENT` from the runtime; a caller that supplies a partial section gets
|
|
14933
|
+
* the resolver's fill. Both must be the enforcing posture — see the const for why.
|
|
14934
|
+
*/
|
|
14557
14935
|
enforcement: z.object({
|
|
14558
|
-
preStep: enforcementMode.default(
|
|
14559
|
-
toolGuards: enforcementMode.default(
|
|
14560
|
-
tamper: enforcementMode.default(
|
|
14936
|
+
preStep: enforcementMode.default(DEFAULT_ENFORCEMENT_MODE).description("Phase pre-step enforcement: strict refuses an out-of-order transition, advisory warns and proceeds."),
|
|
14937
|
+
toolGuards: enforcementMode.default(DEFAULT_ENFORCEMENT_MODE).description("Tool guard mode: strict DENIES an out-of-order tool call, advisory allows it and carries the warning."),
|
|
14938
|
+
tamper: enforcementMode.default(DEFAULT_ENFORCEMENT_MODE).description("Tamper detection: strict refuses an artifact whose LockHash no longer matches its body."),
|
|
14561
14939
|
budgets: z.object({
|
|
14562
14940
|
maxAuditRounds: z.natural().default(DEFAULT_BUDGETS.maxAuditRounds).description("Rounds one phase audit loop may run, even while every round makes progress."),
|
|
14563
14941
|
maxRepairAttempts: z.natural().default(DEFAULT_BUDGETS.maxRepairAttempts).description("How many times a phase may be sent back for repair before the loop stops."),
|
|
@@ -14620,15 +14998,17 @@ function runToolGuard(recursive, exec, root, runId) {
|
|
|
14620
14998
|
const guardMode = recursive.enforcementConfig.toolGuards;
|
|
14621
14999
|
const decision = evaluateToolGuard(exec, root, runId, guardMode);
|
|
14622
15000
|
const coerced = coerceAskToDecision(decision, guardMode);
|
|
15001
|
+
const evaluatedRunId = decision.runId ?? runId;
|
|
14623
15002
|
const final = coerced === decision ? decision : {
|
|
14624
15003
|
...coerced,
|
|
14625
15004
|
rule: decision.rule,
|
|
14626
|
-
transition: decision.transition
|
|
15005
|
+
transition: decision.transition,
|
|
15006
|
+
runId: evaluatedRunId
|
|
14627
15007
|
};
|
|
14628
15008
|
if (root) {
|
|
14629
15009
|
const record = {
|
|
14630
15010
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
14631
|
-
runId,
|
|
15011
|
+
runId: evaluatedRunId,
|
|
14632
15012
|
tool: exec?.name ?? "",
|
|
14633
15013
|
kind: final.kind,
|
|
14634
15014
|
rule: final.rule ?? "none"
|
|
@@ -14637,10 +15017,59 @@ function runToolGuard(recursive, exec, root, runId) {
|
|
|
14637
15017
|
if (final.warn) record.reason = final.warn;
|
|
14638
15018
|
} else if (final.reason) record.reason = final.reason;
|
|
14639
15019
|
if (final.transition) record.transition = final.transition;
|
|
15020
|
+
if (final.kind === "deny" && final.ask) record.ask = final.ask;
|
|
14640
15021
|
appendGuardDecision(root, record);
|
|
14641
15022
|
}
|
|
14642
15023
|
return final;
|
|
14643
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
|
+
}
|
|
14644
15073
|
function apply(ctx, config) {
|
|
14645
15074
|
if (config?.shellOnly) return;
|
|
14646
15075
|
ctx.effect(function* () {
|
|
@@ -14791,8 +15220,14 @@ function apply(ctx, config) {
|
|
|
14791
15220
|
kind: "deny",
|
|
14792
15221
|
reason: "the tool guard produced no decision"
|
|
14793
15222
|
};
|
|
14794
|
-
if (final.kind === "deny")
|
|
14795
|
-
|
|
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
|
+
}
|
|
15230
|
+
if (final.kind === "allow" && final.warn) console.warn("[recursive] tool guard (" + recursive.enforcementConfig.toolGuards + ") allowed this call: " + final.warn);
|
|
14796
15231
|
return typeof next === "function" ? next() : { kind: "allow" };
|
|
14797
15232
|
}));
|
|
14798
15233
|
const observationRuntime = ctx;
|
|
@@ -14909,4 +15344,4 @@ function apply(ctx, config) {
|
|
|
14909
15344
|
});
|
|
14910
15345
|
}
|
|
14911
15346
|
//#endregion
|
|
14912
|
-
export { Config, DEFAULT_BUDGETS, DEFAULT_ENFORCEMENT, 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 };
|