@event4u/agent-config 9.31.0 → 9.32.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +51 -0
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +32 -1
- package/dist/agent-src/rules/delegation-policy.md +11 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
- package/dist/agent-src/templates/scripts/work_engine/state.ts +74 -0
- package/dist/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
- package/dist/cli-delegate/chunk-2WELHO3P.js +582 -0
- package/dist/cli-delegate/{chunk-2WCLVDWC.js → chunk-542DZR4P.js} +117 -147
- package/dist/cli-delegate/chunk-PZWRPRWP.js +38 -0
- package/dist/cli-delegate/cmd_conformance.js +81 -30
- package/dist/cli-delegate/cmd_doctor.js +2 -1
- package/dist/cli-delegate/cmd_handoff.js +166 -57
- package/dist/cli-delegate/cmd_session_recycle.js +17 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +3 -3
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1118 -690
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +79 -29
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
- package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
- package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
- package/src/agent-src/templates/scripts/work_engine/state.ts +74 -0
- package/src/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
- package/src/scripts/_cli/cmd_session_recycle.ts +25 -0
- package/src/scripts/_cli/handoff_generate.ts +36 -1
- package/src/scripts/_cli/handoff_sessions.ts +140 -9
- package/src/scripts/_lib/carrier_divergence.ts +98 -27
- package/src/scripts/_lib/envelope_grounding.ts +206 -0
- package/src/scripts/_lib/host_capability.ts +79 -0
- package/src/scripts/_lib/session_eol.ts +85 -0
- package/src/scripts/_lib/subagent_capsule.ts +256 -1
- package/src/scripts/check_secret_leak.test.ts +43 -0
- package/src/scripts/check_secret_leak.ts +12 -0
- package/src/scripts/handoff_context_hook.ts +66 -18
- package/src/scripts/hooks/session_eol_hook.ts +12 -12
- package/src/scripts/report_carrier_divergence.ts +94 -29
- package/src/scripts/report_conformance_funnel.ts +11 -4
- package/src/scripts/routing_doctor.ts +25 -2
- package/src/scripts/schemas/dod.schema.json +45 -0
- package/dist/cli-delegate/chunk-RVGBH4XK.js +0 -142
package/dist/mcp/server.json
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"transport": {
|
|
10
10
|
"type": "stdio"
|
|
11
11
|
},
|
|
12
|
-
"version": "9.
|
|
12
|
+
"version": "9.32.0"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"repository": {
|
|
16
16
|
"source": "github",
|
|
17
17
|
"url": "https://github.com/event4u-app/agent-config"
|
|
18
18
|
},
|
|
19
|
-
"version": "9.
|
|
19
|
+
"version": "9.32.0"
|
|
20
20
|
}
|
package/dist/mcp/server.mjs
CHANGED
|
@@ -30318,15 +30318,36 @@ function comparePair(a, b) {
|
|
|
30318
30318
|
}
|
|
30319
30319
|
return "body-diff";
|
|
30320
30320
|
}
|
|
30321
|
-
function
|
|
30322
|
-
if (!text.startsWith("---")) return text;
|
|
30321
|
+
function splitFrontmatter(text) {
|
|
30322
|
+
if (!text.startsWith("---")) return { hadFrontmatter: false, frontmatter: "", body: text };
|
|
30323
30323
|
const end = text.indexOf("\n---", 3);
|
|
30324
|
-
if (end === -1) return text;
|
|
30324
|
+
if (end === -1) return { hadFrontmatter: false, frontmatter: "", body: text };
|
|
30325
30325
|
const afterFence = text.indexOf("\n", end + 1);
|
|
30326
|
-
return
|
|
30326
|
+
return {
|
|
30327
|
+
hadFrontmatter: true,
|
|
30328
|
+
frontmatter: text.slice(3, end),
|
|
30329
|
+
body: afterFence === -1 ? "" : text.slice(afterFence + 1)
|
|
30330
|
+
};
|
|
30327
30331
|
}
|
|
30328
30332
|
function proseEqual(a, b) {
|
|
30329
|
-
|
|
30333
|
+
const sa = splitFrontmatter(a);
|
|
30334
|
+
const sb = splitFrontmatter(b);
|
|
30335
|
+
if (!sa.hadFrontmatter && !sb.hadFrontmatter) return false;
|
|
30336
|
+
const leading = /^\n+/;
|
|
30337
|
+
return sa.body.replace(leading, "") === sb.body.replace(leading, "");
|
|
30338
|
+
}
|
|
30339
|
+
function frontmatterPaths(text) {
|
|
30340
|
+
const { hadFrontmatter, frontmatter } = splitFrontmatter(text);
|
|
30341
|
+
if (!hadFrontmatter) return null;
|
|
30342
|
+
const lines = frontmatter.split("\n");
|
|
30343
|
+
const start = lines.findIndex((l) => /^paths:/.test(l));
|
|
30344
|
+
if (start === -1) return null;
|
|
30345
|
+
const block = [lines[start]];
|
|
30346
|
+
for (const line of lines.slice(start + 1)) {
|
|
30347
|
+
if (/^\s/.test(line) && line.trim() !== "") block.push(line);
|
|
30348
|
+
else break;
|
|
30349
|
+
}
|
|
30350
|
+
return block.map((l) => l.trim()).join(" ");
|
|
30330
30351
|
}
|
|
30331
30352
|
function censusRuleDir(dir) {
|
|
30332
30353
|
let files = 0;
|
|
@@ -32221,10 +32242,7 @@ function _ruleType(file) {
|
|
|
32221
32242
|
} catch {
|
|
32222
32243
|
return "";
|
|
32223
32244
|
}
|
|
32224
|
-
|
|
32225
|
-
const end = text.indexOf("\n---", 3);
|
|
32226
|
-
const fm = end === -1 ? text : text.slice(0, end);
|
|
32227
|
-
const m = /^type:\s*"?([a-z]+)"?/m.exec(fm);
|
|
32245
|
+
const m = /^type:\s*"?([a-z]+)"?/m.exec(splitFrontmatter(text).frontmatter);
|
|
32228
32246
|
return m === null ? "" : m[1];
|
|
32229
32247
|
}
|
|
32230
32248
|
function compareCarriers(projectDir, globalDir, sourceDir) {
|
|
@@ -32237,6 +32255,7 @@ function compareCarriers(projectDir, globalDir, sourceDir) {
|
|
|
32237
32255
|
identical: [],
|
|
32238
32256
|
provenanceOnly: [],
|
|
32239
32257
|
frontmatterOnly: [],
|
|
32258
|
+
pathsScopeDiff: [],
|
|
32240
32259
|
bodyDiff: [],
|
|
32241
32260
|
projectOnly: [],
|
|
32242
32261
|
globalOnly: [],
|
|
@@ -32261,12 +32280,12 @@ function compareCarriers(projectDir, globalDir, sourceDir) {
|
|
|
32261
32280
|
}
|
|
32262
32281
|
out.shared += 1;
|
|
32263
32282
|
let verdict;
|
|
32264
|
-
let
|
|
32283
|
+
let projectCopy;
|
|
32284
|
+
let globalCopy;
|
|
32265
32285
|
try {
|
|
32266
|
-
|
|
32267
|
-
|
|
32286
|
+
projectCopy = fs54.readFileSync(path52.join(projectDir, rule));
|
|
32287
|
+
globalCopy = fs54.readFileSync(path52.join(globalDir, rule));
|
|
32268
32288
|
verdict = comparePair(projectCopy, globalCopy);
|
|
32269
|
-
proseSame = proseEqual(projectCopy.toString("utf-8"), globalCopy.toString("utf-8"));
|
|
32270
32289
|
} catch {
|
|
32271
32290
|
out.shared -= 1;
|
|
32272
32291
|
out.unreadable.push(rule);
|
|
@@ -32274,8 +32293,16 @@ function compareCarriers(projectDir, globalDir, sourceDir) {
|
|
|
32274
32293
|
}
|
|
32275
32294
|
if (verdict === "identical") out.identical.push(rule);
|
|
32276
32295
|
else if (verdict === "provenance-only") out.provenanceOnly.push(rule);
|
|
32277
|
-
else
|
|
32278
|
-
|
|
32296
|
+
else {
|
|
32297
|
+
const projectText = projectCopy.toString("utf-8");
|
|
32298
|
+
const globalText = globalCopy.toString("utf-8");
|
|
32299
|
+
if (proseEqual(projectText, globalText)) {
|
|
32300
|
+
out.frontmatterOnly.push(rule);
|
|
32301
|
+
if (frontmatterPaths(projectText) !== frontmatterPaths(globalText)) {
|
|
32302
|
+
out.pathsScopeDiff.push(rule);
|
|
32303
|
+
}
|
|
32304
|
+
} else out.bodyDiff.push(rule);
|
|
32305
|
+
}
|
|
32279
32306
|
}
|
|
32280
32307
|
return out;
|
|
32281
32308
|
}
|
|
@@ -32304,6 +32331,7 @@ function render3(d) {
|
|
|
32304
32331
|
lines.push(` byte-identical ${d.identical.length}`);
|
|
32305
32332
|
lines.push(` differ ONLY in the install stamp ${d.provenanceOnly.length}`);
|
|
32306
32333
|
lines.push(` differ ONLY in frontmatter ${d.frontmatterOnly.length}`);
|
|
32334
|
+
lines.push(` of which disagree on \`paths:\` ${d.pathsScopeDiff.length} \u2190 ACTIONABLE`);
|
|
32307
32335
|
lines.push(` differ in PROSE ${d.bodyDiff.length}`);
|
|
32308
32336
|
if (d.unreadable.length > 0) {
|
|
32309
32337
|
lines.push(` unreadable on one side ${d.unreadable.length} (${d.unreadable.join(", ")})`);
|
|
@@ -32328,16 +32356,27 @@ function render3(d) {
|
|
|
32328
32356
|
for (const r of d.bodyDiff) lines.push(` - ${r}`);
|
|
32329
32357
|
lines.push("");
|
|
32330
32358
|
}
|
|
32331
|
-
if (d.
|
|
32332
|
-
lines.push(` ${String(d.
|
|
32333
|
-
lines.push("
|
|
32334
|
-
lines.push("
|
|
32335
|
-
lines.push("
|
|
32336
|
-
lines.push("
|
|
32337
|
-
lines.push("
|
|
32338
|
-
lines.push("
|
|
32339
|
-
lines.push("
|
|
32340
|
-
|
|
32359
|
+
if (d.pathsScopeDiff.length > 0) {
|
|
32360
|
+
lines.push(` \`paths:\` SCOPE DISAGREEMENT (${String(d.pathsScopeDiff.length)}) \u2014 same prose, different load schedule.`);
|
|
32361
|
+
lines.push(" ACT ON THESE. `paths` is the one frontmatter key this host reads: with it, a");
|
|
32362
|
+
lines.push(" rule fires when a matching file is read and is NOT re-injected after");
|
|
32363
|
+
lines.push(" /compact; without it, the rule loads unconditionally at launch. So the copy");
|
|
32364
|
+
lines.push(" that lacks `paths` DEFEATS the other copy's scoping \u2014 the rule the project");
|
|
32365
|
+
lines.push(" layer meant to deliver conditionally is delivered always, and an obligation");
|
|
32366
|
+
lines.push(" a maintainer deliberately scoped is silently unscoped on any machine that");
|
|
32367
|
+
lines.push(" carries both. Identical prose does not make this inert.");
|
|
32368
|
+
for (const r of d.pathsScopeDiff) lines.push(` - ${r}`);
|
|
32369
|
+
lines.push("");
|
|
32370
|
+
}
|
|
32371
|
+
const inert = d.frontmatterOnly.length - d.pathsScopeDiff.length;
|
|
32372
|
+
if (inert > 0) {
|
|
32373
|
+
lines.push(` ${String(inert)} further pair(s) differ ONLY in frontmatter the host does not read \u2014`);
|
|
32374
|
+
lines.push(" prose byte-identical, `paths` in agreement, nothing to act on. Two writers,");
|
|
32375
|
+
lines.push(" two policies: `generate-tools` emits `paths` where a rule is path-scoped and");
|
|
32376
|
+
lines.push(" nothing otherwise; `install.ts` writes agent-config's own vocabulary plus its");
|
|
32377
|
+
lines.push(" ownership stamp, and none of those keys is one this host reads");
|
|
32378
|
+
lines.push(" (claude-code-rules-dir-contract.md). Reported as a count on purpose: naming");
|
|
32379
|
+
lines.push(" every pair would bury the two classes above, which are the actionable ones.");
|
|
32341
32380
|
lines.push("");
|
|
32342
32381
|
}
|
|
32343
32382
|
if (d.provenanceOnly.length > 0) {
|
|
@@ -32376,6 +32415,10 @@ function render3(d) {
|
|
|
32376
32415
|
if (d.bodyDiff.length === 0) {
|
|
32377
32416
|
lines.push("");
|
|
32378
32417
|
lines.push(" No prose divergence \u2014 no rule's governed text differs between the carriers.");
|
|
32418
|
+
if (d.pathsScopeDiff.length > 0) {
|
|
32419
|
+
lines.push(" NOT an all-clear: the `paths:` disagreements above are still open, and they");
|
|
32420
|
+
lines.push(" change when a rule loads rather than what it says.");
|
|
32421
|
+
}
|
|
32379
32422
|
lines.push(" The condition is transient \u2014 it returns whenever the checkout moves ahead of");
|
|
32380
32423
|
lines.push(" the installed release \u2014 so this is a reading of right now, not a property of");
|
|
32381
32424
|
lines.push(" the repo. Re-run it, do not cite it. That holds doubly here: the reading also");
|
|
@@ -33012,7 +33055,7 @@ function render6(r) {
|
|
|
33012
33055
|
lines.push(" substituted with dist/, which would answer a different question.");
|
|
33013
33056
|
} else {
|
|
33014
33057
|
lines.push(
|
|
33015
|
-
` Divergence: ${d.shared} shared \xB7 ${d.bodyDiff.length} prose-diff \xB7 ${d.frontmatterOnly.length} frontmatter-only \xB7 ${d.provenanceOnly.length} provenance-only \xB7 ${d.projectOnly.length} project-only \xB7 ${d.globalOnly.length} global-only` + (d.manualOnlyGlobal.length > 0 ? ` (${d.manualOnlyGlobal.length} by design, ADR-004 manual)` : "")
|
|
33058
|
+
` Divergence: ${d.shared} shared \xB7 ${d.bodyDiff.length} prose-diff \xB7 ${d.frontmatterOnly.length} frontmatter-only (${d.pathsScopeDiff.length} on \`paths:\`) \xB7 ${d.provenanceOnly.length} provenance-only \xB7 ${d.projectOnly.length} project-only \xB7 ${d.globalOnly.length} global-only` + (d.manualOnlyGlobal.length > 0 ? ` (${d.manualOnlyGlobal.length} by design, ADR-004 manual)` : "")
|
|
33016
33059
|
);
|
|
33017
33060
|
if (d.bodyDiff.length > 0) {
|
|
33018
33061
|
lines.push(` PROSE DIVERGENCE \u2014 both copies reach the model: ${d.bodyDiff.join(", ")}`);
|
|
@@ -33021,9 +33064,16 @@ function render6(r) {
|
|
|
33021
33064
|
lines.push(" (claude-code-rules-dir-contract.md). The project copy is merely the");
|
|
33022
33065
|
lines.push(" newer one \u2014 recency, not precedence.");
|
|
33023
33066
|
}
|
|
33024
|
-
if (d.
|
|
33025
|
-
lines.push(` ${String(d.
|
|
33026
|
-
lines.push("
|
|
33067
|
+
if (d.pathsScopeDiff.length > 0) {
|
|
33068
|
+
lines.push(` \`paths:\` SCOPE DISAGREEMENT (${String(d.pathsScopeDiff.length)}) \u2014 same prose, different load`);
|
|
33069
|
+
lines.push(" schedule. `paths` is the one frontmatter key the host reads, so the copy");
|
|
33070
|
+
lines.push(" without it defeats the other copy's scoping: a deliberately scoped");
|
|
33071
|
+
lines.push(" obligation is delivered unconditionally. Act on these.");
|
|
33072
|
+
}
|
|
33073
|
+
const inertFm = d.frontmatterOnly.length - d.pathsScopeDiff.length;
|
|
33074
|
+
if (inertFm > 0) {
|
|
33075
|
+
lines.push(` ${String(inertFm)} further pair(s) differ only in frontmatter the host does not read \u2014`);
|
|
33076
|
+
lines.push(" prose byte-identical, `paths` in agreement, nothing to act on.");
|
|
33027
33077
|
}
|
|
33028
33078
|
if (d.unreadable.length > 0) {
|
|
33029
33079
|
lines.push(` Unreadable on one side (${d.unreadable.length}) \u2014 a broken install, not a disagreement.`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@event4u/agent-config",
|
|
3
3
|
"mcpName": "io.github.event4u-app/agent-config",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.32.0",
|
|
5
5
|
"description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
@@ -96,6 +96,7 @@ export class DeliveryState {
|
|
|
96
96
|
contract: Record<string, Any> | null;
|
|
97
97
|
stitch: Record<string, Any> | null;
|
|
98
98
|
stack: Record<string, Any> | null;
|
|
99
|
+
self_fix: Record<string, Any> | null;
|
|
99
100
|
|
|
100
101
|
constructor(args: {
|
|
101
102
|
ticket: Record<string, Any>;
|
|
@@ -117,6 +118,7 @@ export class DeliveryState {
|
|
|
117
118
|
contract?: Record<string, Any> | null;
|
|
118
119
|
stitch?: Record<string, Any> | null;
|
|
119
120
|
stack?: Record<string, Any> | null;
|
|
121
|
+
self_fix?: Record<string, Any> | null;
|
|
120
122
|
}) {
|
|
121
123
|
this.ticket = args.ticket;
|
|
122
124
|
this.persona = args.persona ?? 'senior-engineer';
|
|
@@ -137,6 +139,10 @@ export class DeliveryState {
|
|
|
137
139
|
this.contract = args.contract ?? null;
|
|
138
140
|
this.stitch = args.stitch ?? null;
|
|
139
141
|
this.stack = args.stack ?? null;
|
|
142
|
+
// Per-lane bounded-loop counters (`{test: {attempts, signatures[]}}`).
|
|
143
|
+
// `null` means no red check has been seen yet — distinct from `{}`,
|
|
144
|
+
// which means a lane record was created and then emptied.
|
|
145
|
+
this.self_fix = args.self_fix ?? null;
|
|
140
146
|
}
|
|
141
147
|
}
|
|
142
148
|
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded self-fix loop — the shared primitive behind the red-check lanes.
|
|
3
|
+
*
|
|
4
|
+
* The gap this closes, verified in code before it was built: `test` and
|
|
5
|
+
* `verify` both halt to `Outcome.BLOCKED` the instant their recorded verdict
|
|
6
|
+
* is not `success`, and neither carries an attempt counter. The halt surface
|
|
7
|
+
* they emit is a *user* question block with no `@agent-directive:` line, so a
|
|
8
|
+
* red check the agent could have fixed unaided still costs a user round-trip.
|
|
9
|
+
*
|
|
10
|
+
* What this module adds is a counter and two floors — nothing else. It
|
|
11
|
+
* deliberately adds **no critic**: the verdict is already a deterministic
|
|
12
|
+
* value the engine holds in hand, so there is no judgement for a second model
|
|
13
|
+
* to make. That distinction is why the TERMINAL honest null on
|
|
14
|
+
* `recursive-verification` (ADR-106: capability 87% vs 87%, McNemar p=1.0)
|
|
15
|
+
* does not bind here — the measured arm added a critic to decide whether an
|
|
16
|
+
* attempt was good enough, and its killer finding was that cost scaled with
|
|
17
|
+
* *all* tasks while benefit sat in the ~28% tail. A red-check retry fires only
|
|
18
|
+
* on a red: zero cost on the passing majority.
|
|
19
|
+
*
|
|
20
|
+
* The null does bind one thing, and it is honoured: the falsification shape.
|
|
21
|
+
* "The loop is redundant" must remain discoverable, so the loop never converts
|
|
22
|
+
* a red into a green. Every exit it owns is `PARTIAL` with the red verdict
|
|
23
|
+
* still on the surface.
|
|
24
|
+
*
|
|
25
|
+
* Two floors, neither invented here:
|
|
26
|
+
*
|
|
27
|
+
* - **Ceiling** — {@link SELF_FIX_CEILING} attempts per lane, which is the
|
|
28
|
+
* N=3 validation-loop budget from the `autonomous-execution` rule, applied
|
|
29
|
+
* per validation target (that rule resets its counter on a different
|
|
30
|
+
* target, so the counters below are per-lane rather than per-run).
|
|
31
|
+
* - **No-progress** — two consecutive attempts producing an identical verdict
|
|
32
|
+
* signature stop the loop immediately, budget remaining or not. This is the
|
|
33
|
+
* floor the `recursive-verification` skill already stated in prose ("two
|
|
34
|
+
* consecutive attempts score identical on the deterministic scorer; further
|
|
35
|
+
* depth cannot help, so stop"), adopted as code rather than left as advice,
|
|
36
|
+
* and it matches `context-hygiene`'s "same failure signature twice → stop
|
|
37
|
+
* and pivot now".
|
|
38
|
+
*
|
|
39
|
+
* No-progress is checked BEFORE the ceiling on purpose: an unchanged signature
|
|
40
|
+
* means the remaining budget cannot help, so spending it would be the exact
|
|
41
|
+
* wasted-attempt pattern both rules name.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type { DeliveryState } from '../../delivery_state.js';
|
|
45
|
+
import { type Any, Outcome, StepResult, agent_directive } from '../../delivery_state.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Attempts allowed per lane before the loop exits PARTIAL.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately the `autonomous-execution` N=3 budget rather than a fresh
|
|
51
|
+
* number: a second, differently-sized retry ceiling in the same package would
|
|
52
|
+
* be a rule the tree contradicts.
|
|
53
|
+
*/
|
|
54
|
+
export const SELF_FIX_CEILING = 3;
|
|
55
|
+
|
|
56
|
+
/** Lanes that own a bounded loop. One counter each — they are distinct targets. */
|
|
57
|
+
export const SELF_FIX_LANES = ['test', 'verify'] as const;
|
|
58
|
+
|
|
59
|
+
export type SelfFixLane = (typeof SELF_FIX_LANES)[number];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Keys excluded from the verdict signature at every depth.
|
|
63
|
+
*
|
|
64
|
+
* Without this the no-progress floor could never fire: a test verdict carries
|
|
65
|
+
* `duration_ms`, which changes on every run, so two byte-identical failures
|
|
66
|
+
* would sign differently and the loop would burn its whole budget on a target
|
|
67
|
+
* it was making no progress against.
|
|
68
|
+
*/
|
|
69
|
+
export const VOLATILE_KEYS: ReadonlyArray<string> = [
|
|
70
|
+
'duration_ms',
|
|
71
|
+
'elapsed_ms',
|
|
72
|
+
'timestamp',
|
|
73
|
+
'started_at',
|
|
74
|
+
'finished_at',
|
|
75
|
+
'ran_at',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** Why the loop stopped, or that it has budget left. */
|
|
79
|
+
export type SelfFixKind = 'retry' | 'exhausted' | 'no_progress';
|
|
80
|
+
|
|
81
|
+
/** Verdict of {@link decide} — pure, so the caller owns every mutation. */
|
|
82
|
+
export interface SelfFixDecision {
|
|
83
|
+
kind: SelfFixKind;
|
|
84
|
+
/** Attempts already spent on this lane before this decision. */
|
|
85
|
+
attempts: number;
|
|
86
|
+
/** 1-based number of the attempt a `retry` authorises. */
|
|
87
|
+
next_attempt: number;
|
|
88
|
+
ceiling: number;
|
|
89
|
+
signature: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Canonical, volatile-free signature of a recorded verdict.
|
|
94
|
+
*
|
|
95
|
+
* Two failures are "the same failure" when this string matches. Object keys
|
|
96
|
+
* are sorted so key order in the recorded state cannot masquerade as progress.
|
|
97
|
+
*/
|
|
98
|
+
export function verdict_signature(lane: string, verdict_payload: Any): string {
|
|
99
|
+
return `${lane}:${_canonical(verdict_payload)}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read the per-lane counter without mutating anything. */
|
|
103
|
+
export function decide(state: DeliveryState, lane: string, signature: string): SelfFixDecision {
|
|
104
|
+
const lane_state = _lane_state(state, lane);
|
|
105
|
+
const attempts = _nonNegativeInt(lane_state['attempts']);
|
|
106
|
+
const signatures = Array.isArray(lane_state['signatures'])
|
|
107
|
+
? (lane_state['signatures'] as Any[]).filter((s): s is string => typeof s === 'string')
|
|
108
|
+
: [];
|
|
109
|
+
const previous = signatures.length > 0 ? signatures[signatures.length - 1] : null;
|
|
110
|
+
|
|
111
|
+
const base = {
|
|
112
|
+
attempts,
|
|
113
|
+
next_attempt: attempts + 1,
|
|
114
|
+
ceiling: SELF_FIX_CEILING,
|
|
115
|
+
signature,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// No-progress before the ceiling: an unchanged signature means the
|
|
119
|
+
// remaining budget cannot move this target, so spending it is waste.
|
|
120
|
+
if (previous !== null && previous === signature) {
|
|
121
|
+
return { kind: 'no_progress', ...base };
|
|
122
|
+
}
|
|
123
|
+
if (attempts >= SELF_FIX_CEILING) {
|
|
124
|
+
return { kind: 'exhausted', ...base };
|
|
125
|
+
}
|
|
126
|
+
return { kind: 'retry', ...base };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Spend one attempt on `lane` and remember its signature.
|
|
131
|
+
*
|
|
132
|
+
* Mutates `state.self_fix` in place, which is how the counter survives the
|
|
133
|
+
* halt: the dispatcher persists the state it was handed, so the next
|
|
134
|
+
* invocation reads the incremented value. Callers must invoke this exactly
|
|
135
|
+
* once per `retry` decision — recording on an exit would inflate the count.
|
|
136
|
+
*/
|
|
137
|
+
export function record_attempt(state: DeliveryState, lane: string, signature: string): void {
|
|
138
|
+
const container = _pyTruthy(state.self_fix) ? (state.self_fix as Record<string, Any>) : {};
|
|
139
|
+
const lane_state = _lane_state(state, lane);
|
|
140
|
+
const signatures = Array.isArray(lane_state['signatures'])
|
|
141
|
+
? (lane_state['signatures'] as Any[]).filter((s): s is string => typeof s === 'string')
|
|
142
|
+
: [];
|
|
143
|
+
container[lane] = {
|
|
144
|
+
attempts: _nonNegativeInt(lane_state['attempts']) + 1,
|
|
145
|
+
signatures: [...signatures, signature],
|
|
146
|
+
};
|
|
147
|
+
state.self_fix = container;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** DoD entries the agent has not recorded as proven. Never inferred. */
|
|
151
|
+
export function unmet_dod(state: DeliveryState): Array<Record<string, Any>> {
|
|
152
|
+
const dod = (state.ticket ?? {})['dod'];
|
|
153
|
+
if (!Array.isArray(dod)) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
return (dod as Any[]).filter(
|
|
157
|
+
(item): item is Record<string, Any> => _isPlainObject(item) && item['proven'] !== true,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* BLOCKED halt that delegates the fix instead of asking the user.
|
|
163
|
+
*
|
|
164
|
+
* The `@agent-directive:` first line is what makes this an agent round-trip
|
|
165
|
+
* rather than a user one — the same delegation shape `run-tests` and
|
|
166
|
+
* `review-changes` already use. The numbered options stay, because an
|
|
167
|
+
* orchestrator that does not honour directives must still leave the human
|
|
168
|
+
* something to act on.
|
|
169
|
+
*/
|
|
170
|
+
export function retry_halt(args: {
|
|
171
|
+
lane: string;
|
|
172
|
+
ticket_id: string;
|
|
173
|
+
verdict: Any;
|
|
174
|
+
decision: SelfFixDecision;
|
|
175
|
+
fix_hint: string;
|
|
176
|
+
rerun_directive: string;
|
|
177
|
+
}): StepResult {
|
|
178
|
+
const { lane, ticket_id, verdict, decision, fix_hint, rerun_directive } = args;
|
|
179
|
+
return new StepResult({
|
|
180
|
+
outcome: Outcome.BLOCKED,
|
|
181
|
+
questions: [
|
|
182
|
+
agent_directive('fix-failing-checks', {
|
|
183
|
+
ticket: ticket_id,
|
|
184
|
+
lane,
|
|
185
|
+
attempt: decision.next_attempt,
|
|
186
|
+
ceiling: decision.ceiling,
|
|
187
|
+
}),
|
|
188
|
+
`> Ticket ${ticket_id} — \`${lane}\` reported \`${String(verdict)}\`. ` +
|
|
189
|
+
`Self-fix attempt ${decision.next_attempt} of ${decision.ceiling}.`,
|
|
190
|
+
`> The verdict is deterministic, so the fix is delegated rather than asked: ${fix_hint}`,
|
|
191
|
+
`> 1. Continue — fix the failures and re-run \`${rerun_directive}\``,
|
|
192
|
+
'> 2. Abort — stop this cycle and hand the failures back',
|
|
193
|
+
],
|
|
194
|
+
message:
|
|
195
|
+
`Ticket ${ticket_id} ${lane} verdict was \`${String(verdict)}\`; ` +
|
|
196
|
+
`self-fix attempt ${decision.next_attempt}/${decision.ceiling} delegated.`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* PARTIAL exit — the loop stopped and the check is STILL RED.
|
|
202
|
+
*
|
|
203
|
+
* `PARTIAL` rather than `BLOCKED` because attempts were spent and changes may
|
|
204
|
+
* have landed, and emphatically not `SUCCESS`: the risk this loop carries is
|
|
205
|
+
* that it converts a visible red into an invisible one, which is worse than
|
|
206
|
+
* the round-trip it removes. The dispatcher halts on PARTIAL exactly as it
|
|
207
|
+
* halts on BLOCKED, and the red verdict is the second line of the surface.
|
|
208
|
+
*/
|
|
209
|
+
export function partial_exit(args: {
|
|
210
|
+
lane: string;
|
|
211
|
+
ticket_id: string;
|
|
212
|
+
verdict: Any;
|
|
213
|
+
decision: SelfFixDecision;
|
|
214
|
+
unmet: Array<Record<string, Any>>;
|
|
215
|
+
rerun_directive: string;
|
|
216
|
+
}): StepResult {
|
|
217
|
+
const { lane, ticket_id, verdict, decision, unmet, rerun_directive } = args;
|
|
218
|
+
const reason =
|
|
219
|
+
decision.kind === 'no_progress'
|
|
220
|
+
? `two consecutive attempts produced an identical \`${lane}\` verdict, ` +
|
|
221
|
+
'so further attempts cannot help'
|
|
222
|
+
: `the ${decision.ceiling}-attempt budget for \`${lane}\` is spent`;
|
|
223
|
+
const dod_lines =
|
|
224
|
+
unmet.length > 0
|
|
225
|
+
? [
|
|
226
|
+
'> Definition-of-done items still unproven:',
|
|
227
|
+
...unmet.map((item) => {
|
|
228
|
+
const id = typeof item['id'] === 'string' ? item['id'] : '(no id)';
|
|
229
|
+
const check = typeof item['check'] === 'string' ? item['check'] : '(no check)';
|
|
230
|
+
return `> - \`${id}\` — \`${check}\``;
|
|
231
|
+
}),
|
|
232
|
+
]
|
|
233
|
+
: [];
|
|
234
|
+
return new StepResult({
|
|
235
|
+
outcome: Outcome.PARTIAL,
|
|
236
|
+
questions: [
|
|
237
|
+
`> Ticket ${ticket_id} — self-fix stopped after ` +
|
|
238
|
+
`${decision.attempts} attempt(s): ${reason}.`,
|
|
239
|
+
`> \`${lane}\` is STILL \`${String(verdict)}\`. Nothing here claims completion.`,
|
|
240
|
+
...dod_lines,
|
|
241
|
+
`> 1. I'll take it from here — hand me the failures and re-run \`${rerun_directive}\``,
|
|
242
|
+
'> 2. Continue anyway — override on a red verdict (NOT recommended)',
|
|
243
|
+
'> 3. Abort',
|
|
244
|
+
],
|
|
245
|
+
message:
|
|
246
|
+
`Ticket ${ticket_id} ${lane} verdict is \`${String(verdict)}\` after ` +
|
|
247
|
+
`${decision.attempts} self-fix attempt(s) (${decision.kind}); exiting PARTIAL.`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Per-lane record, defaulted without writing anything back. */
|
|
252
|
+
function _lane_state(state: DeliveryState, lane: string): Record<string, Any> {
|
|
253
|
+
const container = _pyTruthy(state.self_fix) ? (state.self_fix as Record<string, Any>) : {};
|
|
254
|
+
const lane_state = container[lane];
|
|
255
|
+
return _isPlainObject(lane_state) ? lane_state : {};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Ints only; a bool, float, negative, or non-number resets to 0. */
|
|
259
|
+
function _nonNegativeInt(value: Any): number {
|
|
260
|
+
if (typeof value !== 'number' || typeof value === 'boolean') {
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Deterministic JSON rendering with sorted keys and volatile keys removed.
|
|
271
|
+
*
|
|
272
|
+
* Hand-rolled rather than `JSON.stringify`: the replacer form cannot sort
|
|
273
|
+
* object keys, and key order is exactly what must not leak into the signature.
|
|
274
|
+
*/
|
|
275
|
+
function _canonical(value: Any): string {
|
|
276
|
+
if (value === null || value === undefined) {
|
|
277
|
+
return 'null';
|
|
278
|
+
}
|
|
279
|
+
if (typeof value === 'boolean' || typeof value === 'number') {
|
|
280
|
+
return String(value);
|
|
281
|
+
}
|
|
282
|
+
if (typeof value === 'string') {
|
|
283
|
+
return JSON.stringify(value);
|
|
284
|
+
}
|
|
285
|
+
if (Array.isArray(value)) {
|
|
286
|
+
return '[' + value.map((v) => _canonical(v)).join(',') + ']';
|
|
287
|
+
}
|
|
288
|
+
if (_isPlainObject(value)) {
|
|
289
|
+
const keys = Object.keys(value)
|
|
290
|
+
.filter((k) => !VOLATILE_KEYS.includes(k))
|
|
291
|
+
.sort();
|
|
292
|
+
return '{' + keys.map((k) => `${JSON.stringify(k)}:${_canonical(value[k])}`).join(',') + '}';
|
|
293
|
+
}
|
|
294
|
+
return JSON.stringify(String(value));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Python truthiness for the container kinds this module reads. */
|
|
298
|
+
function _pyTruthy(value: Any): boolean {
|
|
299
|
+
if (value === null || value === undefined || value === false) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
if (value === true) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
if (typeof value === 'number') {
|
|
306
|
+
return value !== 0;
|
|
307
|
+
}
|
|
308
|
+
if (typeof value === 'string') {
|
|
309
|
+
return value.length !== 0;
|
|
310
|
+
}
|
|
311
|
+
if (Array.isArray(value)) {
|
|
312
|
+
return value.length !== 0;
|
|
313
|
+
}
|
|
314
|
+
if (value instanceof Set || value instanceof Map) {
|
|
315
|
+
return value.size !== 0;
|
|
316
|
+
}
|
|
317
|
+
if (typeof value === 'object') {
|
|
318
|
+
return Object.keys(value as Record<string, Any>).length !== 0;
|
|
319
|
+
}
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** True for a dict-like value (mirrors Python `isinstance(x, dict)`). */
|
|
324
|
+
function _isPlainObject(value: Any): value is Record<string, Any> {
|
|
325
|
+
return (
|
|
326
|
+
typeof value === 'object' &&
|
|
327
|
+
value !== null &&
|
|
328
|
+
!Array.isArray(value) &&
|
|
329
|
+
!(value instanceof Set) &&
|
|
330
|
+
!(value instanceof Map)
|
|
331
|
+
);
|
|
332
|
+
}
|