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.
Files changed (80) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README-en.md +12 -9
  3. package/README.md +15 -4
  4. package/config/eslint/.peaks-rules.cjs +39 -26
  5. package/dist/cli/commands/_register.js +2 -1
  6. package/dist/cli/commands/audit-commands.d.ts +1 -1
  7. package/dist/cli/commands/code-gate-command.d.ts +25 -0
  8. package/dist/cli/commands/code-gate-command.js +86 -0
  9. package/dist/cli/commands/code-mode-gate-commands.js +3 -4
  10. package/dist/cli/commands/code-orchestrator-can-do.js +2 -1
  11. package/dist/cli/commands/cron-commands.js +16 -4
  12. package/dist/cli/commands/dashboard-long-run.js +8 -4
  13. package/dist/cli/commands/dispatch-from-dag.d.ts +1 -1
  14. package/dist/cli/commands/hooks-commands.js +56 -3
  15. package/dist/cli/commands/primer-command.d.ts +59 -0
  16. package/dist/cli/commands/primer-command.js +160 -0
  17. package/dist/cli/commands/qa-commands.js +1 -2
  18. package/dist/cli/commands/sediment-commands.d.ts +1 -1
  19. package/dist/cli/commands/slice-commands.js +20 -8
  20. package/dist/cli/commands/statusline-commands.d.ts +1 -0
  21. package/dist/cli/commands/statusline-commands.js +17 -3
  22. package/dist/cli/commands/tech-commands.d.ts +1 -1
  23. package/dist/cli/program.js +8 -0
  24. package/dist/services/artifacts/request-artifact-service.d.ts +2 -2
  25. package/dist/services/artifacts/request-artifact-service.js +10 -1
  26. package/dist/services/audit/enforcers/lint-reference-shape.js +18 -4
  27. package/dist/services/code/auto-compact-orchestrator.js +9 -1
  28. package/dist/services/code/batch-heartbeat-poller.js +26 -0
  29. package/dist/services/code/orchestrator-can-do.d.ts +24 -0
  30. package/dist/services/code/orchestrator-can-do.js +48 -2
  31. package/dist/services/code/post-compact-detector.d.ts +1 -1
  32. package/dist/services/compact-statusline/compact-statusline-service.js +19 -11
  33. package/dist/services/config/config-safety.d.ts +23 -0
  34. package/dist/services/config/config-safety.js +62 -0
  35. package/dist/services/config/config-service.d.ts +1 -1
  36. package/dist/services/config/config-service.js +2 -4
  37. package/dist/services/container/container-lease.js +21 -8
  38. package/dist/services/context/spillover-store.d.ts +1 -1
  39. package/dist/services/crystallization/crystallization-types.js +20 -8
  40. package/dist/services/crystallization/evidence-brief-builder.js +22 -11
  41. package/dist/services/dispatch/dispatch-record-writer.js +231 -141
  42. package/dist/services/evolution/evolution-types.js +38 -21
  43. package/dist/services/feedback/feedback-promotion-service.js +9 -1
  44. package/dist/services/hooks/pre-tool-code-gate.d.ts +46 -0
  45. package/dist/services/hooks/pre-tool-code-gate.js +91 -0
  46. package/dist/services/ide/current-model-detector.js +1 -2
  47. package/dist/services/lint/detect-eslint.js +8 -5
  48. package/dist/services/lint/eslint-runner.d.ts +1 -2
  49. package/dist/services/lint/eslint-runner.js +86 -23
  50. package/dist/services/lint/npx-resolver.d.ts +6 -0
  51. package/dist/services/lint/npx-resolver.js +47 -0
  52. package/dist/services/loop/loop-release-types.js +32 -13
  53. package/dist/services/loop/spec-service.d.ts +7 -1
  54. package/dist/services/loop/spec-service.js +218 -182
  55. package/dist/services/rd/rd-service.js +22 -9
  56. package/dist/services/scan/archetype-service.js +21 -9
  57. package/dist/services/session/binding-store.js +1 -2
  58. package/dist/services/share/bundle-reader.d.ts +6 -9
  59. package/dist/services/share/bundle-reader.js +297 -223
  60. package/dist/services/skills/hooks-settings-service.d.ts +33 -0
  61. package/dist/services/skills/hooks-settings-service.js +48 -3
  62. package/dist/services/skills/{outer-cache-hook-constants.d.ts → session-start-hook-constants.d.ts} +20 -0
  63. package/dist/services/skills/{outer-cache-hook-constants.js → session-start-hook-constants.js} +20 -0
  64. package/dist/services/skills/skill-statusline-renderer.d.ts +50 -1
  65. package/dist/services/skills/skill-statusline-renderer.js +96 -8
  66. package/dist/services/skills/skill-statusline-service.d.ts +41 -0
  67. package/dist/services/skills/skill-statusline-service.js +56 -6
  68. package/dist/services/slice/calibration-store.js +21 -8
  69. package/dist/services/slice/slice-check-service.js +35 -14
  70. package/dist/services/slice/slice-decompose-service.js +459 -223
  71. package/dist/services/standards/project-context.js +135 -73
  72. package/dist/services/verdict/envelopes.d.ts +1 -2
  73. package/dist/services/verdict/envelopes.js +1 -2
  74. package/dist/services/verdict/verdict-aggregator.d.ts +1 -2
  75. package/dist/services/vm/vm-lease.js +19 -8
  76. package/dist/services/worktree/worktree-lease.d.ts +0 -10
  77. package/dist/services/worktree/worktree-lease.js +19 -8
  78. package/dist/shared/fs-utils.d.ts +1 -1
  79. package/package.json +7 -4
  80. 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
- const MAX_PROMPT_BYTES = 256 * 1024;
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 * 24 * 60 * 60 * 1000;
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 > 200) {
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 (async () => {
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
- try {
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
- batchId,
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(256);
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(200),
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(10, "before_score must be <= 10"),
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(10, "after_score must be <= 10"),
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(200),
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(2000)).default([]),
136
- source_traces: z.array(z.string().min(1).max(256)).default([]),
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(128)
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(200))
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(10, "evaluator score must be <= 10"),
175
- riskTags: z.array(z.string().min(1).max(200)).default([]),
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(8000),
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(2000)).default([]),
188
- overfitRisks: z.array(z.string().min(1).max(2000)).default([]),
189
- safetyRegressionRisks: z.array(z.string().min(1).max(2000)).default([]),
190
- blocker: z.string().trim().min(1).max(2000).optional(),
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(128)
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(200),
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(200),
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(512)
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(512)
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, 5).join('\n')}\n\n## Enforcement\n\nAdd a check to sops/<name>.md and reference from .claude/rules/.`,
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
  }