@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
|
@@ -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 (
|
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
* - Must be a dict.
|
|
18
18
|
* - Must carry a `verdict` key — one of `success`, `failed`,
|
|
19
19
|
* or `mixed` (targeted vs full-suite split). Anything else blocks.
|
|
20
|
-
* - `failed` or `mixed` verdicts
|
|
21
|
-
* the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* - `failed` or `mixed` verdicts enter the bounded self-fix loop
|
|
21
|
+
* (`./_self_fix.ts`): the first attempts are delegated by directive, and an
|
|
22
|
+
* exhausted or non-progressing loop exits `PARTIAL` with the verdict still
|
|
23
|
+
* on the surface so the user decides whether to continue or stop. This
|
|
24
|
+
* follows the `verify-before-complete` rule: a bad test outcome must never
|
|
25
|
+
* be silently skipped, and the loop cannot report one as success.
|
|
24
26
|
* - Optional keys (`targeted`, `full`, `duration_ms`,
|
|
25
27
|
* `followups`) feed the delivery report.
|
|
26
28
|
*/
|
|
@@ -28,6 +30,7 @@
|
|
|
28
30
|
import type { DeliveryState} from '../../delivery_state.js';
|
|
29
31
|
import { type Any, Outcome, StepResult, agent_directive } from '../../delivery_state.js';
|
|
30
32
|
import { resolve_policy } from '../../persona_policy.js';
|
|
33
|
+
import { decide, partial_exit, record_attempt, retry_halt, unmet_dod, verdict_signature } from './_self_fix.js';
|
|
31
34
|
|
|
32
35
|
const _ALLOWED_VERDICTS = ['success', 'failed', 'mixed'] as const;
|
|
33
36
|
|
|
@@ -53,7 +56,18 @@ export const AMBIGUITIES: ReadonlyArray<Record<string, string>> = [
|
|
|
53
56
|
{
|
|
54
57
|
code: 'bad_test_verdict',
|
|
55
58
|
trigger: '`state.tests[\'verdict\']` is `failed` or `mixed`',
|
|
56
|
-
resolution:
|
|
59
|
+
resolution:
|
|
60
|
+
'bounded self-fix loop: agent directive `fix-failing-checks` ' +
|
|
61
|
+
'while attempts remain and the verdict signature keeps changing',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
code: 'self_fix_exhausted',
|
|
65
|
+
trigger:
|
|
66
|
+
'the self-fix budget for `test` is spent, or two consecutive ' +
|
|
67
|
+
'attempts produced an identical verdict signature',
|
|
68
|
+
resolution:
|
|
69
|
+
'PARTIAL honest exit — the red verdict stays on the surface, ' +
|
|
70
|
+
'the user decides whether to take over, override, or abort',
|
|
57
71
|
},
|
|
58
72
|
];
|
|
59
73
|
|
|
@@ -156,18 +170,39 @@ function _blocked_on_shape(state: DeliveryState, issues: string[]): StepResult {
|
|
|
156
170
|
});
|
|
157
171
|
}
|
|
158
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Route a red test verdict through the bounded self-fix loop.
|
|
175
|
+
*
|
|
176
|
+
* Before this loop existed the function below returned a directive-free
|
|
177
|
+
* BLOCKED — a user question block — so every red cost a user round-trip even
|
|
178
|
+
* when the failure was one the agent could fix unaided. Now the first attempts
|
|
179
|
+
* are delegated and only an exhausted or non-progressing loop reaches the
|
|
180
|
+
* user, as a PARTIAL that still carries the red verdict.
|
|
181
|
+
*/
|
|
159
182
|
function _blocked_on_bad_verdict(state: DeliveryState, verdict: Any): StepResult {
|
|
160
183
|
const ticketId = _ticketId(state);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
184
|
+
const signature = verdict_signature('test', state.tests);
|
|
185
|
+
const decision = decide(state, 'test', signature);
|
|
186
|
+
|
|
187
|
+
if (decision.kind === 'retry') {
|
|
188
|
+
record_attempt(state, 'test', signature);
|
|
189
|
+
return retry_halt({
|
|
190
|
+
lane: 'test',
|
|
191
|
+
ticket_id: ticketId,
|
|
192
|
+
verdict,
|
|
193
|
+
decision,
|
|
194
|
+
fix_hint: 'read the failing assertions, fix the cause, re-run the same filter.',
|
|
195
|
+
rerun_directive: 'run-tests',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return partial_exit({
|
|
200
|
+
lane: 'test',
|
|
201
|
+
ticket_id: ticketId,
|
|
202
|
+
verdict,
|
|
203
|
+
decision,
|
|
204
|
+
unmet: unmet_dod(state),
|
|
205
|
+
rerun_directive: 'run-tests',
|
|
171
206
|
});
|
|
172
207
|
}
|
|
173
208
|
|
|
@@ -18,9 +18,12 @@
|
|
|
18
18
|
* `partial`. Matches the `Outcome` vocabulary used everywhere
|
|
19
19
|
* else in the flow.
|
|
20
20
|
* - A `success` verdict advances the flow to `report`.
|
|
21
|
-
* - A `blocked` or `partial` verdict
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* - A `blocked` or `partial` verdict enters the bounded self-fix loop
|
|
22
|
+
* (`./_self_fix.ts`): while attempts remain and the verdict signature
|
|
23
|
+
* keeps changing, the fix is delegated by directive. An exhausted or
|
|
24
|
+
* non-progressing loop exits `PARTIAL` with numbered options so the user
|
|
25
|
+
* decides whether to take over, override (rarely appropriate), or abort.
|
|
26
|
+
* The loop never turns a non-success verdict into `SUCCESS`.
|
|
24
27
|
* - Optional keys (`confidence`, `findings`, `followups`) feed
|
|
25
28
|
* the delivery report.
|
|
26
29
|
*/
|
|
@@ -28,6 +31,7 @@
|
|
|
28
31
|
import type { DeliveryState} from '../../delivery_state.js';
|
|
29
32
|
import { type Any, Outcome, StepResult, agent_directive } from '../../delivery_state.js';
|
|
30
33
|
import { resolve_policy } from '../../persona_policy.js';
|
|
34
|
+
import { decide, partial_exit, record_attempt, retry_halt, unmet_dod, verdict_signature } from './_self_fix.js';
|
|
31
35
|
|
|
32
36
|
const _ALLOWED_VERDICTS = ['success', 'blocked', 'partial'] as const;
|
|
33
37
|
|
|
@@ -52,7 +56,17 @@ export const AMBIGUITIES: ReadonlyArray<Record<string, string>> = [
|
|
|
52
56
|
code: 'bad_verify_verdict',
|
|
53
57
|
trigger: '`state.verify[\'verdict\']` is `blocked` or `partial`',
|
|
54
58
|
resolution:
|
|
55
|
-
'
|
|
59
|
+
'bounded self-fix loop: agent directive `fix-failing-checks` ' +
|
|
60
|
+
'while attempts remain — never bypass ' + '(see `verify-before-complete`)',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
code: 'self_fix_exhausted',
|
|
64
|
+
trigger:
|
|
65
|
+
'the self-fix budget for `verify` is spent, or two consecutive ' +
|
|
66
|
+
'attempts produced an identical verdict signature',
|
|
67
|
+
resolution:
|
|
68
|
+
'PARTIAL honest exit — the non-success verdict stays on the ' +
|
|
69
|
+
'surface; the user takes over, overrides, or aborts',
|
|
56
70
|
},
|
|
57
71
|
];
|
|
58
72
|
|
|
@@ -145,19 +159,39 @@ function _blocked_on_shape(state: DeliveryState, issues: string[]): StepResult {
|
|
|
145
159
|
});
|
|
146
160
|
}
|
|
147
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Route a red review verdict through the bounded self-fix loop.
|
|
164
|
+
*
|
|
165
|
+
* Same shape as the `test` lane, separate counter: `autonomous-execution`
|
|
166
|
+
* resets its N=3 budget on a different validation target, and a review finding
|
|
167
|
+
* is not a failing test. `verify-before-complete` is unaffected — the loop
|
|
168
|
+
* cannot report completion, and its exhausted exit is PARTIAL with the
|
|
169
|
+
* non-success verdict still on the surface.
|
|
170
|
+
*/
|
|
148
171
|
function _blocked_on_bad_verdict(state: DeliveryState, verdict: Any): StepResult {
|
|
149
172
|
const ticketId = _ticketId(state);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
173
|
+
const signature = verdict_signature('verify', state.verify);
|
|
174
|
+
const decision = decide(state, 'verify', signature);
|
|
175
|
+
|
|
176
|
+
if (decision.kind === 'retry') {
|
|
177
|
+
record_attempt(state, 'verify', signature);
|
|
178
|
+
return retry_halt({
|
|
179
|
+
lane: 'verify',
|
|
180
|
+
ticket_id: ticketId,
|
|
181
|
+
verdict,
|
|
182
|
+
decision,
|
|
183
|
+
fix_hint: 'address the recorded findings, then re-run the four-judge review.',
|
|
184
|
+
rerun_directive: 'review-changes',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return partial_exit({
|
|
189
|
+
lane: 'verify',
|
|
190
|
+
ticket_id: ticketId,
|
|
191
|
+
verdict,
|
|
192
|
+
decision,
|
|
193
|
+
unmet: unmet_dod(state),
|
|
194
|
+
rerun_directive: 'review-changes',
|
|
161
195
|
});
|
|
162
196
|
}
|
|
163
197
|
|
|
@@ -220,6 +220,7 @@ export class WorkState {
|
|
|
220
220
|
ui_polish: Dict | null;
|
|
221
221
|
contract: Dict | null;
|
|
222
222
|
stitch: Dict | null;
|
|
223
|
+
self_fix: Dict | null;
|
|
223
224
|
halts: Dict[];
|
|
224
225
|
version: number;
|
|
225
226
|
persona: string;
|
|
@@ -245,6 +246,7 @@ export class WorkState {
|
|
|
245
246
|
ui_polish?: Dict | null;
|
|
246
247
|
contract?: Dict | null;
|
|
247
248
|
stitch?: Dict | null;
|
|
249
|
+
self_fix?: Dict | null;
|
|
248
250
|
halts?: Dict[];
|
|
249
251
|
version?: number;
|
|
250
252
|
persona?: string;
|
|
@@ -269,6 +271,7 @@ export class WorkState {
|
|
|
269
271
|
this.ui_polish = opts.ui_polish ?? null;
|
|
270
272
|
this.contract = opts.contract ?? null;
|
|
271
273
|
this.stitch = opts.stitch ?? null;
|
|
274
|
+
this.self_fix = opts.self_fix ?? null;
|
|
272
275
|
this.halts = opts.halts ?? [];
|
|
273
276
|
this.version = opts.version ?? SCHEMA_VERSION;
|
|
274
277
|
this.persona = opts.persona ?? 'senior-engineer';
|
|
@@ -309,6 +312,7 @@ export function to_dict(state: WorkState): Dict {
|
|
|
309
312
|
_validate_ui_polish(state.ui_polish);
|
|
310
313
|
_validate_contract(state.contract);
|
|
311
314
|
_validate_stitch(state.stitch);
|
|
315
|
+
_validate_self_fix(state.self_fix);
|
|
312
316
|
_validate_halts(state.halts);
|
|
313
317
|
return {
|
|
314
318
|
version: state.version,
|
|
@@ -324,6 +328,7 @@ export function to_dict(state: WorkState): Dict {
|
|
|
324
328
|
ui_polish: state.ui_polish,
|
|
325
329
|
contract: state.contract,
|
|
326
330
|
stitch: state.stitch,
|
|
331
|
+
self_fix: state.self_fix,
|
|
327
332
|
halts: [...state.halts],
|
|
328
333
|
persona: state.persona,
|
|
329
334
|
memory: state.memory,
|
|
@@ -406,6 +411,9 @@ export function from_dict(payload: JsonValue): WorkState {
|
|
|
406
411
|
const stitch = payload['stitch'] ?? null;
|
|
407
412
|
_validate_stitch(stitch);
|
|
408
413
|
|
|
414
|
+
const self_fix = payload['self_fix'] ?? null;
|
|
415
|
+
_validate_self_fix(self_fix);
|
|
416
|
+
|
|
409
417
|
const halts = 'halts' in payload ? payload['halts'] : [];
|
|
410
418
|
_validate_halts(halts);
|
|
411
419
|
|
|
@@ -422,6 +430,7 @@ export function from_dict(payload: JsonValue): WorkState {
|
|
|
422
430
|
ui_polish: _isDict(ui_polish) ? { ...ui_polish } : null,
|
|
423
431
|
contract: _isDict(contract) ? { ...contract } : null,
|
|
424
432
|
stitch: _isDict(stitch) ? { ...stitch } : null,
|
|
433
|
+
self_fix: _isDict(self_fix) ? { ...self_fix } : null,
|
|
425
434
|
halts: Array.isArray(halts) ? ([...halts] as Dict[]) : [],
|
|
426
435
|
version: version as number,
|
|
427
436
|
persona: _get(payload, 'persona', 'senior-engineer') as string,
|
|
@@ -866,6 +875,71 @@ function _validate_ui_review(ui_review: JsonValue): void {
|
|
|
866
875
|
}
|
|
867
876
|
}
|
|
868
877
|
|
|
878
|
+
/**
|
|
879
|
+
* Reject malformed `self_fix` envelopes; tolerate `null` and `{}`.
|
|
880
|
+
*
|
|
881
|
+
* `null` means no red check has been seen. Once populated, each key is a lane
|
|
882
|
+
* name (`test`, `verify`) whose record may carry `attempts` (a non-negative
|
|
883
|
+
* int) and `signatures` (a list of strings). As with `ui_polish`, the schema
|
|
884
|
+
* enforces only shape: the ceiling and the no-progress floor are the handler's
|
|
885
|
+
* business (`directives/backend/_self_fix.ts`), so a state file cannot decide
|
|
886
|
+
* how many attempts are allowed.
|
|
887
|
+
*
|
|
888
|
+
* `attempts` is validated but NOT bounded here on purpose. A file carrying
|
|
889
|
+
* `attempts: 99` is well-formed and simply exhausted — rejecting it would turn
|
|
890
|
+
* a spent budget into a crash, and the loop's whole point is a clean exit.
|
|
891
|
+
*/
|
|
892
|
+
function _validate_self_fix(self_fix: JsonValue): void {
|
|
893
|
+
if (self_fix === null) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (!_isDict(self_fix)) {
|
|
897
|
+
throw new SchemaError(
|
|
898
|
+
`state.self_fix must be a JSON object or null; ` +
|
|
899
|
+
`got ${pyTypeName(self_fix)}`,
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
for (const [lane, record] of Object.entries(self_fix)) {
|
|
903
|
+
if (!_isDict(record)) {
|
|
904
|
+
throw new SchemaError(
|
|
905
|
+
`state.self_fix.${lane} must be a JSON object; ` +
|
|
906
|
+
`got ${pyTypeName(record)}`,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
if ('attempts' in record) {
|
|
910
|
+
const attempts = record['attempts'];
|
|
911
|
+
if (
|
|
912
|
+
typeof attempts !== 'number' ||
|
|
913
|
+
typeof attempts === 'boolean' ||
|
|
914
|
+
!Number.isInteger(attempts) ||
|
|
915
|
+
attempts < 0
|
|
916
|
+
) {
|
|
917
|
+
throw new SchemaError(
|
|
918
|
+
`state.self_fix.${lane}.attempts must be a non-negative ` +
|
|
919
|
+
`integer when present; got ${pyRepr(attempts)}`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if ('signatures' in record) {
|
|
924
|
+
const signatures = record['signatures'];
|
|
925
|
+
if (!Array.isArray(signatures)) {
|
|
926
|
+
throw new SchemaError(
|
|
927
|
+
`state.self_fix.${lane}.signatures must be a list when ` +
|
|
928
|
+
`present; got ${pyTypeName(signatures)}`,
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
for (const entry of signatures) {
|
|
932
|
+
if (typeof entry !== 'string') {
|
|
933
|
+
throw new SchemaError(
|
|
934
|
+
`state.self_fix.${lane}.signatures entries must be ` +
|
|
935
|
+
`strings; got ${pyTypeName(entry)}`,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
869
943
|
/**
|
|
870
944
|
* Reject malformed `ui_polish` envelopes; tolerate `null` and `{}`.
|
|
871
945
|
*
|
|
@@ -150,6 +150,7 @@ export function _to_delivery(work: WorkState): DeliveryState {
|
|
|
150
150
|
contract: work.contract as Record<string, DeliveryAny> | null,
|
|
151
151
|
stitch: work.stitch as Record<string, DeliveryAny> | null,
|
|
152
152
|
stack: work.stack as Record<string, DeliveryAny> | null,
|
|
153
|
+
self_fix: work.self_fix as Record<string, DeliveryAny> | null,
|
|
153
154
|
});
|
|
154
155
|
}
|
|
155
156
|
|
|
@@ -182,6 +183,11 @@ export function _sync_back(work: WorkState, delivery: DeliveryState): void {
|
|
|
182
183
|
work.contract = delivery.contract as Dict | null;
|
|
183
184
|
work.stitch = delivery.stitch as Dict | null;
|
|
184
185
|
work.stack = delivery.stack as Dict | null;
|
|
186
|
+
// Reassigned (not mutated) by `record_attempt`, so the defensive mirror
|
|
187
|
+
// above is load-bearing here rather than a no-op: without this line the
|
|
188
|
+
// attempt counter would reset on every dispatch and the loop would never
|
|
189
|
+
// reach either floor.
|
|
190
|
+
work.self_fix = delivery.self_fix as Dict | null;
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
/**
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
CAPSULE_SCHEMA_VERSION,
|
|
39
39
|
validateRecycleEnvelope,
|
|
40
40
|
} from '../_lib/subagent_capsule.js';
|
|
41
|
+
import { collectGrounding } from '../_lib/envelope_grounding.js';
|
|
41
42
|
import { atomic_write_json } from '../hooks/state_io.js';
|
|
42
43
|
|
|
43
44
|
export interface RecycleResult {
|
|
@@ -61,6 +62,9 @@ export function templateEnvelope(): Record<string, unknown> {
|
|
|
61
62
|
open_worker_envelopes: [],
|
|
62
63
|
artifact_paths: ['<path to a deliverable / note / evidence file>'],
|
|
63
64
|
assumptions: [],
|
|
65
|
+
next_task: '<the ONE task this envelope is written for — select content for it>',
|
|
66
|
+
suggested_skills: ['<skill the successor should invoke>'],
|
|
67
|
+
failed_approaches: ['<tried X, failed because Y — or the single entry "none">'],
|
|
64
68
|
};
|
|
65
69
|
}
|
|
66
70
|
|
|
@@ -123,6 +127,27 @@ export function runSessionRecycle(
|
|
|
123
127
|
}
|
|
124
128
|
if (envelope['workspace'] === undefined) envelope['workspace'] = projectRoot;
|
|
125
129
|
|
|
130
|
+
// Scripted grounding (Phase 3.3): the factual fields are READ, never
|
|
131
|
+
// composed. A model-written branch is a claim; this is a reading. The
|
|
132
|
+
// composer cannot override them — a "next_task" it got wrong is a
|
|
133
|
+
// proposal, a "head" it got wrong is a silent stale resume.
|
|
134
|
+
const grounding = collectGrounding(projectRoot);
|
|
135
|
+
// Drop the composer's factual keys UNCONDITIONALLY first. Guarding each
|
|
136
|
+
// assignment on `!== null` would leave a model-composed branch or head
|
|
137
|
+
// standing whenever the git read fails — the consumer would then compare
|
|
138
|
+
// a fabricated anchor against the real tree and produce either false
|
|
139
|
+
// drift or, worse, false silence. An unreadable fact is absent, never
|
|
140
|
+
// inherited from the composer.
|
|
141
|
+
for (const key of ['repo_identity', 'branch', 'head', 'status_summary', 'last_verify', 'uncommitted_paths']) {
|
|
142
|
+
delete envelope[key];
|
|
143
|
+
}
|
|
144
|
+
if (grounding.repo_identity !== null) envelope['repo_identity'] = grounding.repo_identity;
|
|
145
|
+
if (grounding.branch !== null) envelope['branch'] = grounding.branch;
|
|
146
|
+
if (grounding.head !== null) envelope['head'] = grounding.head;
|
|
147
|
+
if (grounding.status_summary !== null) envelope['status_summary'] = grounding.status_summary;
|
|
148
|
+
if (grounding.last_verify !== null) envelope['last_verify'] = grounding.last_verify;
|
|
149
|
+
envelope['uncommitted_paths'] = grounding.uncommitted_paths;
|
|
150
|
+
|
|
126
151
|
const violations = validateRecycleEnvelope(envelope);
|
|
127
152
|
if (violations.length > 0) {
|
|
128
153
|
return {
|
|
@@ -35,6 +35,19 @@ export const WORD_CAP = 1200;
|
|
|
35
35
|
const SNIPPET_CHARS = 200;
|
|
36
36
|
const DONE_SNIPPET_CHARS = 400;
|
|
37
37
|
const MAX_ERRORS = 5;
|
|
38
|
+
/**
|
|
39
|
+
* Per-error cap. Above `SNIPPET_CHARS` because an error string is kept
|
|
40
|
+
* verbatim so it stays greppable — but deliberately not far above.
|
|
41
|
+
*
|
|
42
|
+
* The section competes for a fixed word cap whose trim pops the NEWEST entry
|
|
43
|
+
* first, and the newest failure is the one a successor needs. Enlarging each
|
|
44
|
+
* entry therefore makes that pre-existing trim order bite more often. 400 is
|
|
45
|
+
* twice the old truncation — enough to carry a real error line whole — while
|
|
46
|
+
* keeping the five-entry section within roughly a sixth of the cap. The
|
|
47
|
+
* pop()-newest ordering is pre-existing and is not this change's to alter;
|
|
48
|
+
* this bound is what keeps the change from amplifying it.
|
|
49
|
+
*/
|
|
50
|
+
const MAX_ERROR_CHARS = 400;
|
|
38
51
|
const MAX_DECISIONS = 5;
|
|
39
52
|
const MAX_FILES = 15;
|
|
40
53
|
|
|
@@ -325,11 +338,23 @@ export function build_handoff(
|
|
|
325
338
|
);
|
|
326
339
|
|
|
327
340
|
// Errors + fixes — failure-pattern turns, oldest→newest, last 5.
|
|
341
|
+
//
|
|
342
|
+
// Precision rule (Phase 3.1): the error string is carried VERBATIM. It
|
|
343
|
+
// used to be `_snippet`-ed like every other section, which truncates
|
|
344
|
+
// mid-string — and a truncated error cannot be grepped, matched against a
|
|
345
|
+
// tracker, or recognised when it recurs, which is the only reason to
|
|
346
|
+
// carry it at all. So the FAILURE-matching line is taken whole (capped
|
|
347
|
+
// only against an absurd single line) instead of the turn being cut at an
|
|
348
|
+
// arbitrary offset.
|
|
328
349
|
const errors = _redact_items(
|
|
329
350
|
toolish
|
|
330
351
|
.filter((t) => FAILURE_RE.test(t.text))
|
|
331
352
|
.slice(-MAX_ERRORS)
|
|
332
|
-
.map((t) =>
|
|
353
|
+
.map((t) => {
|
|
354
|
+
const line = t.text.split('\n').find((l) => FAILURE_RE.test(l))?.trim() ?? t.text.trim();
|
|
355
|
+
const prefix = t.tool ? `${t.tool}: ` : '';
|
|
356
|
+
return `${prefix}${line}`.slice(0, MAX_ERROR_CHARS);
|
|
357
|
+
}),
|
|
333
358
|
);
|
|
334
359
|
|
|
335
360
|
// Key decisions — decision-pattern assistant turns, last 5.
|
|
@@ -396,6 +421,16 @@ export function build_handoff(
|
|
|
396
421
|
}
|
|
397
422
|
for (const s of sections) {
|
|
398
423
|
lines.push('', `## ${s.title}`, '');
|
|
424
|
+
if (s.title === 'Resume pointer') {
|
|
425
|
+
// Precision rules (Phase 3.1), rendered where the successor
|
|
426
|
+
// reads them rather than filed in a doc nobody opens.
|
|
427
|
+
lines.push(
|
|
428
|
+
'> Precision contract: identify code by signature or `path:line`, never by',
|
|
429
|
+
'> description; quote error strings verbatim; give every resume step its',
|
|
430
|
+
'> expected outcome, so "done" is checkable rather than felt.',
|
|
431
|
+
'',
|
|
432
|
+
);
|
|
433
|
+
}
|
|
399
434
|
if (s.items.length === 0) {
|
|
400
435
|
if (s.verbatim && verbatim.withheldLines > 0) {
|
|
401
436
|
lines.push(`- [${verbatim.withheldLines} line(s) withheld by privacy floor]`);
|