peaks-loop 4.0.16 → 4.0.18
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/CHANGELOG.md +55 -0
- package/README-en.md +12 -9
- package/README.md +15 -4
- package/config/eslint/.peaks-rules.cjs +39 -26
- package/dist/cli/commands/_register.js +2 -1
- package/dist/cli/commands/audit-commands.d.ts +1 -1
- package/dist/cli/commands/code-gate-command.d.ts +25 -0
- package/dist/cli/commands/code-gate-command.js +86 -0
- package/dist/cli/commands/code-mode-gate-commands.js +3 -4
- package/dist/cli/commands/code-orchestrator-can-do.js +2 -1
- package/dist/cli/commands/cron-commands.js +16 -4
- package/dist/cli/commands/dashboard-long-run.js +8 -4
- package/dist/cli/commands/dispatch-from-dag.d.ts +1 -1
- package/dist/cli/commands/hooks-commands.js +56 -3
- package/dist/cli/commands/primer-command.d.ts +59 -0
- package/dist/cli/commands/primer-command.js +160 -0
- package/dist/cli/commands/qa-commands.js +1 -2
- package/dist/cli/commands/sediment-commands.d.ts +1 -1
- package/dist/cli/commands/slice-commands.js +20 -8
- package/dist/cli/commands/statusline-commands.d.ts +1 -0
- package/dist/cli/commands/statusline-commands.js +17 -3
- package/dist/cli/commands/tech-commands.d.ts +1 -1
- package/dist/cli/program.js +8 -0
- package/dist/services/artifacts/request-artifact-service.d.ts +2 -2
- package/dist/services/artifacts/request-artifact-service.js +10 -1
- package/dist/services/audit/enforcers/lint-reference-shape.js +18 -4
- package/dist/services/code/auto-compact-orchestrator.js +9 -1
- package/dist/services/code/batch-heartbeat-poller.js +26 -0
- package/dist/services/code/orchestrator-can-do.d.ts +24 -0
- package/dist/services/code/orchestrator-can-do.js +48 -2
- package/dist/services/code/post-compact-detector.d.ts +1 -1
- package/dist/services/compact-statusline/compact-statusline-service.js +19 -11
- package/dist/services/config/config-safety.d.ts +23 -0
- package/dist/services/config/config-safety.js +62 -0
- package/dist/services/config/config-service.d.ts +1 -1
- package/dist/services/config/config-service.js +2 -4
- package/dist/services/container/container-lease.js +21 -8
- package/dist/services/context/spillover-store.d.ts +1 -1
- package/dist/services/crystallization/crystallization-types.js +20 -8
- package/dist/services/crystallization/evidence-brief-builder.js +22 -11
- package/dist/services/dispatch/dispatch-record-writer.js +231 -141
- package/dist/services/evolution/evolution-types.js +38 -21
- package/dist/services/feedback/feedback-promotion-service.js +9 -1
- package/dist/services/hooks/pre-tool-code-gate.d.ts +46 -0
- package/dist/services/hooks/pre-tool-code-gate.js +91 -0
- package/dist/services/ide/current-model-detector.js +1 -2
- package/dist/services/lint/detect-eslint.js +8 -5
- package/dist/services/lint/eslint-runner.d.ts +1 -2
- package/dist/services/lint/eslint-runner.js +86 -23
- package/dist/services/lint/npx-resolver.d.ts +6 -0
- package/dist/services/lint/npx-resolver.js +47 -0
- package/dist/services/loop/loop-release-types.js +32 -13
- package/dist/services/loop/spec-service.d.ts +7 -1
- package/dist/services/loop/spec-service.js +218 -182
- package/dist/services/rd/rd-service.js +22 -9
- package/dist/services/scan/archetype-service.js +21 -9
- package/dist/services/session/binding-store.js +1 -2
- package/dist/services/share/bundle-reader.d.ts +6 -9
- package/dist/services/share/bundle-reader.js +297 -223
- package/dist/services/skills/hooks-settings-service.d.ts +33 -0
- package/dist/services/skills/hooks-settings-service.js +48 -3
- package/dist/services/skills/{outer-cache-hook-constants.d.ts → session-start-hook-constants.d.ts} +20 -0
- package/dist/services/skills/{outer-cache-hook-constants.js → session-start-hook-constants.js} +20 -0
- package/dist/services/skills/skill-statusline-renderer.d.ts +50 -1
- package/dist/services/skills/skill-statusline-renderer.js +96 -8
- package/dist/services/skills/skill-statusline-service.d.ts +41 -0
- package/dist/services/skills/skill-statusline-service.js +56 -6
- package/dist/services/slice/calibration-store.js +21 -8
- package/dist/services/slice/slice-check-service.js +35 -14
- package/dist/services/slice/slice-decompose-service.js +459 -223
- package/dist/services/standards/project-context.js +135 -73
- package/dist/services/verdict/envelopes.d.ts +1 -2
- package/dist/services/verdict/envelopes.js +1 -2
- package/dist/services/verdict/verdict-aggregator.d.ts +1 -2
- package/dist/services/vm/vm-lease.js +19 -8
- package/dist/services/worktree/worktree-lease.d.ts +0 -10
- package/dist/services/worktree/worktree-lease.js +19 -8
- package/dist/shared/fs-utils.d.ts +1 -1
- package/package.json +7 -4
- package/skills/peaks-code/SKILL.md +46 -0
|
@@ -30,7 +30,21 @@ import { assertSafeDispatchRecordPath, dispatchRecordPath } from '../security/sa
|
|
|
30
30
|
import { withFileLockSync } from 'peaks-loop-shared-channel';
|
|
31
31
|
import { isStageLabel } from './stage-enum.js';
|
|
32
32
|
import { emitLeaseEvent } from '../observability/observability-service.js';
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* PRD-002b slice 2 — extract dispatch-record size budgets (max-prompt
|
|
35
|
+
* bytes, note truncation cap) + time-math primitives so the
|
|
36
|
+
* no-magic-numbers rule stops flagging the writer pipeline.
|
|
37
|
+
*/
|
|
38
|
+
const BYTES_PER_KB = 1024;
|
|
39
|
+
const MAX_PROMPT_KB = 256;
|
|
40
|
+
const MAX_PROMPT_BYTES = MAX_PROMPT_KB * BYTES_PER_KB;
|
|
41
|
+
const NOTE_MAX_CHARS = 200;
|
|
42
|
+
const MS_PER_SECOND = 1_000;
|
|
43
|
+
const SECONDS_PER_MINUTE = 60;
|
|
44
|
+
const MINUTES_PER_HOUR = 60;
|
|
45
|
+
const HOURS_PER_DAY = 24;
|
|
46
|
+
const MS_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
47
|
+
const REDACTION_MAX_SCAN_DEPTH = 20;
|
|
34
48
|
/** Write a new dispatch record (G2 + G5 + G6). Returns the absolute path. */
|
|
35
49
|
export function writeInitialDispatchRecord(input) {
|
|
36
50
|
const { projectRoot, sessionId, requestId, role, prompt, toolCall, batchId } = input;
|
|
@@ -43,7 +57,35 @@ export function writeInitialDispatchRecord(input) {
|
|
|
43
57
|
}
|
|
44
58
|
const path = dispatchRecordPath(projectRoot, sessionId, requestId, now());
|
|
45
59
|
const safePath = assertSafeDispatchRecordPath(path, projectRoot);
|
|
46
|
-
const record =
|
|
60
|
+
const record = buildInitialDispatchRecord(input, now);
|
|
61
|
+
writeAtomic(safePath, record);
|
|
62
|
+
// Slice 2026-06-23-audit-4th #A4: register the path in the
|
|
63
|
+
// session's active-dispatches index so a future restart can
|
|
64
|
+
// discover in-flight records without scanning the directory.
|
|
65
|
+
// The index is best-effort (no lock): the on-disk record is the
|
|
66
|
+
// source of truth; the index is purely a hint for the LLM-side
|
|
67
|
+
// runner. A crash between writeAtomic and the index write is
|
|
68
|
+
// non-fatal — the next restart scans the directory anyway.
|
|
69
|
+
registerActiveDispatch({
|
|
70
|
+
projectRoot,
|
|
71
|
+
sessionId,
|
|
72
|
+
recordPath: safePath,
|
|
73
|
+
requestId,
|
|
74
|
+
role,
|
|
75
|
+
batchId,
|
|
76
|
+
now
|
|
77
|
+
});
|
|
78
|
+
return { path: safePath, record };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Construct the initial `DispatchRecord` from a `WriteInitialDispatchInput`.
|
|
82
|
+
* PRD-002b slice 6: extracted from `writeInitialDispatchRecord` so the
|
|
83
|
+
* writer stays under the `max-lines-per-function: 50` ESLint ceiling.
|
|
84
|
+
* Behavior is byte-identical to the previous inline literal.
|
|
85
|
+
*/
|
|
86
|
+
function buildInitialDispatchRecord(input, now) {
|
|
87
|
+
const { role, requestId, sessionId, prompt, toolCall, batchId } = input;
|
|
88
|
+
return {
|
|
47
89
|
version: '4.0.0',
|
|
48
90
|
createdAt: now().toISOString(),
|
|
49
91
|
completedAt: null,
|
|
@@ -100,24 +142,6 @@ export function writeInitialDispatchRecord(input) {
|
|
|
100
142
|
graphNodeId: typeof input.graphNodeId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(input.graphNodeId) ? input.graphNodeId : null,
|
|
101
143
|
graphRef: typeof input.graphRef === 'string' && input.graphRef.length > 0 ? input.graphRef : null,
|
|
102
144
|
};
|
|
103
|
-
writeAtomic(safePath, record);
|
|
104
|
-
// Slice 2026-06-23-audit-4th #A4: register the path in the
|
|
105
|
-
// session's active-dispatches index so a future restart can
|
|
106
|
-
// discover in-flight records without scanning the directory.
|
|
107
|
-
// The index is best-effort (no lock): the on-disk record is the
|
|
108
|
-
// source of truth; the index is purely a hint for the LLM-side
|
|
109
|
-
// runner. A crash between writeAtomic and the index write is
|
|
110
|
-
// non-fatal — the next restart scans the directory anyway.
|
|
111
|
-
registerActiveDispatch({
|
|
112
|
-
projectRoot,
|
|
113
|
-
sessionId,
|
|
114
|
-
recordPath: safePath,
|
|
115
|
-
requestId,
|
|
116
|
-
role,
|
|
117
|
-
batchId,
|
|
118
|
-
now
|
|
119
|
-
});
|
|
120
|
-
return { path: safePath, record };
|
|
121
145
|
}
|
|
122
146
|
function activeDispatchIndexPath(projectRoot, sessionId) {
|
|
123
147
|
return resolve(projectRoot, '.peaks', '_sub_agents', sessionId, 'active-dispatches.json');
|
|
@@ -238,7 +262,7 @@ export function isOrphanDispatchRecord(opts) {
|
|
|
238
262
|
const s = statSync(opts.recordPath);
|
|
239
263
|
const now = opts.now ?? new Date();
|
|
240
264
|
const ageMs = now.getTime() - s.mtimeMs;
|
|
241
|
-
const ttlMs = DISPATCH_RECORD_TTL_DAYS *
|
|
265
|
+
const ttlMs = DISPATCH_RECORD_TTL_DAYS * MS_PER_DAY;
|
|
242
266
|
return ageMs > ttlMs;
|
|
243
267
|
}
|
|
244
268
|
/** Append a heartbeat (G6). Idempotent on (at, status) — append-only. */
|
|
@@ -250,7 +274,7 @@ export function appendHeartbeat(input) {
|
|
|
250
274
|
err.code = 'INVALID_PROGRESS';
|
|
251
275
|
throw err;
|
|
252
276
|
}
|
|
253
|
-
if (note !== undefined && note.length >
|
|
277
|
+
if (note !== undefined && note.length > NOTE_MAX_CHARS) {
|
|
254
278
|
const err = new Error(`note must be ≤ 200 chars (got ${note.length})`);
|
|
255
279
|
err.code = 'NOTE_TOO_LONG';
|
|
256
280
|
throw err;
|
|
@@ -345,68 +369,7 @@ export function tryAutoReleaseLease(args) {
|
|
|
345
369
|
// `node:child_process` is loaded via dynamic import so this module
|
|
346
370
|
// remains ESM-compatible (the compiled heartbeat CLI throws
|
|
347
371
|
// `require is not defined` if we use `require` here).
|
|
348
|
-
void (
|
|
349
|
-
let spawned = false;
|
|
350
|
-
try {
|
|
351
|
-
const cp = await import('node:child_process');
|
|
352
|
-
const child = cp.spawn(process.execPath, [
|
|
353
|
-
process.argv[1] ?? '',
|
|
354
|
-
'worktree', 'release',
|
|
355
|
-
'--lease-id', args.leaseId,
|
|
356
|
-
'--project', args.projectRoot,
|
|
357
|
-
'--session', args.sessionId,
|
|
358
|
-
'--json'
|
|
359
|
-
],
|
|
360
|
-
// `pipe` rather than `ignore` so a spawn failure (e.g. ENOENT
|
|
361
|
-
// on process.argv[1] in a test) shows up on stderr instead
|
|
362
|
-
// of vanishing into the void. The release CLI is short-lived
|
|
363
|
-
// so buffering is irrelevant.
|
|
364
|
-
{ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached: true });
|
|
365
|
-
spawned = true;
|
|
366
|
-
if (process.env.PEAKS_WORKTREE_LEASE_DEBUG) {
|
|
367
|
-
child.stderr?.on('data', (d) => process.stderr.write(`[release] ${d.toString('utf8')}`));
|
|
368
|
-
child.stdout?.on('data', (d) => process.stderr.write(`[release] ${d.toString('utf8')}`));
|
|
369
|
-
child.on('error', (e) => process.stderr.write(`[release error] ${e.message}\n`));
|
|
370
|
-
child.on('exit', (code) => process.stderr.write(`[release exit] code=${code}\n`));
|
|
371
|
-
}
|
|
372
|
-
else {
|
|
373
|
-
child.stderr?.on('data', () => { });
|
|
374
|
-
child.stdout?.on('data', () => { });
|
|
375
|
-
child.on('error', () => { });
|
|
376
|
-
}
|
|
377
|
-
child.unref();
|
|
378
|
-
}
|
|
379
|
-
catch (e) {
|
|
380
|
-
// Slice 2026-07-29-worktree-l2-extended Part 4.A: surface
|
|
381
|
-
// auto-release failures to the observability stream so the
|
|
382
|
-
// dashboard can alert. The spawn-attempt itself threw (not
|
|
383
|
-
// a child-process exit-code failure — those are not
|
|
384
|
-
// catchable from the parent because the child is detached
|
|
385
|
-
// and unref'd). emitLeaseEvent is fire-and-forget; it
|
|
386
|
-
// returns a result we don't inspect.
|
|
387
|
-
emitLeaseEvent({
|
|
388
|
-
sessionId: args.sessionId,
|
|
389
|
-
projectRoot: args.projectRoot,
|
|
390
|
-
kind: 'autoRelease-failed',
|
|
391
|
-
leaseId: args.leaseId,
|
|
392
|
-
reason: e.message
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
if (spawned) {
|
|
396
|
-
// Record the success path. Idempotent with the manual
|
|
397
|
-
// `peaks worktree release` metric — the release CLI itself
|
|
398
|
-
// emits a 'release' event when it runs and lands. Two
|
|
399
|
-
// events for one logical release is acceptable; the
|
|
400
|
-
// dashboard can dedup or count both under
|
|
401
|
-
// lease.autoRelease.count.
|
|
402
|
-
emitLeaseEvent({
|
|
403
|
-
sessionId: args.sessionId,
|
|
404
|
-
projectRoot: args.projectRoot,
|
|
405
|
-
kind: 'autoRelease',
|
|
406
|
-
leaseId: args.leaseId
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
})();
|
|
372
|
+
void spawnLeaseReleaseChild(args);
|
|
410
373
|
// NB: we deliberately do NOT log per-call here — every heartbeat
|
|
411
374
|
// that reports done in a busy session would otherwise spam the
|
|
412
375
|
// log. The release CLI itself emits a structured envelope on
|
|
@@ -415,6 +378,75 @@ export function tryAutoReleaseLease(args) {
|
|
|
415
378
|
args.logger(`peaks.worktree.autoRelease leaseId=${args.leaseId} sessionId=${args.sessionId}`);
|
|
416
379
|
}
|
|
417
380
|
}
|
|
381
|
+
/**
|
|
382
|
+
* Spawn the detached `peaks worktree release` subprocess.
|
|
383
|
+
* PRD-002b slice 6: extracted from `tryAutoReleaseLease` so the
|
|
384
|
+
* public wrapper stays under the `max-lines-per-function: 50`
|
|
385
|
+
* ESLint ceiling. Behavior is byte-identical to the previous
|
|
386
|
+
* inline IIFE.
|
|
387
|
+
*/
|
|
388
|
+
async function spawnLeaseReleaseChild(args) {
|
|
389
|
+
let spawned = false;
|
|
390
|
+
try {
|
|
391
|
+
const cp = await import('node:child_process');
|
|
392
|
+
const child = cp.spawn(process.execPath, [
|
|
393
|
+
process.argv[1] ?? '',
|
|
394
|
+
'worktree', 'release',
|
|
395
|
+
'--lease-id', args.leaseId,
|
|
396
|
+
'--project', args.projectRoot,
|
|
397
|
+
'--session', args.sessionId,
|
|
398
|
+
'--json'
|
|
399
|
+
],
|
|
400
|
+
// `pipe` rather than `ignore` so a spawn failure (e.g. ENOENT
|
|
401
|
+
// on process.argv[1] in a test) shows up on stderr instead
|
|
402
|
+
// of vanishing into the void. The release CLI is short-lived
|
|
403
|
+
// so buffering is irrelevant.
|
|
404
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached: true });
|
|
405
|
+
spawned = true;
|
|
406
|
+
if (process.env.PEAKS_WORKTREE_LEASE_DEBUG) {
|
|
407
|
+
child.stderr?.on('data', (d) => process.stderr.write(`[release] ${d.toString('utf8')}`));
|
|
408
|
+
child.stdout?.on('data', (d) => process.stderr.write(`[release] ${d.toString('utf8')}`));
|
|
409
|
+
child.on('error', (e) => process.stderr.write(`[release error] ${e.message}\n`));
|
|
410
|
+
child.on('exit', (code) => process.stderr.write(`[release exit] code=${code}\n`));
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
child.stderr?.on('data', () => { });
|
|
414
|
+
child.stdout?.on('data', () => { });
|
|
415
|
+
child.on('error', () => { });
|
|
416
|
+
}
|
|
417
|
+
child.unref();
|
|
418
|
+
}
|
|
419
|
+
catch (e) {
|
|
420
|
+
// Slice 2026-07-29-worktree-l2-extended Part 4.A: surface
|
|
421
|
+
// auto-release failures to the observability stream so the
|
|
422
|
+
// dashboard can alert. The spawn-attempt itself threw (not
|
|
423
|
+
// a child-process exit-code failure — those are not
|
|
424
|
+
// catchable from the parent because the child is detached
|
|
425
|
+
// and unref'd). emitLeaseEvent is fire-and-forget; it
|
|
426
|
+
// returns a result we don't inspect.
|
|
427
|
+
emitLeaseEvent({
|
|
428
|
+
sessionId: args.sessionId,
|
|
429
|
+
projectRoot: args.projectRoot,
|
|
430
|
+
kind: 'autoRelease-failed',
|
|
431
|
+
leaseId: args.leaseId,
|
|
432
|
+
reason: e.message
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
if (spawned) {
|
|
436
|
+
// Record the success path. Idempotent with the manual
|
|
437
|
+
// `peaks worktree release` metric — the release CLI itself
|
|
438
|
+
// emits a 'release' event when it runs and lands. Two
|
|
439
|
+
// events for one logical release is acceptable; the
|
|
440
|
+
// dashboard can dedup or count both under
|
|
441
|
+
// lease.autoRelease.count.
|
|
442
|
+
emitLeaseEvent({
|
|
443
|
+
sessionId: args.sessionId,
|
|
444
|
+
projectRoot: args.projectRoot,
|
|
445
|
+
kind: 'autoRelease',
|
|
446
|
+
leaseId: args.leaseId
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
418
450
|
/** Mark a record as completed (success / failed / cancelled / no-execution). */
|
|
419
451
|
export function markCompleted(input) {
|
|
420
452
|
// Slice 2026-06-23-audit-3rd #3: lock + re-read so a concurrent
|
|
@@ -444,55 +476,7 @@ export function markCompleted(input) {
|
|
|
444
476
|
// (best-effort; the dispatch record itself is the source of truth
|
|
445
477
|
// and the transition is observable through the graph store).
|
|
446
478
|
if (result.record.workflowId !== null && result.record.graphNodeId !== null && result.record.graphRef !== null) {
|
|
447
|
-
|
|
448
|
-
// Lazy ESM dynamic import: the graph lifecycle service is not
|
|
449
|
-
// on the dispatch hot path; it would be wasteful to import it
|
|
450
|
-
// for non-graph-bound dispatches. ESM dynamic import returns
|
|
451
|
-
// a promise; we do not await (best-effort) — the dispatch
|
|
452
|
-
// record write is the load-bearing artifact, and the graph
|
|
453
|
-
// node transition is observable to the next CLI call.
|
|
454
|
-
void (async () => {
|
|
455
|
-
try {
|
|
456
|
-
const lifecycleMod = await import('../workflow/workflow-node-lifecycle.js');
|
|
457
|
-
const storeMod = await import('../workflow/workflow-graph-store.js');
|
|
458
|
-
const sessionRoot = (() => {
|
|
459
|
-
try {
|
|
460
|
-
return storeMod.graphPathFor({
|
|
461
|
-
projectRoot: input.projectRoot ?? '',
|
|
462
|
-
sessionId: result.record.sessionId,
|
|
463
|
-
graphRef: result.record.graphRef ?? '',
|
|
464
|
-
workflowId: result.record.workflowId ?? '',
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
|
-
catch {
|
|
468
|
-
return null;
|
|
469
|
-
}
|
|
470
|
-
})();
|
|
471
|
-
if (sessionRoot === null)
|
|
472
|
-
return;
|
|
473
|
-
const graph = storeMod.readGraph({
|
|
474
|
-
projectRoot: input.projectRoot ?? '',
|
|
475
|
-
sessionId: result.record.sessionId,
|
|
476
|
-
graphRef: result.record.graphRef ?? '',
|
|
477
|
-
workflowId: result.record.workflowId ?? '',
|
|
478
|
-
});
|
|
479
|
-
const node = graph.nodes.find((n) => n.id === result.record.graphNodeId);
|
|
480
|
-
if (node === undefined)
|
|
481
|
-
return;
|
|
482
|
-
// Use the dispatch record's path as the dispatchRef. The
|
|
483
|
-
// record itself is the load-bearing artifact; the
|
|
484
|
-
// graph-node transition is a derived side-effect.
|
|
485
|
-
const dispatchRef = input.recordPath;
|
|
486
|
-
lifecycleMod.writeEnvelope({
|
|
487
|
-
graphNode: node,
|
|
488
|
-
dispatchRef,
|
|
489
|
-
envelopeDispatchRef: dispatchRef,
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
catch { /* best-effort graph transition */ }
|
|
493
|
-
})();
|
|
494
|
-
}
|
|
495
|
-
catch { /* best-effort */ }
|
|
479
|
+
scheduleGraphEnvelopeTransition(input, result.record);
|
|
496
480
|
}
|
|
497
481
|
// Slice 2026-06-23-audit-4th #A4: update the active-dispatches
|
|
498
482
|
// index. Best-effort (the on-disk record is the source of truth);
|
|
@@ -533,6 +517,67 @@ export function markCompleted(input) {
|
|
|
533
517
|
}
|
|
534
518
|
return result;
|
|
535
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Schedule a detached graph-envelope transition for a completed dispatch.
|
|
522
|
+
* PRD-002b slice 6: extracted from `markCompleted` to keep that function
|
|
523
|
+
* under the `max-lines-per-function: 50` ESLint ceiling. Behavior is
|
|
524
|
+
* byte-identical to the previous inline block:
|
|
525
|
+
* - skip when graph binding fields are all non-null on the record;
|
|
526
|
+
* - dynamic ESM import the lifecycle + store modules (not on hot path);
|
|
527
|
+
* - best-effort; failures swallowed so the dispatch record write is
|
|
528
|
+
* load-bearing and the next CLI call can observe the transition.
|
|
529
|
+
*/
|
|
530
|
+
function scheduleGraphEnvelopeTransition(input, record) {
|
|
531
|
+
try {
|
|
532
|
+
// Lazy ESM dynamic import: the graph lifecycle service is not
|
|
533
|
+
// on the dispatch hot path; it would be wasteful to import it
|
|
534
|
+
// for non-graph-bound dispatches. ESM dynamic import returns
|
|
535
|
+
// a promise; we do not await (best-effort) — the dispatch
|
|
536
|
+
// record write is the load-bearing artifact, and the graph
|
|
537
|
+
// node transition is observable to the next CLI call.
|
|
538
|
+
void (async () => {
|
|
539
|
+
try {
|
|
540
|
+
const lifecycleMod = await import('../workflow/workflow-node-lifecycle.js');
|
|
541
|
+
const storeMod = await import('../workflow/workflow-graph-store.js');
|
|
542
|
+
const sessionRoot = (() => {
|
|
543
|
+
try {
|
|
544
|
+
return storeMod.graphPathFor({
|
|
545
|
+
projectRoot: input.projectRoot ?? '',
|
|
546
|
+
sessionId: record.sessionId,
|
|
547
|
+
graphRef: record.graphRef ?? '',
|
|
548
|
+
workflowId: record.workflowId ?? '',
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
})();
|
|
555
|
+
if (sessionRoot === null)
|
|
556
|
+
return;
|
|
557
|
+
const graph = storeMod.readGraph({
|
|
558
|
+
projectRoot: input.projectRoot ?? '',
|
|
559
|
+
sessionId: record.sessionId,
|
|
560
|
+
graphRef: record.graphRef ?? '',
|
|
561
|
+
workflowId: record.workflowId ?? '',
|
|
562
|
+
});
|
|
563
|
+
const node = graph.nodes.find((n) => n.id === record.graphNodeId);
|
|
564
|
+
if (node === undefined)
|
|
565
|
+
return;
|
|
566
|
+
// Use the dispatch record's path as the dispatchRef. The
|
|
567
|
+
// record itself is the load-bearing artifact; the
|
|
568
|
+
// graph-node transition is a derived side-effect.
|
|
569
|
+
const dispatchRef = input.recordPath;
|
|
570
|
+
lifecycleMod.writeEnvelope({
|
|
571
|
+
graphNode: node,
|
|
572
|
+
dispatchRef,
|
|
573
|
+
envelopeDispatchRef: dispatchRef,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
catch { /* best-effort graph transition */ }
|
|
577
|
+
})();
|
|
578
|
+
}
|
|
579
|
+
catch { /* best-effort */ }
|
|
580
|
+
}
|
|
536
581
|
/** Mark a record as disposed (reducer ran). */
|
|
537
582
|
export function markDisposed(recordPath, now = () => new Date()) {
|
|
538
583
|
// Lock + re-read (see markCompleted).
|
|
@@ -626,6 +671,42 @@ function upgradeRecord(parsed) {
|
|
|
626
671
|
throw new Error(`Dispatch record version mismatch: expected '4.0.0', '3.2', '3.1', 3, 2, or 1, got ${JSON.stringify(rawVersion)}. ` +
|
|
627
672
|
'The v1 → v4.0.0 migration is in-file; records from much older or newer builds must be regenerated.');
|
|
628
673
|
}
|
|
674
|
+
const legacy = parseUpgradeRecordLegacyFields(obj);
|
|
675
|
+
const migration = parseUpgradeRecordMigrationFields(obj);
|
|
676
|
+
return {
|
|
677
|
+
version: '4.0.0',
|
|
678
|
+
createdAt: legacy.createdAt,
|
|
679
|
+
completedAt: legacy.completedAt,
|
|
680
|
+
outcome: legacy.outcome,
|
|
681
|
+
artifactPaths: legacy.artifactPaths,
|
|
682
|
+
disposed: legacy.disposed,
|
|
683
|
+
disposedAt: legacy.disposedAt,
|
|
684
|
+
role: legacy.role,
|
|
685
|
+
requestId: legacy.requestId,
|
|
686
|
+
sessionId: legacy.sessionId,
|
|
687
|
+
prompt: legacy.prompt,
|
|
688
|
+
toolCall: legacy.toolCall,
|
|
689
|
+
batchId: legacy.batchId,
|
|
690
|
+
heartbeats: legacy.heartbeats,
|
|
691
|
+
lastBeatAt: legacy.lastBeatAt,
|
|
692
|
+
status: legacy.status,
|
|
693
|
+
stage: migration.stage,
|
|
694
|
+
leaseId: migration.leaseId,
|
|
695
|
+
isolationStartedAt: migration.isolationStartedAt,
|
|
696
|
+
serviceKill: migration.serviceKill,
|
|
697
|
+
mergeBackAttempts: migration.mergeBackAttempts,
|
|
698
|
+
workflowId: migration.workflowId,
|
|
699
|
+
graphNodeId: migration.graphNodeId,
|
|
700
|
+
graphRef: migration.graphRef
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Parse the v1..v3.2 core fields of a legacy dispatch record.
|
|
705
|
+
* PRD-002b slice 6: extracted from `upgradeRecord` so the reader
|
|
706
|
+
* stays under the `max-lines-per-function: 50` ESLint ceiling.
|
|
707
|
+
* Behavior is byte-identical to the previous inline block.
|
|
708
|
+
*/
|
|
709
|
+
function parseUpgradeRecordLegacyFields(obj) {
|
|
629
710
|
const role = stringField(obj, 'role');
|
|
630
711
|
const requestId = stringField(obj, 'requestId');
|
|
631
712
|
const sessionId = stringField(obj, 'sessionId');
|
|
@@ -666,22 +747,31 @@ function upgradeRecord(parsed) {
|
|
|
666
747
|
? obj.batchId
|
|
667
748
|
: 'legacy-batch';
|
|
668
749
|
return {
|
|
669
|
-
version: '4.0.0',
|
|
670
|
-
createdAt,
|
|
671
|
-
completedAt,
|
|
672
|
-
outcome,
|
|
673
|
-
artifactPaths,
|
|
674
|
-
disposed,
|
|
675
|
-
disposedAt,
|
|
676
750
|
role,
|
|
677
751
|
requestId,
|
|
678
752
|
sessionId,
|
|
679
753
|
prompt,
|
|
680
754
|
toolCall,
|
|
681
|
-
|
|
755
|
+
createdAt,
|
|
682
756
|
heartbeats,
|
|
683
757
|
lastBeatAt,
|
|
684
758
|
status,
|
|
759
|
+
completedAt,
|
|
760
|
+
outcome,
|
|
761
|
+
artifactPaths,
|
|
762
|
+
disposed,
|
|
763
|
+
disposedAt,
|
|
764
|
+
batchId
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Parse the post-v3 migration fields of a legacy dispatch record.
|
|
769
|
+
* PRD-002b slice 6: extracted from `upgradeRecord` so the reader
|
|
770
|
+
* stays under the `max-lines-per-function: 50` ESLint ceiling.
|
|
771
|
+
* Behavior is byte-identical to the previous inline block.
|
|
772
|
+
*/
|
|
773
|
+
function parseUpgradeRecordMigrationFields(obj) {
|
|
774
|
+
return {
|
|
685
775
|
// Slice 2026-07-29-dispatch-stall-governance / S5 (AC-5.1 / PB-2)
|
|
686
776
|
// — legacy records (pre-slice) had no `stage` field. The reader
|
|
687
777
|
// defaults to `null` so the watch surface can tell "no stage ever
|
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
/* ---------------------------------------------------------------------- */
|
|
3
|
+
/* PRD-002b slice 2 — schema-limit constants extracted from inline */
|
|
4
|
+
/* `.max(N)` calls so the no-magic-numbers rule stops flagging the */
|
|
5
|
+
/* constraint values. Names describe the field, not just the number. */
|
|
6
|
+
/* ---------------------------------------------------------------------- */
|
|
7
|
+
const EVO_TARGET_RELEASE_ID_MAX = 256;
|
|
8
|
+
const EVO_OPTIMIZATION_DIMENSION_MAX = 200;
|
|
9
|
+
const EVO_AUTHOR_ID_MAX = 200;
|
|
10
|
+
const EVO_RISK_TAG_MAX = 200;
|
|
11
|
+
const EVO_RED_LINE_MAX = 2000;
|
|
12
|
+
const EVO_SOURCE_TRACE_MAX = 256;
|
|
13
|
+
const EVO_EVAL_ID_MAX = 128;
|
|
14
|
+
const EVO_DIMENSION_ITEM_MAX = 200;
|
|
15
|
+
const EVO_SCORING_SCALE_MAX = 10;
|
|
16
|
+
const EVO_REFUTE_PARAGRAPH_MAX = 8000;
|
|
17
|
+
const EVO_SKEPTIC_RISK_MAX = 2000;
|
|
18
|
+
const EVO_POINTER_MAX = 512;
|
|
2
19
|
/**
|
|
3
20
|
* EvolutionEvaluation — spec §4.4 / §6.
|
|
4
21
|
*
|
|
@@ -62,7 +79,7 @@ export const EVOLUTION_VERDICTS = [
|
|
|
62
79
|
* target_kind; we keep this as a non-empty string and let the
|
|
63
80
|
* service layer validate per target_kind.
|
|
64
81
|
*/
|
|
65
|
-
const TargetReleaseIdSchema = z.string().min(1).max(
|
|
82
|
+
const TargetReleaseIdSchema = z.string().min(1).max(EVO_TARGET_RELEASE_ID_MAX);
|
|
66
83
|
/**
|
|
67
84
|
* EvolutionProposal — the create payload.
|
|
68
85
|
*
|
|
@@ -86,7 +103,7 @@ export const EvolutionProposalInputSchema = z.object({
|
|
|
86
103
|
.string()
|
|
87
104
|
.trim()
|
|
88
105
|
.min(1, "optimization_dimension is required (single dimension per round)")
|
|
89
|
-
.max(
|
|
106
|
+
.max(EVO_OPTIMIZATION_DIMENSION_MAX),
|
|
90
107
|
before_snapshot: z.record(z.unknown()).default({}),
|
|
91
108
|
after_snapshot: z.record(z.unknown()).default({}),
|
|
92
109
|
diff: z.record(z.unknown()).default({}),
|
|
@@ -94,12 +111,12 @@ export const EvolutionProposalInputSchema = z.object({
|
|
|
94
111
|
.number()
|
|
95
112
|
.finite("before_score must be a finite number")
|
|
96
113
|
.min(0, "before_score must be >= 0")
|
|
97
|
-
.max(
|
|
114
|
+
.max(EVO_SCORING_SCALE_MAX, "before_score must be <= 10"),
|
|
98
115
|
after_score: z
|
|
99
116
|
.number()
|
|
100
117
|
.finite("after_score must be a finite number")
|
|
101
118
|
.min(0, "after_score must be >= 0")
|
|
102
|
-
.max(
|
|
119
|
+
.max(EVO_SCORING_SCALE_MAX, "after_score must be <= 10"),
|
|
103
120
|
score_delta_min: z
|
|
104
121
|
.number()
|
|
105
122
|
.finite("score_delta_min must be a finite number")
|
|
@@ -109,7 +126,7 @@ export const EvolutionProposalInputSchema = z.object({
|
|
|
109
126
|
.string()
|
|
110
127
|
.trim()
|
|
111
128
|
.min(1, "author_id is required")
|
|
112
|
-
.max(
|
|
129
|
+
.max(EVO_AUTHOR_ID_MAX),
|
|
113
130
|
/**
|
|
114
131
|
* The LLM-side marker that this proposal targets a SINGLE object
|
|
115
132
|
* (AC-8). Multi-object proposals must be split into multiple
|
|
@@ -132,8 +149,8 @@ export const EvolutionProposalInputSchema = z.object({
|
|
|
132
149
|
}),
|
|
133
150
|
}),
|
|
134
151
|
rubric: z.record(z.unknown()).default({}),
|
|
135
|
-
red_lines: z.array(z.string().min(1).max(
|
|
136
|
-
source_traces: z.array(z.string().min(1).max(
|
|
152
|
+
red_lines: z.array(z.string().min(1).max(EVO_RED_LINE_MAX)).default([]),
|
|
153
|
+
source_traces: z.array(z.string().min(1).max(EVO_SOURCE_TRACE_MAX)).default([]),
|
|
137
154
|
});
|
|
138
155
|
/**
|
|
139
156
|
* The PERSISTED proposal shape — adds `dimensions` (a length-1
|
|
@@ -145,12 +162,12 @@ export const EvolutionProposalSchema = EvolutionProposalInputSchema.extend({
|
|
|
145
162
|
id: z
|
|
146
163
|
.string()
|
|
147
164
|
.min(1)
|
|
148
|
-
.max(
|
|
165
|
+
.max(EVO_EVAL_ID_MAX)
|
|
149
166
|
.regex(/^eval-[0-9a-f-]{8,}$/, {
|
|
150
167
|
message: "id must start with 'eval-' followed by a hex/UUID-ish suffix",
|
|
151
168
|
}),
|
|
152
169
|
dimensions: z
|
|
153
|
-
.array(z.string().min(1).max(
|
|
170
|
+
.array(z.string().min(1).max(EVO_DIMENSION_ITEM_MAX))
|
|
154
171
|
.length(1, "dimensions must be exactly length 1 (AC-8)"),
|
|
155
172
|
target_count: z.literal(1),
|
|
156
173
|
schema_version: z
|
|
@@ -171,23 +188,23 @@ export const IndependentEvaluatorResultSchema = z.object({
|
|
|
171
188
|
.number()
|
|
172
189
|
.finite()
|
|
173
190
|
.min(0, "evaluator score must be >= 0")
|
|
174
|
-
.max(
|
|
175
|
-
riskTags: z.array(z.string().min(1).max(
|
|
191
|
+
.max(EVO_SCORING_SCALE_MAX, "evaluator score must be <= 10"),
|
|
192
|
+
riskTags: z.array(z.string().min(1).max(EVO_RISK_TAG_MAX)).default([]),
|
|
176
193
|
refuteParagraph: z
|
|
177
194
|
.string()
|
|
178
195
|
.trim()
|
|
179
196
|
.min(1, "refuteParagraph is required (one paragraph of independent-context rebuttal)")
|
|
180
|
-
.max(
|
|
197
|
+
.max(EVO_REFUTE_PARAGRAPH_MAX),
|
|
181
198
|
});
|
|
182
199
|
/**
|
|
183
200
|
* The regression skeptic's verdict. AC-14: a separate sub-agent
|
|
184
201
|
* that attempts to refute the proposal.
|
|
185
202
|
*/
|
|
186
203
|
export const RegressionSkepticResultSchema = z.object({
|
|
187
|
-
driftRisks: z.array(z.string().min(1).max(
|
|
188
|
-
overfitRisks: z.array(z.string().min(1).max(
|
|
189
|
-
safetyRegressionRisks: z.array(z.string().min(1).max(
|
|
190
|
-
blocker: z.string().trim().min(1).max(
|
|
204
|
+
driftRisks: z.array(z.string().min(1).max(EVO_SKEPTIC_RISK_MAX)).default([]),
|
|
205
|
+
overfitRisks: z.array(z.string().min(1).max(EVO_SKEPTIC_RISK_MAX)).default([]),
|
|
206
|
+
safetyRegressionRisks: z.array(z.string().min(1).max(EVO_SKEPTIC_RISK_MAX)).default([]),
|
|
207
|
+
blocker: z.string().trim().min(1).max(EVO_SKEPTIC_RISK_MAX).optional(),
|
|
191
208
|
});
|
|
192
209
|
/**
|
|
193
210
|
* The full evolution evaluation row as persisted. Differs from the
|
|
@@ -207,7 +224,7 @@ export const EvolutionEvaluationInputSchema = z.object({
|
|
|
207
224
|
id: z
|
|
208
225
|
.string()
|
|
209
226
|
.min(1)
|
|
210
|
-
.max(
|
|
227
|
+
.max(EVO_EVAL_ID_MAX)
|
|
211
228
|
.regex(/^eval-[0-9a-f-]{8,}$/, {
|
|
212
229
|
message: "id must start with 'eval-' followed by a hex/UUID-ish suffix",
|
|
213
230
|
}),
|
|
@@ -216,12 +233,12 @@ export const EvolutionEvaluationInputSchema = z.object({
|
|
|
216
233
|
.string()
|
|
217
234
|
.trim()
|
|
218
235
|
.min(1, "evaluator_id is required (independent scorer; AC-12)")
|
|
219
|
-
.max(
|
|
236
|
+
.max(EVO_AUTHOR_ID_MAX),
|
|
220
237
|
skeptic_id: z
|
|
221
238
|
.string()
|
|
222
239
|
.trim()
|
|
223
240
|
.min(1, "skeptic_id is required (regression skeptic; AC-14)")
|
|
224
|
-
.max(
|
|
241
|
+
.max(EVO_AUTHOR_ID_MAX),
|
|
225
242
|
evaluator_result: IndependentEvaluatorResultSchema,
|
|
226
243
|
skeptic_result: RegressionSkepticResultSchema,
|
|
227
244
|
verdict: EvolutionVerdictSchema,
|
|
@@ -229,13 +246,13 @@ export const EvolutionEvaluationInputSchema = z.object({
|
|
|
229
246
|
.string()
|
|
230
247
|
.trim()
|
|
231
248
|
.min(1, "user_confirmation_pointer is required (AC-15)")
|
|
232
|
-
.max(
|
|
249
|
+
.max(EVO_POINTER_MAX)
|
|
233
250
|
.optional(),
|
|
234
251
|
brief_pointer: z
|
|
235
252
|
.string()
|
|
236
253
|
.trim()
|
|
237
254
|
.min(1, "brief_pointer is required (spec §4.4)")
|
|
238
|
-
.max(
|
|
255
|
+
.max(EVO_POINTER_MAX)
|
|
239
256
|
.optional(),
|
|
240
257
|
schema_version: z
|
|
241
258
|
.literal("peaks.evolution/1")
|
|
@@ -27,6 +27,14 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
29
29
|
import { dirname, join, resolve } from 'node:path';
|
|
30
|
+
/**
|
|
31
|
+
* PRD-002b slice 2 — extract magic numbers used by the promotion
|
|
32
|
+
* helper. `slice(0, 5)` was used as a heuristic on the user's body
|
|
33
|
+
* to keep the rule stub short; `slice(0, 2000)` caps error-mirror
|
|
34
|
+
* stderr payload size to avoid unbounded log growth.
|
|
35
|
+
*/
|
|
36
|
+
const RULE_BODY_PREVIEW_LINES = 5;
|
|
37
|
+
const STDERR_TRUNCATE_BYTES = 2000;
|
|
30
38
|
export const PROMOTION_LAYERS = ['A', 'B', 'C'];
|
|
31
39
|
export const PROMOTION_LAYER_DETAILS = [
|
|
32
40
|
{ layer: 'A', label: 'peaks-sop gate', description: 'Append to sops/*.md and reference from a peaks-sop check. Procedural rules.' },
|
|
@@ -211,7 +219,7 @@ export function generatePromotionStub(opts) {
|
|
|
211
219
|
const { layer, feedbackName } = opts;
|
|
212
220
|
if (layer === 'A') {
|
|
213
221
|
return {
|
|
214
|
-
snippet: `# SOP entry for feedback "${feedbackName}"\n\n<!-- Append the rule + acceptance criteria below. Reference from a new peaks-sop gate. -->\n\n## Rule\n\n${opts.feedbackBody.split('\n').slice(0,
|
|
222
|
+
snippet: `# SOP entry for feedback "${feedbackName}"\n\n<!-- Append the rule + acceptance criteria below. Reference from a new peaks-sop gate. -->\n\n## Rule\n\n${opts.feedbackBody.split('\n').slice(0, RULE_BODY_PREVIEW_LINES).join('\n')}\n\n## Enforcement\n\nAdd a check to sops/<name>.md and reference from .claude/rules/.`,
|
|
215
223
|
targetFiles: [`sops/${feedbackName}.md`]
|
|
216
224
|
};
|
|
217
225
|
}
|