@event4u/agent-config 14.19.0 → 14.21.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +351 -156
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +10 -0
- package/dist/agent-src/commands/agent-handoff.md +42 -41
- package/dist/agent-src/commands/analyze/inbox.md +1 -1
- package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
- package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
- package/dist/agent-src/commands/analyze.md +12 -7
- package/dist/agent-src/commands/feature/plan.md +55 -16
- package/dist/agent-src/commands/feature/refactor.md +8 -3
- package/dist/agent-src/commands/humanize.md +48 -4
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +11 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
- package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
- package/dist/agent-src/rules/evaluator-independence.md +4 -4
- package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
- package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
- package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
- package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
- package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
- package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
- package/dist/agent-src/skills/markitdown/SKILL.md +2 -2
- package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
- package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
- package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/features.md +5 -0
- package/dist/agent-src/templates/roadmaps.md +29 -1
- package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/dist/cli/mcp/dispatch.js +10 -0
- package/dist/cli/mcp/stdio.js +33 -2
- package/dist/cli/mcp/telemetry.js +227 -0
- package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
- package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-2TAQ2K4V.js} +166 -1
- package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-643Z7FIC.js} +79 -12
- package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
- package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-J3RFNKG4.js} +41 -5
- package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
- package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
- package/dist/cli-delegate/cmd_conformance.js +88 -110
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +42 -12
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +59 -902
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +62 -31
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -6
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -5
- package/dist/hooks/dispatch.js +3801 -2803
- package/dist/install/install.mjs +396 -168
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +809 -180
- package/dist/scripts/ai_council/content_ceiling.js +63 -0
- package/dist/scripts/ai_council/seat_policy.js +106 -0
- package/dist/scripts/ai_council/transport_resolver.js +21 -0
- package/dist/scripts/mcp_server/telemetry.js +148 -0
- package/docs/guidelines/agent-infra/naming.md +1 -1
- package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
- package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
- package/docs/guidelines/redundancy-taxonomy.md +28 -0
- package/llms.txt +14 -14
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/src/config/assurance-capability-registry.json +9 -0
- package/src/config/gate-coverage.yml +55 -0
- package/src/config/gate-violation-baselines.json +18 -6
- package/src/config/release-gate-locality.yml +4 -3
- package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
- package/src/scripts/_lib/audit_field_provenance.ts +44 -3
- package/src/scripts/_lib/blocked_by_marker.ts +189 -0
- package/src/scripts/_lib/host_capability.ts +76 -1
- package/src/scripts/_lib/install_reach_checks.ts +33 -1
- package/src/scripts/_lib/mcp_bridge.ts +222 -10
- package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
- package/src/scripts/_lib/orchestration_record.ts +23 -1
- package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
- package/src/scripts/_lib/release_highlights.ts +19 -211
- package/src/scripts/_lib/release_material.ts +27 -77
- package/src/scripts/_lib/release_position.ts +96 -0
- package/src/scripts/_lib/review_skipped_record.ts +17 -1
- package/src/scripts/_lib/security_lint.ts +179 -7
- package/src/scripts/_lib/self_repair.ts +141 -6
- package/src/scripts/_lib/self_repair_store.ts +273 -7
- package/src/scripts/_lib/structured_ask.ts +117 -0
- package/src/scripts/_lib/subagent_capsule.ts +155 -3
- package/src/scripts/_lib/tty_prompt.ts +76 -0
- package/src/scripts/ai_council/api_transport.ts +163 -0
- package/src/scripts/ai_council/clients.ts +58 -63
- package/src/scripts/ai_council/config.ts +13 -0
- package/src/scripts/ai_council/content_ceiling.ts +79 -0
- package/src/scripts/ai_council/jury_aggregate.ts +178 -0
- package/src/scripts/ai_council/seat_policy.ts +137 -0
- package/src/scripts/ai_council/status_surface.ts +79 -0
- package/src/scripts/ai_council/transport_resolver.ts +59 -1
- package/src/scripts/ai_tells_rules.ts +446 -5
- package/src/scripts/ask_block_census.ts +499 -0
- package/src/scripts/bench_humanizer_eval.ts +264 -40
- package/src/scripts/bench_prose_tells_fp.ts +198 -0
- package/src/scripts/build_rule_trigger_matrix.ts +5 -0
- package/src/scripts/check_branch_work_committed.ts +234 -0
- package/src/scripts/check_mcp_doc_drift.ts +217 -0
- package/src/scripts/check_pack_size.ts +321 -3
- package/src/scripts/check_release_highlights.ts +0 -157
- package/src/scripts/council_cli.ts +18 -27
- package/src/scripts/detect_ai_tells.ts +142 -20
- package/src/scripts/handoff_context_hook.ts +54 -7
- package/src/scripts/harvest_reference_tokens.ts +698 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +31 -1
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
- package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
- package/src/scripts/hooks/session_eol_hook.ts +18 -7
- package/src/scripts/install-hooks.sh +52 -0
- package/src/scripts/install.ts +4 -4
- package/src/scripts/lint_agent_security.ts +316 -25
- package/src/scripts/lint_confusables.ts +42 -6
- package/src/scripts/lint_handoffs.ts +0 -132
- package/src/scripts/lint_hidden_unicode.ts +49 -7
- package/src/scripts/lint_instruction_smuggling.ts +42 -6
- package/src/scripts/lint_mcp_config_security.ts +42 -6
- package/src/scripts/lint_roadmap_blockers.ts +51 -1
- package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
- package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
- package/src/scripts/mcp_server/resources.ts +41 -0
- package/src/scripts/mcp_server/telemetry.ts +19 -4
- package/src/scripts/migrate_command_suggestions.ts +1 -1
- package/src/scripts/orchestration_record.ts +12 -1
- package/src/scripts/probe_unblocked_ask.ts +135 -2
- package/src/scripts/release.ts +50 -95
- package/src/scripts/release_drill.ts +6 -10
- package/src/scripts/release_env.ts +1 -0
- package/src/scripts/release_publication.ts +40 -31
- package/src/scripts/self_repair_cli.ts +31 -1
- package/src/scripts/self_review_gate.ts +409 -15
- package/src/scripts/skill_scout.ts +69 -0
- package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
- package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
|
@@ -12,12 +12,16 @@ import * as fs from 'node:fs';
|
|
|
12
12
|
import * as path from 'node:path';
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
|
+
ACTIVE_STATUSES,
|
|
15
16
|
creationCapReached,
|
|
16
17
|
type DefectFinding,
|
|
17
18
|
type DefectRecord,
|
|
18
19
|
type DefectSource,
|
|
19
20
|
fingerprint,
|
|
21
|
+
isWellFormedTarget,
|
|
20
22
|
mergeRecord,
|
|
23
|
+
parseTarget,
|
|
24
|
+
validateRecord,
|
|
21
25
|
} from './self_repair.js';
|
|
22
26
|
|
|
23
27
|
export const STORE_REL = path.join('agents', 'runtime', 'self-repair');
|
|
@@ -48,7 +52,16 @@ export function readRecord(root: string, fp: string): DefectRecord | null {
|
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Write one record. VALIDATES first — a `parked` record with no wake condition
|
|
57
|
+
* or a malformed target never reaches the store, because a store that accepts
|
|
58
|
+
* an unwakeable park has the same defect the park was supposed to fix.
|
|
59
|
+
*/
|
|
51
60
|
export function writeRecord(root: string, record: DefectRecord): void {
|
|
61
|
+
const problems = validateRecord(record);
|
|
62
|
+
if (problems.length > 0) {
|
|
63
|
+
throw new Error(`refusing to write an invalid record: ${problems.join('; ')}`);
|
|
64
|
+
}
|
|
52
65
|
const dir = storeDir(root);
|
|
53
66
|
fs.mkdirSync(dir, { recursive: true });
|
|
54
67
|
fs.writeFileSync(
|
|
@@ -71,7 +84,7 @@ export function listRecords(root: string): DefectRecord[] {
|
|
|
71
84
|
// The overflow counter shares the directory and the extension but is not
|
|
72
85
|
// a record. `isRecord` would reject it anyway; naming it here keeps the
|
|
73
86
|
// exclusion intentional rather than incidental.
|
|
74
|
-
if (!n.endsWith('.json') || n === OVERFLOW_FILE) {
|
|
87
|
+
if (!n.endsWith('.json') || n === OVERFLOW_FILE || n === TARGET_INDEX_FILE) {
|
|
75
88
|
continue;
|
|
76
89
|
}
|
|
77
90
|
const rec = readRecord(root, n.slice(0, -'.json'.length));
|
|
@@ -83,8 +96,16 @@ export function listRecords(root: string): DefectRecord[] {
|
|
|
83
96
|
return out;
|
|
84
97
|
}
|
|
85
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The records still in the queue. Since the status enum widened past
|
|
101
|
+
* `open | released`, "still queued" is `open` OR `candidate` — a record someone
|
|
102
|
+
* accepted but has not yet acted on has not left the queue, and a `declined`,
|
|
103
|
+
* `superseded`, `actioned` or `parked` one has.
|
|
104
|
+
*/
|
|
86
105
|
export function openRecords(root: string): DefectRecord[] {
|
|
87
|
-
return listRecords(root).filter((r) =>
|
|
106
|
+
return listRecords(root).filter((r) =>
|
|
107
|
+
(ACTIVE_STATUSES as readonly string[]).includes(r.status),
|
|
108
|
+
);
|
|
88
109
|
}
|
|
89
110
|
|
|
90
111
|
/**
|
|
@@ -134,29 +155,60 @@ export function upsertFinding(
|
|
|
134
155
|
root: string,
|
|
135
156
|
finding: DefectFinding,
|
|
136
157
|
now: string,
|
|
158
|
+
// The tree the targets resolve against. Defaults to the store root, which
|
|
159
|
+
// is the production case (both are the repo). A test sandboxes the STORE in
|
|
160
|
+
// a tmp dir while still resolving `rule:scope-control` against the real
|
|
161
|
+
// tree, and folding the two roots together would make that untestable.
|
|
162
|
+
opts?: { treeRoot?: string },
|
|
137
163
|
): DefectRecord | null {
|
|
138
164
|
const fp = fingerprint(finding.defect_class, finding.evidence);
|
|
165
|
+
// Targets are resolved against the tree HERE, at the write boundary: the
|
|
166
|
+
// pure half cannot read the filesystem, and validating anywhere later would
|
|
167
|
+
// mean a dangling id had already been stored.
|
|
168
|
+
const split = partitionTargets(opts?.treeRoot ?? root, [
|
|
169
|
+
...(finding.target ?? []),
|
|
170
|
+
...(finding.proposes ?? []),
|
|
171
|
+
]);
|
|
172
|
+
const resolved: DefectFinding = {
|
|
173
|
+
...finding,
|
|
174
|
+
...(split.target.length > 0 ? { target: split.target } : {}),
|
|
175
|
+
...(split.proposes.length > 0 ? { proposes: split.proposes } : {}),
|
|
176
|
+
};
|
|
177
|
+
if (split.target.length === 0) delete resolved.target;
|
|
178
|
+
if (split.proposes.length === 0) delete resolved.proposes;
|
|
139
179
|
const existing = readRecord(root, fp);
|
|
140
180
|
// The fold path never consults the cap: a fingerprint already in the store
|
|
141
181
|
// adds no file, so capping it would silence a tracked defect for no bound.
|
|
142
|
-
if (existing === null && creationCapReached(listRecords(root),
|
|
143
|
-
bumpOverflow(root,
|
|
182
|
+
if (existing === null && creationCapReached(listRecords(root), resolved, now)) {
|
|
183
|
+
bumpOverflow(root, resolved.source, now);
|
|
144
184
|
return null;
|
|
145
185
|
}
|
|
146
|
-
const merged = mergeRecord(existing,
|
|
186
|
+
const merged = mergeRecord(existing, resolved, now);
|
|
147
187
|
writeRecord(root, merged);
|
|
148
188
|
return merged;
|
|
149
189
|
}
|
|
150
190
|
|
|
151
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* Mark a record released — a PR or issue now carries it.
|
|
193
|
+
*
|
|
194
|
+
* The stored status is `actioned`, not the retired `released`: releasing IS the
|
|
195
|
+
* action, and the widened enum has no separate slot for it. The function name
|
|
196
|
+
* is kept because the CALLER's event is still "a release happened".
|
|
197
|
+
*/
|
|
152
198
|
export function markReleased(root: string, fp: string, now: string): DefectRecord | null {
|
|
153
199
|
const rec = readRecord(root, fp);
|
|
154
200
|
if (rec === null) {
|
|
155
201
|
return null;
|
|
156
202
|
}
|
|
157
203
|
// A successful release supersedes any earlier failed attempts.
|
|
158
|
-
const next: DefectRecord = {
|
|
204
|
+
const next: DefectRecord = {
|
|
205
|
+
...rec,
|
|
206
|
+
status: 'actioned',
|
|
207
|
+
resolution: rec.resolution ?? 'released — a pull request or issue now carries this record',
|
|
208
|
+
last_seen: now,
|
|
209
|
+
};
|
|
159
210
|
delete next.release_errors;
|
|
211
|
+
delete next.parked_until;
|
|
160
212
|
writeRecord(root, next);
|
|
161
213
|
return next;
|
|
162
214
|
}
|
|
@@ -186,3 +238,217 @@ export function attachReleaseErrors(
|
|
|
186
238
|
writeRecord(root, next);
|
|
187
239
|
return next;
|
|
188
240
|
}
|
|
241
|
+
|
|
242
|
+
// target resolution
|
|
243
|
+
//
|
|
244
|
+
// `suggested_surface` is free text, so nothing could join a record to the asset
|
|
245
|
+
// it is about. A `target` entry is `<kind>:<id>` from a closed vocabulary AND it
|
|
246
|
+
// is resolved against the tree at WRITE time — an unresolvable entry is not
|
|
247
|
+
// rejected (a record proposing an asset that should exist is real information)
|
|
248
|
+
// and is not silently accepted either (a dangling id looks joined and joins to
|
|
249
|
+
// nothing). It is moved to `proposes`, where a reader can see it for what it is.
|
|
250
|
+
|
|
251
|
+
/** Where each target kind lives in the source tree. */
|
|
252
|
+
function _targetExists(root: string, kind: string, id: string): boolean {
|
|
253
|
+
if (kind === 'rule') {
|
|
254
|
+
return fs.existsSync(path.join(root, 'src', 'rules', `${id}.md`));
|
|
255
|
+
}
|
|
256
|
+
if (kind === 'skill') {
|
|
257
|
+
return fs.existsSync(path.join(root, 'src', 'skills', id, 'SKILL.md'));
|
|
258
|
+
}
|
|
259
|
+
if (kind === 'command') {
|
|
260
|
+
return _commandIds(root).has(id);
|
|
261
|
+
}
|
|
262
|
+
if (kind === 'hook') {
|
|
263
|
+
return _concernIds(root).has(id);
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Command ids, read from the pack tree: every `command.md` under
|
|
270
|
+
* `src/domains/<pack>/…` contributes the name of its containing directory, plus
|
|
271
|
+
* the `parent-child` form a clustered command is referred to by.
|
|
272
|
+
*/
|
|
273
|
+
function _commandIds(root: string): ReadonlySet<string> {
|
|
274
|
+
const out = new Set<string>();
|
|
275
|
+
const base = path.join(root, 'src', 'domains');
|
|
276
|
+
const walk = (dir: string, trail: string[]): void => {
|
|
277
|
+
let entries: fs.Dirent[];
|
|
278
|
+
try {
|
|
279
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
280
|
+
} catch {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
for (const ent of entries) {
|
|
284
|
+
if (ent.isDirectory()) {
|
|
285
|
+
walk(path.join(dir, ent.name), [...trail, ent.name]);
|
|
286
|
+
} else if (ent.name === 'command.md' && trail.length > 0) {
|
|
287
|
+
out.add(trail[trail.length - 1] as string);
|
|
288
|
+
if (trail.length > 1) {
|
|
289
|
+
out.add(trail.slice(-2).join('-'));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
walk(base, []);
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Hook-concern ids, read from the compiled manifest rather than re-parsing the
|
|
300
|
+
* YAML: `hook_manifest.json` is the artifact the dispatcher itself reads, so a
|
|
301
|
+
* concern that resolves here is a concern that actually exists at runtime.
|
|
302
|
+
*/
|
|
303
|
+
function _concernIds(root: string): ReadonlySet<string> {
|
|
304
|
+
try {
|
|
305
|
+
const raw = fs.readFileSync(
|
|
306
|
+
path.join(root, 'src', 'scripts', 'hook_manifest.json'),
|
|
307
|
+
'utf-8',
|
|
308
|
+
);
|
|
309
|
+
const parsed = JSON.parse(raw) as { manifest?: { concerns?: Record<string, unknown> } };
|
|
310
|
+
return new Set(Object.keys(parsed.manifest?.concerns ?? {}));
|
|
311
|
+
} catch {
|
|
312
|
+
return new Set<string>();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Split a caller's target list into the entries that resolve in the tree and
|
|
318
|
+
* the entries that do not. Malformed entries go to `proposes` too — a string
|
|
319
|
+
* that is not `<kind>:<id>` cannot be resolved, and dropping it would lose the
|
|
320
|
+
* observation.
|
|
321
|
+
*/
|
|
322
|
+
export function partitionTargets(
|
|
323
|
+
root: string,
|
|
324
|
+
values: readonly string[],
|
|
325
|
+
): { target: string[]; proposes: string[] } {
|
|
326
|
+
const target: string[] = [];
|
|
327
|
+
const proposes: string[] = [];
|
|
328
|
+
for (const raw of values) {
|
|
329
|
+
const value = raw.trim();
|
|
330
|
+
if (value === '') continue;
|
|
331
|
+
const parsed = isWellFormedTarget(value) ? parseTarget(value) : null;
|
|
332
|
+
if (parsed !== null && _targetExists(root, parsed.kind, parsed.id)) {
|
|
333
|
+
if (!target.includes(value)) target.push(value);
|
|
334
|
+
} else if (!proposes.includes(value)) {
|
|
335
|
+
proposes.push(value);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return { target, proposes };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// per-target occurrence index
|
|
342
|
+
//
|
|
343
|
+
// `occurrences` counts a FINGERPRINT, which is class plus normalised evidence
|
|
344
|
+
// shape — so two different manifestations of one rule's weakness are two
|
|
345
|
+
// records with one occurrence each, and the third-recurrence escalation stated
|
|
346
|
+
// in `skill-improvement-pipeline` had nothing that could ever establish a third
|
|
347
|
+
// recurrence. This is a DERIVED read over the record files: regenerable,
|
|
348
|
+
// byte-stable, and gitignored with the rest of `agents/runtime/`.
|
|
349
|
+
|
|
350
|
+
// cache-invalidation: regenerated WHOLESALE from the record files on every run
|
|
351
|
+
// (`writeTargetIndex` recomputes from scratch — no incremental or partial
|
|
352
|
+
// state), and the output is deterministic by construction, so a stale read is
|
|
353
|
+
// impossible and a version namespace would name a format nothing reads across
|
|
354
|
+
// runs. The shape change that would matter is a shape change to the records
|
|
355
|
+
// themselves, which `validateRecord` already gates.
|
|
356
|
+
export const TARGET_INDEX_FILE = '_target-index.json';
|
|
357
|
+
|
|
358
|
+
export interface TargetCount {
|
|
359
|
+
target: string;
|
|
360
|
+
/** Sum of `occurrences` across every record naming this target. */
|
|
361
|
+
occurrences: number;
|
|
362
|
+
/** Fingerprints naming it — a per-target count of DISTINCT manifestations. */
|
|
363
|
+
records: string[];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function targetIndexPath(root: string): string {
|
|
367
|
+
return path.join(storeDir(root), TARGET_INDEX_FILE);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Aggregate occurrences by target. Pure over the store's record files. */
|
|
371
|
+
export function targetCounts(root: string): TargetCount[] {
|
|
372
|
+
const acc = new Map<string, { occurrences: number; records: string[] }>();
|
|
373
|
+
for (const rec of listRecords(root)) {
|
|
374
|
+
for (const value of rec.target ?? []) {
|
|
375
|
+
const prev = acc.get(value) ?? { occurrences: 0, records: [] };
|
|
376
|
+
prev.occurrences += Number.isFinite(rec.occurrences) ? rec.occurrences : 0;
|
|
377
|
+
if (!prev.records.includes(rec.fingerprint)) prev.records.push(rec.fingerprint);
|
|
378
|
+
acc.set(value, prev);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return [...acc.entries()]
|
|
382
|
+
.map(([target, v]) => ({
|
|
383
|
+
target,
|
|
384
|
+
occurrences: v.occurrences,
|
|
385
|
+
records: [...v.records].sort(),
|
|
386
|
+
}))
|
|
387
|
+
.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Write the derived index. Deterministic by construction — sorted keys, sorted
|
|
392
|
+
* fingerprints, no timestamp — so deleting it and rebuilding produces the same
|
|
393
|
+
* bytes. A timestamp here would make the artifact look fresh while saying
|
|
394
|
+
* nothing, and would destroy the one property that makes a rebuild routine.
|
|
395
|
+
*/
|
|
396
|
+
export function writeTargetIndex(root: string): TargetCount[] {
|
|
397
|
+
const counts = targetCounts(root);
|
|
398
|
+
fs.mkdirSync(storeDir(root), { recursive: true });
|
|
399
|
+
fs.writeFileSync(targetIndexPath(root), `${JSON.stringify(counts, null, 2)}\n`, 'utf-8');
|
|
400
|
+
return counts;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// status transitions
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Migrate `released` records — the retired status — to `actioned`, carrying a
|
|
407
|
+
* resolution line that says the migration is why. Idempotent: a record already
|
|
408
|
+
* on the new enum is left byte-identical, so running it twice over a store
|
|
409
|
+
* produces no second diff.
|
|
410
|
+
*
|
|
411
|
+
* Returns the fingerprints it changed.
|
|
412
|
+
*/
|
|
413
|
+
export function migrateReleasedRecords(root: string): string[] {
|
|
414
|
+
const changed: string[] = [];
|
|
415
|
+
for (const rec of listRecords(root)) {
|
|
416
|
+
if ((rec.status as string) !== 'released') continue;
|
|
417
|
+
const next: DefectRecord = {
|
|
418
|
+
...rec,
|
|
419
|
+
status: 'actioned',
|
|
420
|
+
resolution:
|
|
421
|
+
rec.resolution ??
|
|
422
|
+
'migrated from the retired `released` status; the release itself is the action recorded',
|
|
423
|
+
};
|
|
424
|
+
writeRecord(root, next);
|
|
425
|
+
changed.push(rec.fingerprint);
|
|
426
|
+
}
|
|
427
|
+
return changed.sort();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Move a record to a terminal or parked status, validating the result. */
|
|
431
|
+
export function setStatus(
|
|
432
|
+
root: string,
|
|
433
|
+
fp: string,
|
|
434
|
+
next: DefectRecord['status'],
|
|
435
|
+
now: string,
|
|
436
|
+
extra?: { parked_until?: string; resolution?: string },
|
|
437
|
+
): DefectRecord | null {
|
|
438
|
+
const rec = readRecord(root, fp);
|
|
439
|
+
if (rec === null) return null;
|
|
440
|
+
const updated: DefectRecord = {
|
|
441
|
+
...rec,
|
|
442
|
+
status: next,
|
|
443
|
+
last_seen: now,
|
|
444
|
+
...(extra?.parked_until !== undefined ? { parked_until: extra.parked_until } : {}),
|
|
445
|
+
...(extra?.resolution !== undefined ? { resolution: extra.resolution } : {}),
|
|
446
|
+
};
|
|
447
|
+
if (next !== 'parked') delete updated.parked_until;
|
|
448
|
+
const problems = validateRecord(updated);
|
|
449
|
+
if (problems.length > 0) {
|
|
450
|
+
throw new Error(`refusing to write an invalid record: ${problems.join('; ')}`);
|
|
451
|
+
}
|
|
452
|
+
writeRecord(root, updated);
|
|
453
|
+
return updated;
|
|
454
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structured-ask surface — one shared definition of "a host's own question
|
|
3
|
+
* tool", read by every consumer that needs it.
|
|
4
|
+
*
|
|
5
|
+
* Three consumers exist and they must not each invent their own answer:
|
|
6
|
+
*
|
|
7
|
+
* - `probe_unblocked_ask.ts` partitions hand-back ask turns into `text` and
|
|
8
|
+
* `native`, where `native` means the turn carried a call to such a tool.
|
|
9
|
+
* - `hooks/one_question_per_ask_hook.ts` denies a call carrying more than one
|
|
10
|
+
* question.
|
|
11
|
+
* - `_lib/host_capability.ts` carries the boolean `structured_ask` capability;
|
|
12
|
+
* the per-host SHAPE lives here rather than in the manifest, because every
|
|
13
|
+
* manifest field is a strict boolean that `asBool` coerces — a nested object
|
|
14
|
+
* put there would normalize silently to `false`.
|
|
15
|
+
*
|
|
16
|
+
* What is observed, and what is a shape guess — the line is drawn explicitly.
|
|
17
|
+
*
|
|
18
|
+
* `STRUCTURED_ASK_SHAPES` is **empty**. No host in this repository's registry
|
|
19
|
+
* carries an observed structured-ask tool, so there is no per-host tool name to
|
|
20
|
+
* match on, and inventing one from a vendor's documentation is exactly what
|
|
21
|
+
* `host-capability-manifest.md` § Observation protocol forbids.
|
|
22
|
+
*
|
|
23
|
+
* `STRUCTURED_ASK_TOOL_NAME_RE` is therefore a **name-shape pattern, not a host
|
|
24
|
+
* fact**. It exists so that if such a tool ever appears in a transcript, the
|
|
25
|
+
* probe MEASURES it rather than missing it — and so the number it reports today
|
|
26
|
+
* (zero) is a measurement rather than an absence of instrumentation. A reader
|
|
27
|
+
* must not read a match as evidence that a named host ships that tool; the only
|
|
28
|
+
* thing that establishes that is an observation written into
|
|
29
|
+
* `STRUCTURED_ASK_SHAPES` with the protocol's four-part citation.
|
|
30
|
+
*
|
|
31
|
+
* Consequence, stated rather than discovered later: `native` is expected to be
|
|
32
|
+
* **0** on every corpus this repo can scan today. That is the honest reading of
|
|
33
|
+
* a capability nobody has observed, not a broken detector.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** The per-host shape of a structured-ask tool, once one has been OBSERVED. */
|
|
37
|
+
export interface StructuredAskShape {
|
|
38
|
+
/** The tool name as it appears in a transcript's `tool_use` block. */
|
|
39
|
+
readonly tool: string;
|
|
40
|
+
/** How many questions one call may carry. `1` is the shape this repo wants. */
|
|
41
|
+
readonly max_questions: number;
|
|
42
|
+
/** How many options one question may offer. */
|
|
43
|
+
readonly max_options_per_question: number;
|
|
44
|
+
/** Whether the host's picker admits a free-text answer alongside the options. */
|
|
45
|
+
readonly free_text: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Observed per-host structured-ask shapes.
|
|
50
|
+
*
|
|
51
|
+
* EMPTY, deliberately. A row is written only from an observation in a real
|
|
52
|
+
* session under `contexts/execution/host-capability-manifest.md` § Observation
|
|
53
|
+
* protocol — host · host version · transcript reference · date. Never from a
|
|
54
|
+
* host's documentation, and never by analogy to another host.
|
|
55
|
+
*/
|
|
56
|
+
export const STRUCTURED_ASK_SHAPES: Readonly<Record<string, StructuredAskShape>> = {};
|
|
57
|
+
|
|
58
|
+
/** The observed shape for `hostId`, or `undefined` when none has been observed. */
|
|
59
|
+
export function structuredAskShape(
|
|
60
|
+
hostId: string | null | undefined,
|
|
61
|
+
): StructuredAskShape | undefined {
|
|
62
|
+
if (hostId === null || hostId === undefined) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return STRUCTURED_ASK_SHAPES[hostId];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Name-SHAPE pattern for a structured-ask tool. See the module header: this is
|
|
70
|
+
* a matcher, not a claim about any host.
|
|
71
|
+
*/
|
|
72
|
+
export const STRUCTURED_ASK_TOOL_NAME_RE = /^(ask[_-]?user[_-]?question|user[_-]?question|ask[_-]?question)s?$/i;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Is `name` a structured-ask tool call?
|
|
76
|
+
*
|
|
77
|
+
* An observed per-host shape wins outright — that is a fact about the host. The
|
|
78
|
+
* name-shape pattern is the fallback, and it is why the probe can report a
|
|
79
|
+
* measured zero instead of nothing at all.
|
|
80
|
+
*/
|
|
81
|
+
export function isStructuredAskTool(name: string, hostId?: string | null): boolean {
|
|
82
|
+
const shape = structuredAskShape(hostId);
|
|
83
|
+
if (shape !== undefined) {
|
|
84
|
+
return name === shape.tool;
|
|
85
|
+
}
|
|
86
|
+
return STRUCTURED_ASK_TOOL_NAME_RE.test(name);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* How many questions a structured-ask tool payload carries.
|
|
91
|
+
*
|
|
92
|
+
* Returns `-1` when the payload's shape is not recognised at all — a caller
|
|
93
|
+
* that gates on this must treat `-1` as "cannot tell", never as "one". The two
|
|
94
|
+
* recognised shapes are the only ones a picker can plausibly use: an array of
|
|
95
|
+
* question objects under a `questions`-like key, or a single question field.
|
|
96
|
+
*
|
|
97
|
+
* A `questions` array of length 0 is 0, not 1: an empty ask is malformed and a
|
|
98
|
+
* gate should be able to say so.
|
|
99
|
+
*/
|
|
100
|
+
export function countStructuredAskQuestions(input: unknown): number {
|
|
101
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
102
|
+
return -1;
|
|
103
|
+
}
|
|
104
|
+
const src = input as Record<string, unknown>;
|
|
105
|
+
for (const key of ['questions', 'question_list', 'items', 'prompts']) {
|
|
106
|
+
const v = src[key];
|
|
107
|
+
if (Array.isArray(v)) {
|
|
108
|
+
return v.length;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const key of ['question', 'prompt', 'header']) {
|
|
112
|
+
if (typeof src[key] === 'string' && (src[key] as string).trim() !== '') {
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return -1;
|
|
117
|
+
}
|
|
@@ -109,7 +109,39 @@ export interface WorkerCapsule {
|
|
|
109
109
|
* affordable precisely here — an envelope is consume-once and expires in
|
|
110
110
|
* hours, so the migration window is a session, not a release.
|
|
111
111
|
*/
|
|
112
|
-
export const CAPSULE_SCHEMA_VERSION =
|
|
112
|
+
export const CAPSULE_SCHEMA_VERSION = 4;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Versions a reader ACCEPTS. Version 4 is the first bump that is purely
|
|
116
|
+
* ADDITIVE, and that is why it widens the accepted set instead of replacing it.
|
|
117
|
+
*
|
|
118
|
+
* The compatibility contract is stated here rather than inferred.
|
|
119
|
+
*
|
|
120
|
+
* - **Scope.** The version is ENVELOPE-WIDE, not per-variant. One number
|
|
121
|
+
* describes the whole schema module, so a variant added later does not
|
|
122
|
+
* silently re-date records of another variant.
|
|
123
|
+
* - **New reader, old record.** A v3 record validates. Its required set is v3's
|
|
124
|
+
* — the fields added at v4 are not demanded of it, because demanding them
|
|
125
|
+
* would be a retroactive requirement on records already written.
|
|
126
|
+
* - **Old reader, new record.** A v3-only reader rejects a v4 record with the
|
|
127
|
+
* version violation named. That is the pre-existing loud-discard behaviour and
|
|
128
|
+
* is affordable for the same reason v3 gave: a record is consume-once and
|
|
129
|
+
* expires in {@link RECYCLE_MAX_AGE_HOURS} hours, so the migration window is a
|
|
130
|
+
* session, not a release.
|
|
131
|
+
* - **Unknown version.** Anything outside this set is rejected with the set
|
|
132
|
+
* printed. Absent is not a pass — an undated record is not "probably current".
|
|
133
|
+
* - **Unknown variant.** Rejected by `variant` check, never coerced. A reader
|
|
134
|
+
* that does not know a variant must not guess at its required fields.
|
|
135
|
+
* - **Rollback.** Reverting to a v3-only reader after v4 records exist discards
|
|
136
|
+
* those records loudly rather than mis-reading them; the loss is one session's
|
|
137
|
+
* resume, which is the same loss as no record at all.
|
|
138
|
+
*
|
|
139
|
+
* Adding a required field is what forced v3 to be a hard break. v4 adds
|
|
140
|
+
* `successful_approaches`, `open_questions` and `predecessor`, and requires the
|
|
141
|
+
* first and third **only of records that declare themselves v4** — so no
|
|
142
|
+
* previously valid record becomes invalid.
|
|
143
|
+
*/
|
|
144
|
+
export const ACCEPTED_CAPSULE_VERSIONS: readonly number[] = [3, 4];
|
|
113
145
|
|
|
114
146
|
/** The two CHECKPOINT variants sharing this schema. */
|
|
115
147
|
export const CAPSULE_VARIANTS = ['worker', 'main_session'] as const;
|
|
@@ -280,6 +312,30 @@ export function validateCapsule(input: unknown): string[] {
|
|
|
280
312
|
* wire gate; this variant is consumed from a FILE by
|
|
281
313
|
* `handoff_context_hook`, so the module itself must be the strict gate.
|
|
282
314
|
*/
|
|
315
|
+
/**
|
|
316
|
+
* The four questions a resuming session asks, and the field that answers each —
|
|
317
|
+
* written down rather than left implicit, because the gap that produced
|
|
318
|
+
* `successful_approaches` stayed invisible for exactly as long as the mapping
|
|
319
|
+
* was unwritten: three of the four had a field, the fourth had nothing, and no
|
|
320
|
+
* reader of the schema had a reason to notice.
|
|
321
|
+
*
|
|
322
|
+
* *What was it about?* is answered directly by `task`, with `summary` carrying
|
|
323
|
+
* the one-line outcome. *What was the goal?* is answered only by PROXY:
|
|
324
|
+
* `acceptance_criteria` states what **done** means, which is the goal expressed
|
|
325
|
+
* as a test rather than as an intention, and `next_task` narrows it to the
|
|
326
|
+
* successor's first move. *What did NOT work?* is `failed_approaches`, direct,
|
|
327
|
+
* required, explicit `none`. *What DID work?* is `successful_approaches`,
|
|
328
|
+
* direct, required at version 4, explicit `none`.
|
|
329
|
+
*
|
|
330
|
+
* The one proxy is labelled a proxy on purpose. A goal can be met by criteria
|
|
331
|
+
* nobody wrote down, so reading `acceptance_criteria` as the goal itself is the
|
|
332
|
+
* error the label exists to prevent.
|
|
333
|
+
*
|
|
334
|
+
* Everything else the schema carries — `remaining`, `not_carried_forward`,
|
|
335
|
+
* `constraints`, `decisions`, `open_questions`, the drift anchor — answers a
|
|
336
|
+
* question the four do not ask. That is not surplus; it is simply outside this
|
|
337
|
+
* mapping.
|
|
338
|
+
*/
|
|
283
339
|
export interface MainSessionRecycleEnvelope {
|
|
284
340
|
/** Must be {@link CAPSULE_SCHEMA_VERSION}. */
|
|
285
341
|
capsule_version: number;
|
|
@@ -371,6 +427,59 @@ export interface MainSessionRecycleEnvelope {
|
|
|
371
427
|
* successor re-burning a recorded dead end.
|
|
372
428
|
*/
|
|
373
429
|
failed_approaches: string[];
|
|
430
|
+
/**
|
|
431
|
+
* Approaches this session tried and KEPT — "did X, it worked because Y".
|
|
432
|
+
*
|
|
433
|
+
* **The counterpart `failed_approaches` did not have**, and its absence was
|
|
434
|
+
* a structural defect rather than an omission: a record that can say what to
|
|
435
|
+
* avoid and cannot say what to repeat is pessimistic by construction, and a
|
|
436
|
+
* successor re-derives every working decision from scratch while being
|
|
437
|
+
* warned off the dead ends.
|
|
438
|
+
*
|
|
439
|
+
* Required at capsule version 4 with at least one entry, for exactly the
|
|
440
|
+
* reason `failed_approaches` is: a session that kept nothing writes the
|
|
441
|
+
* single entry `none` explicitly, so "nothing worked" stays distinguishable
|
|
442
|
+
* from "nobody wrote it down". An optional field would re-create the silence
|
|
443
|
+
* one layer down.
|
|
444
|
+
*/
|
|
445
|
+
successful_approaches?: string[];
|
|
446
|
+
/**
|
|
447
|
+
* Questions this session could not settle — the successor's first reading
|
|
448
|
+
* list. The worker variant has carried `open_risks` since v1
|
|
449
|
+
* (`WorkerCapsule.open_risks`) and the handoff template asks for open
|
|
450
|
+
* questions in prose; the session variant had neither, so the one thing a
|
|
451
|
+
* resuming session most needs to not silently drop had no field.
|
|
452
|
+
*
|
|
453
|
+
* Optional, unlike `successful_approaches`: an empty question list is not
|
|
454
|
+
* the same asymmetry — a record with nothing open is a complete claim, and
|
|
455
|
+
* `remaining` already carries the work.
|
|
456
|
+
*/
|
|
457
|
+
open_questions?: string[];
|
|
458
|
+
/**
|
|
459
|
+
* The session that WROTE this record.
|
|
460
|
+
*
|
|
461
|
+
* Added with `predecessor` and not separable from it: an edge needs two
|
|
462
|
+
* endpoints, and before v4 a record carried neither. `workspace` answers
|
|
463
|
+
* "which checkout", which is what the identity check needs; it cannot
|
|
464
|
+
* answer "which session", which is what a chain needs.
|
|
465
|
+
*/
|
|
466
|
+
session_id?: string;
|
|
467
|
+
/**
|
|
468
|
+
* The session this one continues, or the explicit string `none`.
|
|
469
|
+
*
|
|
470
|
+
* Required at capsule version 4. Before it, `grep -rn
|
|
471
|
+
* "predecessor_session\|lineage_id" src/` returned 0 and the reader
|
|
472
|
+
* injected whatever record was lying at the path — which is not the same
|
|
473
|
+
* question as "which record is mine" that the session key answers. A key
|
|
474
|
+
* says whose record this is; a predecessor edge says which record this
|
|
475
|
+
* session should be reading.
|
|
476
|
+
*
|
|
477
|
+
* `none` is a STATED ABSENCE, never an empty string or a missing field. The
|
|
478
|
+
* first session in a workspace has no predecessor and says so, so a reader
|
|
479
|
+
* never waits on something that will not arrive — and a NAMED predecessor
|
|
480
|
+
* that is absent is a refusal, never a fall-through to a different record.
|
|
481
|
+
*/
|
|
482
|
+
predecessor?: string;
|
|
374
483
|
/**
|
|
375
484
|
* Drift anchor — all three, or none of them is one. Identity is the
|
|
376
485
|
* canonicalized remote (or the realpath of the common git dir when there
|
|
@@ -615,6 +724,10 @@ const RECYCLE_ENVELOPE_KEYS: ReadonlySet<string> = new Set([
|
|
|
615
724
|
'next_task',
|
|
616
725
|
'suggested_skills',
|
|
617
726
|
'failed_approaches',
|
|
727
|
+
'successful_approaches',
|
|
728
|
+
'open_questions',
|
|
729
|
+
'predecessor',
|
|
730
|
+
'session_id',
|
|
618
731
|
'repo_identity',
|
|
619
732
|
'branch',
|
|
620
733
|
'head',
|
|
@@ -644,9 +757,16 @@ export function validateRecycleEnvelope(input: unknown): string[] {
|
|
|
644
757
|
}
|
|
645
758
|
}
|
|
646
759
|
|
|
647
|
-
|
|
648
|
-
|
|
760
|
+
const version = e['capsule_version'];
|
|
761
|
+
if (typeof version !== 'number' || !ACCEPTED_CAPSULE_VERSIONS.includes(version)) {
|
|
762
|
+
errors.push(`capsule_version must be one of ${ACCEPTED_CAPSULE_VERSIONS.join(' | ')}`);
|
|
649
763
|
}
|
|
764
|
+
// Requiredness is version-conditional, and only in the additive direction:
|
|
765
|
+
// a v4 record owes the v4 fields, a v3 record owes exactly what v3 owed.
|
|
766
|
+
// Demanding the new fields of an already-written record would be a
|
|
767
|
+
// retroactive requirement, which is the one thing the recorded schema lock
|
|
768
|
+
// forbids ("add fields or variants, never repurpose or remove one").
|
|
769
|
+
const atV4 = version === 4;
|
|
650
770
|
if (e['variant'] !== 'main_session') {
|
|
651
771
|
errors.push("variant must be 'main_session'");
|
|
652
772
|
}
|
|
@@ -697,6 +817,38 @@ export function validateRecycleEnvelope(input: unknown): string[] {
|
|
|
697
817
|
);
|
|
698
818
|
}
|
|
699
819
|
|
|
820
|
+
// Version 4 additions. `successful_approaches` mirrors `failed_approaches`
|
|
821
|
+
// exactly — required with >= 1 entry, `none` written explicitly — because
|
|
822
|
+
// the asymmetry between the two is the defect the field exists to remove,
|
|
823
|
+
// and an optional counterpart to a required field re-creates it.
|
|
824
|
+
checkList(errors, 'successful_approaches', e['successful_approaches'], MAX_LINE_CHARS, atV4);
|
|
825
|
+
if (Array.isArray(e['successful_approaches']) && e['successful_approaches'].length === 0) {
|
|
826
|
+
errors.push(
|
|
827
|
+
'successful_approaches must carry at least one entry — write "none" explicitly, so ' +
|
|
828
|
+
'silence and "nothing worked" stay distinguishable',
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
checkList(errors, 'open_questions', e['open_questions'], MAX_LINE_CHARS, false);
|
|
832
|
+
|
|
833
|
+
// The predecessor edge (Phase 2.3). Required at v4, and an EMPTY value is
|
|
834
|
+
// not an answer: `none` is a claim ("this session starts a chain"), absence
|
|
835
|
+
// is a reader waiting for something that will not arrive.
|
|
836
|
+
const predecessor = e['predecessor'];
|
|
837
|
+
if (predecessor === undefined) {
|
|
838
|
+
if (atV4) {
|
|
839
|
+
errors.push(
|
|
840
|
+
'predecessor is required — name the session this one continues, or write "none" ' +
|
|
841
|
+
'explicitly for the first session in a workspace',
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
} else if (!isShortLine(predecessor, MAX_REF_CHARS)) {
|
|
845
|
+
errors.push(`predecessor must be a single line of 1–${MAX_REF_CHARS} chars ("none" when there is no predecessor)`);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (e['session_id'] !== undefined && !isShortLine(e['session_id'], MAX_REF_CHARS)) {
|
|
849
|
+
errors.push(`session_id must be a single line of 1–${MAX_REF_CHARS} chars`);
|
|
850
|
+
}
|
|
851
|
+
|
|
700
852
|
// Drift anchor (Phase 3.2). Optional as a set — an older envelope simply
|
|
701
853
|
// has none — but each present field must still be a short line.
|
|
702
854
|
for (const field of ['repo_identity', 'branch', 'head', 'status_summary', 'last_verify'] as const) {
|