@event4u/agent-config 9.31.0 → 9.33.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 +146 -0
- package/dist/agent-src/commands/review/routing.md +3 -4
- package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +32 -1
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +17 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
- package/dist/agent-src/skills/docker/SKILL.md +11 -0
- package/dist/agent-src/skills/laravel/SKILL.md +11 -0
- package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/ownership-map.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/registry.js +4 -4
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-2WELHO3P.js +582 -0
- package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
- package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
- package/dist/cli-delegate/{chunk-2WCLVDWC.js → chunk-PMQ6Z3T3.js} +1642 -165
- package/dist/cli-delegate/chunk-PZWRPRWP.js +38 -0
- package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
- package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
- package/dist/cli-delegate/cmd_conformance.js +308 -227
- package/dist/cli-delegate/cmd_doctor.js +4 -2
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +160 -58
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +30 -5
- package/dist/cli-delegate/cmd_settings_get.js +4 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_settings_set.js +5 -3
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- 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 +1136 -690
- package/dist/install/install.mjs +7 -4
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3688 -1159
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +107 -3
- package/src/agent-src/templates/scripts/ownership-map.example.yml +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/config/cost-parity-budget.json +167 -0
- package/src/config/gate-coverage.yml +44 -5
- package/src/config/quorum-attendance-budget.json +39 -0
- package/src/scripts/_cli/cmd_doctor.ts +69 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +44 -3
- package/src/scripts/_cli/handoff_generate.ts +36 -1
- package/src/scripts/_cli/handoff_sessions.ts +140 -9
- package/src/scripts/_dispatch.bash +5 -3
- 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/orchestration_record.ts +45 -0
- package/src/scripts/_lib/session_eol.ts +85 -0
- package/src/scripts/_lib/subagent_capsule.ts +256 -1
- package/src/scripts/ai_council/clients.ts +31 -0
- package/src/scripts/ai_council/events_log.ts +128 -6
- package/src/scripts/ai_council/orchestrator.ts +12 -3
- package/src/scripts/ai_council/pricing.ts +48 -1
- package/src/scripts/ai_council/quorum.ts +20 -0
- package/src/scripts/ai_council/session.ts +11 -0
- package/src/scripts/ask_transport.ts +20 -1
- package/src/scripts/cache_realization_report.ts +38 -0
- package/src/scripts/check_release_highlights.ts +7 -1
- package/src/scripts/check_secret_leak.test.ts +43 -0
- package/src/scripts/check_secret_leak.ts +12 -0
- package/src/scripts/conformance_scan.ts +105 -0
- package/src/scripts/cost/track.mjs +39 -0
- package/src/scripts/cost_summary.ts +104 -1
- package/src/scripts/council_cli.ts +131 -13
- package/src/scripts/dispatch_economy_report.ts +5 -2
- package/src/scripts/handoff_context_hook.ts +66 -18
- package/src/scripts/hooks/session_eol_hook.ts +12 -12
- package/src/scripts/install.ts +20 -6
- package/src/scripts/lint_budget_ownership.ts +81 -0
- package/src/scripts/lint_skill_frontmatter_safety.ts +55 -1
- package/src/scripts/lint_workflow_paths.ts +384 -0
- package/src/scripts/preamble_byte_census.ts +9 -1
- package/src/scripts/report_carrier_divergence.ts +104 -29
- package/src/scripts/report_conformance_funnel.ts +51 -4
- package/src/scripts/routing_doctor.ts +25 -2
- package/src/scripts/schemas/dod.schema.json +45 -0
- package/src/scripts/schemas/subagent.schema.json +3 -2
- package/dist/cli-delegate/chunk-RVGBH4XK.js +0 -142
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.33.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,
|
|
@@ -18,10 +18,34 @@
|
|
|
18
18
|
* Blocking a pipeline on "the user has an open decision" would turn a
|
|
19
19
|
* visibility aid into a second thing to fight.
|
|
20
20
|
*
|
|
21
|
+
* `--reply` is the same projection shaped for the END OF A CHAT REPLY rather
|
|
22
|
+
* than a terminal pane, and it exists because the pull channel above is only
|
|
23
|
+
* read by someone who already knows to ask. Transcript forensics over three
|
|
24
|
+
* consumer sessions found five "what do I do now?" follow-ups, every one of
|
|
25
|
+
* them next to a blocker the agent HAD reported — as a file reference, as a
|
|
26
|
+
* bare option count, or as a choice of substitute work. What the same corpus
|
|
27
|
+
* also shows is that a tool's output gets carried into the reply verbatim
|
|
28
|
+
* (the dashboard's step count appears word-for-word inside eight option
|
|
29
|
+
* blocks). So the fix is not to make the agent format better from memory; it
|
|
30
|
+
* is to hand it the finished text. See ADR-222.
|
|
31
|
+
*
|
|
32
|
+
* Two properties carry the contract mechanically:
|
|
33
|
+
* - Nothing owned by the user open ⇒ EMPTY output. "No blocker → no block"
|
|
34
|
+
* stops being a judgement call, so the command is safe to call
|
|
35
|
+
* unconditionally at reply-close.
|
|
36
|
+
* - Exactly ONE blocker is rendered in full — the one that unblocks most.
|
|
37
|
+
* Every other one is a single trailing line. The user is never handed a
|
|
38
|
+
* flat list to weigh themselves.
|
|
39
|
+
*
|
|
40
|
+
* Labels are English like the rest of the CLI surface; the agent mirrors them
|
|
41
|
+
* into the user's language when it lifts them into a reply, exactly as it does
|
|
42
|
+
* for `Recommendation:` / `Empfehlung:`.
|
|
43
|
+
*
|
|
21
44
|
* Invocation (from project root):
|
|
22
45
|
* ./agent-config gates # decisions owned by the user
|
|
23
46
|
* ./agent-config gates --all # every open blocker, grouped by owner
|
|
24
47
|
* ./agent-config gates --json # machine-readable
|
|
48
|
+
* ./agent-config gates --reply # reply-close form; empty when none
|
|
25
49
|
*/
|
|
26
50
|
|
|
27
51
|
import * as path from 'node:path';
|
|
@@ -209,6 +233,76 @@ function render(entries: readonly Entry[], all: boolean): string {
|
|
|
209
233
|
return lines.join('\n') + '\n';
|
|
210
234
|
}
|
|
211
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Reply-close form: the one blocking decision in full, the rest as one line.
|
|
238
|
+
*
|
|
239
|
+
* Deliberately narrower than `render()`. `Status` and `Owner` are roadmap-file
|
|
240
|
+
* metadata that a reader of a reply cannot act on, so they are dropped; what
|
|
241
|
+
* survives is what the user has to DO and how they will know it is done. The
|
|
242
|
+
* five-field shape stays where it is enforced — in the roadmap file — and does
|
|
243
|
+
* not follow the blocker into prose it would only pad.
|
|
244
|
+
*
|
|
245
|
+
* Returns '' when nothing is owned by the user, so the caller can append the
|
|
246
|
+
* result unconditionally and get silence when silence is correct.
|
|
247
|
+
*/
|
|
248
|
+
function renderReply(entries: readonly Entry[]): string {
|
|
249
|
+
const mine = entries.filter((e) => needsUser(e.blocker.owner));
|
|
250
|
+
if (mine.length === 0) {
|
|
251
|
+
return '';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// `collectEntries` already sorted by unblocking weight, so the first entry
|
|
255
|
+
// IS the one that matters most. Picking it here rather than asking the
|
|
256
|
+
// caller to choose is the point: a flat list is the failure mode.
|
|
257
|
+
const lead = mine[0] as Entry;
|
|
258
|
+
const rest = mine.slice(1);
|
|
259
|
+
const lines: string[] = [];
|
|
260
|
+
|
|
261
|
+
lines.push(`Blocked: ${lead.blocker.blocks} — ${lead.roadmapRel}`);
|
|
262
|
+
|
|
263
|
+
const steps = regroupTodo(lead.blocker.todo);
|
|
264
|
+
const doLabel = isLegacy(lead.blocker) ? 'Blocked until:' : 'Do:';
|
|
265
|
+
if (steps.length === 0) {
|
|
266
|
+
lines.push(
|
|
267
|
+
`${doLabel} (no steps recorded — the blocker entry needs a **What to do:** list)`,
|
|
268
|
+
);
|
|
269
|
+
} else if (steps.length === 1) {
|
|
270
|
+
lines.push(...wrap(`${doLabel} ${steps[0] as string}`, WIDTH, ' '));
|
|
271
|
+
} else {
|
|
272
|
+
// Emitted as authored, never re-numbered: real blocker entries already
|
|
273
|
+
// carry their own `1.` / `2.` markers (the CI-enforced `What to do:`
|
|
274
|
+
// shape), so adding our own would produce "1. 1. …". An entry that
|
|
275
|
+
// arrives unnumbered stays unnumbered — that is a fact about the
|
|
276
|
+
// roadmap entry, and papering over it here would hide it from the
|
|
277
|
+
// person who could fix it.
|
|
278
|
+
lines.push(`${doLabel}`);
|
|
279
|
+
for (const s of steps) {
|
|
280
|
+
lines.push(...wrap(` ${s}`, WIDTH, ' '));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
lines.push(...wrap(`Done when: ${lead.blocker.resolvedWhen}`, WIDTH, ' '));
|
|
284
|
+
|
|
285
|
+
if (rest.length > 0) {
|
|
286
|
+
// A count, not a roster. Naming all of them reproduces the exact
|
|
287
|
+
// failure this form exists to end — "zwölf Entscheidungen bei Dir …
|
|
288
|
+
// rund 850 Zeilen", which the transcript shows produced no decision at
|
|
289
|
+
// all. The one that blocks is written out above; the rest are one
|
|
290
|
+
// command away, and saying so is shorter than listing them and more
|
|
291
|
+
// honest than hiding them.
|
|
292
|
+
lines.push('');
|
|
293
|
+
lines.push(
|
|
294
|
+
...wrap(
|
|
295
|
+
`${rest.length} other decision${rest.length !== 1 ? 's' : ''} also wait${rest.length === 1 ? 's' : ''} on you, ` +
|
|
296
|
+
'none blocking this — `agent-config gates`.',
|
|
297
|
+
WIDTH,
|
|
298
|
+
' ',
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return lines.join('\n') + '\n';
|
|
304
|
+
}
|
|
305
|
+
|
|
212
306
|
function renderJson(entries: readonly Entry[], all: boolean): string {
|
|
213
307
|
const pick = all ? entries : entries.filter((e) => needsUser(e.blocker.owner));
|
|
214
308
|
return (
|
|
@@ -270,18 +364,28 @@ function main(argv?: readonly string[]): number {
|
|
|
270
364
|
const args = argv ?? process.argv.slice(2);
|
|
271
365
|
const all = args.includes('--all');
|
|
272
366
|
const json = args.includes('--json');
|
|
367
|
+
const reply = args.includes('--reply');
|
|
273
368
|
|
|
274
369
|
const repoRoot = _resolveRepoRoot(process.cwd());
|
|
275
370
|
const roadmapRoot = path.join(repoRoot, 'agents', 'roadmaps');
|
|
276
371
|
if (!_isDir(roadmapRoot)) {
|
|
372
|
+
// `--reply` stays silent here too: a project without roadmaps has no
|
|
373
|
+
// blocker to hand over, and a reply-close line saying so would be the
|
|
374
|
+
// ceremony the form exists to avoid.
|
|
277
375
|
process.stdout.write(
|
|
278
|
-
|
|
376
|
+
reply
|
|
377
|
+
? ''
|
|
378
|
+
: json
|
|
379
|
+
? '{"needsYou":0,"other":0,"blockers":[]}\n'
|
|
380
|
+
: 'No roadmaps directory — nothing to report.\n',
|
|
279
381
|
);
|
|
280
382
|
return 0;
|
|
281
383
|
}
|
|
282
384
|
|
|
283
385
|
const entries = collectEntries(roadmapRoot);
|
|
284
|
-
process.stdout.write(
|
|
386
|
+
process.stdout.write(
|
|
387
|
+
reply ? renderReply(entries) : json ? renderJson(entries, all) : render(entries, all),
|
|
388
|
+
);
|
|
285
389
|
return 0;
|
|
286
390
|
}
|
|
287
391
|
|
|
@@ -308,5 +412,5 @@ if (_isCliEntry()) {
|
|
|
308
412
|
process.exitCode = main();
|
|
309
413
|
}
|
|
310
414
|
|
|
311
|
-
export { main, needsUser, regroupTodo, wrap, collectEntries, render, renderJson };
|
|
415
|
+
export { main, needsUser, regroupTodo, wrap, collectEntries, render, renderJson, renderReply };
|
|
312
416
|
export type { Entry };
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# Copy this file to `.github/ownership-map.yml` (preferred) or
|
|
4
4
|
# `agents/ownership-map.yml` and adapt the entries to your stack.
|
|
5
5
|
# Omitting the file entirely is fine — routing falls back to the
|
|
6
|
-
#
|
|
6
|
+
# common role vocabulary in `skill:review-routing`.
|
|
7
7
|
#
|
|
8
8
|
# First match wins per file. List narrower globs before broader ones.
|
|
9
9
|
|
|
@@ -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
|
+
}
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* part of the contract).
|
|
8
8
|
*
|
|
9
9
|
* The step never calls an LLM. It inspects `state.ticket` (which carries
|
|
10
|
-
* `input.data` after the CLI projection)
|
|
10
|
+
* `input.data` after the CLI projection), shape-checks the optional
|
|
11
|
+
* `dod[]` slot (the executable definition-of-done —
|
|
12
|
+
* `src/scripts/schemas/dod.schema.json`), and routes on shape:
|
|
11
13
|
*
|
|
12
14
|
* - **Ticket envelope** (`id`, `title`, `acceptance_criteria`) — the
|
|
13
15
|
* R1 path. Validates the minimum viable shape and either returns
|
|
@@ -84,6 +86,15 @@ export const AMBIGUITIES: ReadonlyArray<Record<string, string>> = [
|
|
|
84
86
|
'user answers one clarifying question; the agent ' +
|
|
85
87
|
're-runs `refine-prompt` against the refreshed prompt',
|
|
86
88
|
},
|
|
89
|
+
{
|
|
90
|
+
code: 'malformed_dod',
|
|
91
|
+
trigger:
|
|
92
|
+
'`dod` key present but not a list, or an entry missing a ' +
|
|
93
|
+
'non-empty `id` / `check` string',
|
|
94
|
+
resolution:
|
|
95
|
+
'fix the `dod[]` entries against `src/scripts/schemas/dod.schema.json`, ' +
|
|
96
|
+
'or drop the key — the slot is optional',
|
|
97
|
+
},
|
|
87
98
|
{
|
|
88
99
|
code: 'prompt_ui_intent',
|
|
89
100
|
trigger:
|
|
@@ -99,6 +110,27 @@ export const AMBIGUITIES: ReadonlyArray<Record<string, string>> = [
|
|
|
99
110
|
/** Route on envelope shape: ticket path or prompt path. */
|
|
100
111
|
export function run(state: DeliveryState): StepResult {
|
|
101
112
|
const data = state.ticket || {};
|
|
113
|
+
|
|
114
|
+
// The `dod[]` slot is shape-checked on BOTH paths before either routes, so
|
|
115
|
+
// a malformed definition-of-done cannot reach the self-fix loop that
|
|
116
|
+
// reports it. Absent key → no check, no behaviour change.
|
|
117
|
+
const dod_issues = diagnose_dod(data);
|
|
118
|
+
if (dod_issues.length > 0) {
|
|
119
|
+
const dod_ticket_id = _pyTruthy(data.id) ? _pyStr(data.id) : '(no id)';
|
|
120
|
+
return new StepResult({
|
|
121
|
+
outcome: Outcome.BLOCKED,
|
|
122
|
+
questions: [
|
|
123
|
+
`> Ticket ${dod_ticket_id} — the \`dod[]\` slot is malformed: ` + dod_issues.join('; ') + '.',
|
|
124
|
+
'> Every entry needs a non-empty `id` and a non-empty `check` ' +
|
|
125
|
+
'(the command that proves it) — prose there cannot be run.',
|
|
126
|
+
'> 1. Fix the `dod[]` entries and re-invoke',
|
|
127
|
+
'> 2. Drop the `dod` key — the slot is optional',
|
|
128
|
+
'> 3. Abort',
|
|
129
|
+
],
|
|
130
|
+
message: `Ticket ${dod_ticket_id} has a malformed dod[] slot: ` + dod_issues.join('; '),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
102
134
|
if (_is_prompt_envelope(data)) {
|
|
103
135
|
return _run_prompt(state, data);
|
|
104
136
|
}
|
|
@@ -117,6 +149,53 @@ export function run(state: DeliveryState): StepResult {
|
|
|
117
149
|
});
|
|
118
150
|
}
|
|
119
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Report what is wrong with an envelope's `dod[]` slot; `[]` when it is fine.
|
|
154
|
+
*
|
|
155
|
+
* Exported so the shape is testable on its own and so a caller can ask the
|
|
156
|
+
* question without triggering a halt. An absent key is valid — the slot is
|
|
157
|
+
* optional and omitting it means the author never declared a machine-checkable
|
|
158
|
+
* done-condition, which is different from declaring an empty one.
|
|
159
|
+
*
|
|
160
|
+
* `check` is required to be non-empty for the reason the schema states: the
|
|
161
|
+
* dispatcher spawns no subprocesses, so `check` is handed to an agent to run,
|
|
162
|
+
* and an agent cannot run a sentence.
|
|
163
|
+
*/
|
|
164
|
+
export function diagnose_dod(data: Record<string, Any>): string[] {
|
|
165
|
+
if (!_isPlainObject(data) || !('dod' in data)) {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
const dod = data.dod;
|
|
169
|
+
if (!Array.isArray(dod)) {
|
|
170
|
+
return [`\`dod\` must be a list, got ${_pyTypeName(dod)}`];
|
|
171
|
+
}
|
|
172
|
+
const issues: string[] = [];
|
|
173
|
+
const seen = new Set<string>();
|
|
174
|
+
dod.forEach((entry, idx) => {
|
|
175
|
+
const position = idx + 1;
|
|
176
|
+
if (!_isPlainObject(entry)) {
|
|
177
|
+
issues.push(`entry ${position} must be an object, got ${_pyTypeName(entry)}`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const id = entry.id;
|
|
181
|
+
if (typeof id !== 'string' || !id.trim()) {
|
|
182
|
+
issues.push(`entry ${position} has no \`id\``);
|
|
183
|
+
} else if (seen.has(id)) {
|
|
184
|
+
issues.push(`entry ${position} repeats id \`${id}\``);
|
|
185
|
+
} else {
|
|
186
|
+
seen.add(id);
|
|
187
|
+
}
|
|
188
|
+
const check = entry.check;
|
|
189
|
+
if (typeof check !== 'string' || !check.trim()) {
|
|
190
|
+
issues.push(`entry ${position} has no \`check\` command`);
|
|
191
|
+
}
|
|
192
|
+
if ('proven' in entry && typeof entry.proven !== 'boolean') {
|
|
193
|
+
issues.push(`entry ${position} has a non-boolean \`proven\``);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
return issues;
|
|
197
|
+
}
|
|
198
|
+
|
|
120
199
|
/**
|
|
121
200
|
* True when `state.ticket` carries a prompt envelope.
|
|
122
201
|
*
|
|
@@ -442,6 +521,32 @@ function _pyTruthy(value: unknown): boolean {
|
|
|
442
521
|
return true;
|
|
443
522
|
}
|
|
444
523
|
|
|
524
|
+
/** Python `type(x).__name__` for the value kinds the dod diagnose path sees. */
|
|
525
|
+
function _pyTypeName(value: unknown): string {
|
|
526
|
+
if (value === null || value === undefined) {
|
|
527
|
+
return 'NoneType';
|
|
528
|
+
}
|
|
529
|
+
if (typeof value === 'boolean') {
|
|
530
|
+
return 'bool';
|
|
531
|
+
}
|
|
532
|
+
if (typeof value === 'number') {
|
|
533
|
+
return Number.isInteger(value) ? 'int' : 'float';
|
|
534
|
+
}
|
|
535
|
+
if (typeof value === 'string') {
|
|
536
|
+
return 'str';
|
|
537
|
+
}
|
|
538
|
+
if (Array.isArray(value)) {
|
|
539
|
+
return 'list';
|
|
540
|
+
}
|
|
541
|
+
if (value instanceof Set) {
|
|
542
|
+
return 'set';
|
|
543
|
+
}
|
|
544
|
+
if (value instanceof Map || _isPlainObject(value)) {
|
|
545
|
+
return 'dict';
|
|
546
|
+
}
|
|
547
|
+
return typeof value;
|
|
548
|
+
}
|
|
549
|
+
|
|
445
550
|
/** True for a dict-like value (mirrors Python `isinstance(x, dict)`). */
|
|
446
551
|
function _isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
447
552
|
return (
|