peaks-loop 3.1.1 → 3.1.2

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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.1.2 — 2026-07-04
4
+
5
+ ### Added — Mechanical Job-mode gates (Step 0.8 enforcement)
6
+
7
+ The v3.1.1 recorder-only design was bypassed twice under load (2026-07-03 35-slice incident, 2026-07-04 3018-file incident): the LLM skipped `peaks solo detect-job` and ran fake-completion narratives instead of Job mode. The v3.1.2 patch converts Step 0.8 from "LLM should call" to "LLM cannot proceed without calling" through four mechanical gates — no LLM interpretation required.
8
+
9
+ - **PreToolUse hook** — `peaks workspace init` now installs a `Bash` matcher in `.claude/settings.local.json` (TEMPLATE_VERSION bumped 1.2.0 → 1.3.0) that runs `peaks solo gate-step-08 --project .` before every Bash call. Exit 0 = allow, exit 2 = block. Existing `Write|Edit|MultiEdit` matcher preserved. Re-running `peaks workspace init` is idempotent (`templateContentMatches`).
10
+ - **`peaks solo gate-step-08`** — new service `src/services/solo/step-08-gate.ts` (~120 LOC) + CLI. Reads `.peaks/_runtime/<sessionId>/job-shape.json`. When `decision.isJob=true` AND `job/<jid>/progress.json` exists, prints `Next: slice #<done+1> of <total> (<currentSlice>)` so the LLM cannot wake up cold. When the file is missing AND the user's prompt (from `--prompt` / `last-prompt.txt`) matches the fail-closed backup regex `/(until|全部|until all done|disavow cost|不用考虑费用|all of them)/i`, exits 2 with `BLOCKED:` on stderr.
11
+ - **Size-fear ban** — `peaks solo emit-handoff`. New service `src/services/solo/emit-handoff.ts` (~70 LOC) + CLI. Under Job mode (`isJob=true`), refuses to emit a final handoff while `remaining > 0`. Codes: `JOB_NOT_INITIALIZED` (no state.json), `JOB_REMAINING_BLOCKED` (remaining > 0), allow when `remaining === 0`, allow under `--force-under-job` override.
12
+ - **Forced auto-compact** — extended `peaks solo context-now` with `--enforce-job-mode` (auto-enabled when `job-shape.json` says `isJob=true`). At `ratio >= 0.85` returns `action: 'auto-compact-now'` (MANDATORY, not advisory); at `ratio >= 0.95` returns `action: 'red-line'`. SKILL.md Step N+2 prose updated: "in Job mode, ≥ 0.85 is MANDATORY auto-compact."
13
+ - **On-disk slice progress** — `peaks job checkpoint --state done` now writes `.peaks/_runtime/<sessionId>/job/<jid>/progress.json` after updating state.json. New `peaks job progress --job-id <jid>` reader. `gate-step-08` reads the same file in its allow-job path so the LLM gets resume context BEFORE any Bash call. SKILL.md Step 0.7 updated: "if progress.json exists, read it FIRST and surface `Next: slice #N of M (<currentSlice>)`."
14
+
15
+ ### Tests (v3.1.2)
16
+
17
+ - `tests/unit/solo/gate-step-08.test.ts` — 9 unit tests covering the 4 paths + Next: slice context injection + backup-regex sanity + prompt-source fallback.
18
+ - `tests/unit/solo/emit-handoff.test.ts` — 10 unit tests covering the 4 paths + `--force-under-job` override + `JOB_NOT_INITIALIZED` + skipped-slices edge case + `--job-id` override.
19
+ - `tests/integration/solo-gate-step-08-hook.test.ts` — 5 integration tests spawning `node bin/peaks.js solo gate-step-08` as a real child process (the hook protocol itself), asserting exit 0 / exit 2 + stderr BLOCKED line + JSON envelope shape.
20
+ - Extended `tests/unit/solo/solo-step-08-block-guard.test.ts` to assert SKILL.md + runbook.md reference `peaks solo gate-step-08`, `peaks solo emit-handoff`, `peaks job progress`, and the Job-mode MANDATORY auto-compact prose.
21
+ - Extended `tests/unit/workspace/workspace-init-claude-hooks.test.ts` case-A to assert both PreToolUse matchers (`Write|Edit|MultiEdit` AND `Bash`) are emitted with the new Bash command invoking `peaks solo gate-step-08`.
22
+
23
+ ### Memory
24
+
25
+ Two consecutive ship-day incidents motivated this release: `2026-07-03-v3-1-0-job-trigger-miss.md` (35-slice app/ batch) + `2026-07-04-v3-1-1-second-incident-3018-files.md` (3018-file UT batch). Lesson: "a skill that says MUST without a mechanical gate is not a real gate." The hook is the difference between "the LLM is told to do X" and "the LLM cannot proceed without X having happened."
26
+
3
27
  ## 3.1.1 — 2026-07-03
4
28
 
5
29
  ### Added — Step 0.8 detector-as-recorder (LLM-judged, CLI-validated)
@@ -5,6 +5,7 @@ import { fail, ok } from '../../shared/result.js';
5
5
  import { addJsonOption, printResult } from '../cli-helpers.js';
6
6
  import { JobStateStore } from '../../services/job/job-state-store.js';
7
7
  import { JobOrchestrator } from '../../services/job/job-orchestrator.js';
8
+ import { writeJobProgress, readJobProgress, tryReadJobProgress } from '../../services/job/job-progress-store.js';
8
9
  import { JobRotation } from '../../services/job/job-rotation.js';
9
10
  import { SubAgentJobWrapper } from '../../services/job/subagent-job-wrapper.js';
10
11
  import { emitJobEvent } from '../../services/job/job-event-emitter.js';
@@ -166,10 +167,25 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
166
167
  });
167
168
  if (!parsed.success)
168
169
  return printResult(io, fail('checkpoint', 'INVALID_CHECKPOINT', parsed.error.message, {}), opts);
169
- const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
170
+ const jobRoot = resolveJobStateRoot(opts);
171
+ const store = new JobStateStore(jobRoot.rootDir);
170
172
  const orch = new JobOrchestrator(store);
171
173
  if (parsed.data.state === 'done') {
172
174
  await orch.checkpointDone({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, ...(parsed.data.commitSha ? { commitSha: parsed.data.commitSha } : {}) });
175
+ // v3.1.2: after each --state done, mirror slice progress to
176
+ // .peaks/_runtime/<sessionId>/job/<jid>/progress.json so the
177
+ // next LLM turn (or peaks solo gate-step-08 hook) can read it.
178
+ const project = projectRoot(opts);
179
+ const sessId = jobRoot.sessionId;
180
+ const state = orch.status(parsed.data.jobId);
181
+ writeJobProgress(project, sessId, {
182
+ jobId: parsed.data.jobId,
183
+ done: state.done,
184
+ total: state.total,
185
+ currentSlice: state.currentSlice ?? `slice-${state.done + 1}`,
186
+ lastCommitSha: parsed.data.commitSha ?? null,
187
+ updatedAt: new Date().toISOString()
188
+ });
173
189
  }
174
190
  else if (parsed.data.state === 'skipped') {
175
191
  await orch.checkpointSkipped({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, reason: parsed.data.reason });
@@ -221,6 +237,43 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
221
237
  printResult(io, ok('resume', { resumed: opts.jobId, ...s }), opts);
222
238
  });
223
239
  addJsonOption(job.commands.find(c => c.name() === 'resume'));
240
+ // v3.1.2: read the on-disk slice progress mirror written by `peaks
241
+ // job checkpoint --state done`. Used by peaks solo gate-step-08 and
242
+ // by peaks-solo Step 0.7 (resume) to surface `Next: slice #N of M
243
+ // (<currentSlice>)` without re-deriving from state.json.
244
+ job
245
+ .command('progress')
246
+ .description('v3.1.2: read the on-disk slice progress mirror (.peaks/_runtime/<sid>/job/<jid>/progress.json). ' +
247
+ 'Returns { jobId, done, total, currentSlice, lastCommitSha, updatedAt }.')
248
+ .requiredOption('--job-id <jid>')
249
+ .option('--project <repo>')
250
+ .option('--allow-missing', 'return done=0/total=0 envelope instead of failing when progress.json is absent')
251
+ .action(async (opts) => {
252
+ try {
253
+ const jobRoot = resolveJobStateRoot(opts);
254
+ const sessId = jobRoot.sessionId;
255
+ const project = projectRoot(opts);
256
+ const progress = opts.allowMissing === true
257
+ ? tryReadJobProgress(project, sessId, opts.jobId)
258
+ : readJobProgress(project, sessId, opts.jobId);
259
+ if (progress === null) {
260
+ printResult(io, fail('progress', 'NO_PROGRESS', `No progress.json for job ${opts.jobId} at .peaks/_runtime/${sessId}/job/${opts.jobId}/progress.json`, { jobId: opts.jobId, sessionId: sessId }, [
261
+ 'Run `peaks job checkpoint --state done ...` at least once to seed progress.json.',
262
+ 'Or pass --allow-missing to return a zero-progress envelope.'
263
+ ]), opts.json);
264
+ process.exitCode = 1;
265
+ return;
266
+ }
267
+ printResult(io, ok('progress', progress, [], [
268
+ `Next: slice #${progress.done + 1} of ${progress.total} (${progress.currentSlice})`
269
+ ]), opts.json);
270
+ }
271
+ catch (err) {
272
+ printResult(io, fail('progress', 'PROGRESS_READ_FAILED', err instanceof Error ? err.message : String(err), { jobId: opts.jobId }, ['Verify the job id and try again']), opts.json);
273
+ process.exitCode = 1;
274
+ }
275
+ });
276
+ addJsonOption(job.commands.find(c => c.name() === 'progress'));
224
277
  job
225
278
  .command('handoff')
226
279
  .requiredOption('--job-id <jid>')
@@ -26,6 +26,8 @@ import { getSkillPresence, checkStalePresence } from '../../services/skills/skil
26
26
  import { detectPostCompactResume, formatPostCompactResumeLogLine } from '../../services/solo/post-compact-detector.js';
27
27
  import { runAutoCompact } from '../../services/solo/auto-compact-orchestrator.js';
28
28
  import { readJobShapeDecision, writeJobShapeDecision, JobShapeDecisionError, JOB_SHAPE_NOT_DECIDED, JOB_SHAPE_ALREADY_DECIDED } from '../../services/solo/job-shape-decision.js';
29
+ import { evaluateStep08, STEP_08_BACKUP_REGEX } from '../../services/solo/step-08-gate.js';
30
+ import { evaluateEmitHandoff, JOB_NOT_INITIALIZED, JOB_REMAINING_BLOCKED } from '../../services/solo/emit-handoff.js';
29
31
  const STEP_ORDER = [
30
32
  'load-memory',
31
33
  'standards-preflight',
@@ -383,38 +385,88 @@ export function registerSoloCommands(program, io) {
383
385
  'without requiring the LLM to pass --prompt-size <bytes> manually. ' +
384
386
  'Adapter-driven (no hard-coded IDE names): Claude Code is the MVP ' +
385
387
  'implementation; trae / codex / cursor / qoder / tongyi-lingma / ' +
386
- 'hermes / openclaw register their own env-var via IdeAdapter.compact.')
388
+ 'hermes / openclaw register their own env-var via IdeAdapter.compact. ' +
389
+ 'v3.1.2: when --enforce-job-mode is set OR job-shape.json says isJob=true, ' +
390
+ '≥0.85 emits action=auto-compact-now (MANDATORY, not advisory) and ' +
391
+ '≥0.95 emits action=red-line (forced hook fires next turn).')
387
392
  .requiredOption('--project <path>', 'target project root')
388
- .option('--session-id <sid>', 'override session id (default: read from active presence)')).action(async (opts) => {
393
+ .option('--session-id <sid>', 'override session id (default: read from active presence)')
394
+ .option('--enforce-job-mode', 'v3.1.2: treat ≥0.85 as MANDATORY auto-compact (not advisory). Auto-enabled when job-shape.json says isJob=true.')).action(async (opts) => {
389
395
  try {
390
396
  const { readContextPercent } = await import('../../services/context/auto-compact-reader.js');
397
+ // v3.1.2: detect Job mode from job-shape.json when --enforce-job-mode
398
+ // is not explicitly passed. The LLM is the source of truth for
399
+ // whether the request is Job-shaped; the recorded decision is.
400
+ let isJobMode = opts.enforceJobMode === true;
401
+ if (!isJobMode) {
402
+ try {
403
+ const sessionIdForDecision = opts.sessionId ?? readActiveSid(opts.project);
404
+ if (sessionIdForDecision !== null) {
405
+ const record = readJobShapeDecision(opts.project, sessionIdForDecision);
406
+ if (record.decision.isJob)
407
+ isJobMode = true;
408
+ }
409
+ }
410
+ catch (err) {
411
+ if (!(err instanceof JobShapeDecisionError))
412
+ throw err;
413
+ // missing/malformed decision file is fine — fall back to advisory.
414
+ }
415
+ }
391
416
  const probe = readContextPercent({
392
417
  projectRoot: opts.project,
393
418
  sessionId: opts.sessionId ?? readActiveSid(opts.project) ?? 'unknown',
394
419
  env: process.env
395
420
  });
396
421
  const ratioPct = (probe.ratio * 100).toFixed(1);
397
- const verdict = probe.ratio >= 0.95 ? 'red-line'
398
- : probe.ratio >= 0.85 ? 'pre-compact'
399
- : probe.ratio >= 0.5 ? 'soft-warn'
422
+ let action = 'ok';
423
+ let next = null;
424
+ if (probe.ratio >= 0.95) {
425
+ action = isJobMode ? 'red-line' : 'red-line';
426
+ next = 'peaks session auto-compact-hook';
427
+ }
428
+ else if (probe.ratio >= 0.85) {
429
+ if (isJobMode) {
430
+ action = 'auto-compact-now';
431
+ next = 'peaks session auto-compact --execute';
432
+ }
433
+ else {
434
+ action = 'soft-warn';
435
+ }
436
+ }
437
+ else if (probe.ratio >= 0.5) {
438
+ action = 'soft-warn';
439
+ }
440
+ const verdict = action === 'red-line' ? 'red-line'
441
+ : action === 'auto-compact-now' ? 'pre-compact'
442
+ : action === 'soft-warn' ? 'soft-warn'
400
443
  : 'ok';
444
+ const jobModeNotice = isJobMode
445
+ ? 'Job mode enforced: ≥0.85 is MANDATORY auto-compact (v3.1.2).'
446
+ : 'Advisory mode (single-rid): ≥0.85 is recommended, not mandatory.';
401
447
  printResult(io, ok('solo.context-now', {
402
448
  ratio: probe.ratio,
403
449
  ratioPct: `${ratioPct}%`,
404
450
  verdict,
451
+ action,
452
+ next,
453
+ jobMode: isJobMode,
405
454
  source: probe.source,
406
455
  ide: probe.ide,
407
456
  capacityBytes: probe.capacityBytes,
408
457
  rawBytes: probe.rawBytes ?? null,
409
458
  capturedAt: probe.capturedAt
410
459
  }, [], [
411
- verdict === 'red-line'
412
- ? `RED LINE: ≥ 95%. Run \`peaks solo auto-compact\` immediately (next sub-agent dispatch will be blocked).`
413
- : verdict === 'pre-compact'
414
- ? `Pre-compact zone (85–95%). Run \`peaks solo auto-compact\` to converge now.`
415
- : verdict === 'soft-warn'
416
- ? `Soft warn (50–85%). Continue working; the next \`peaks context now\` will re-check.`
417
- : `Below 50%. No action required.`
460
+ action === 'red-line'
461
+ ? `RED LINE: ≥ 95%. Next: \`${next}\` (PreToolUse hook fires next turn).`
462
+ : action === 'auto-compact-now'
463
+ ? `Job-mode MANDATORY auto-compact. Solo MUST call \`${next}\` WITHOUT confirmation.`
464
+ : action === 'soft-warn'
465
+ ? isJobMode
466
+ ? `Job mode soft-warn (50–85%). Continue working; the next \`peaks context now\` will re-check.`
467
+ : `Soft warn (50–85%). Continue working; the next \`peaks context now\` will re-check.`
468
+ : `Below 50%. No action required.`,
469
+ jobModeNotice
418
470
  ]), true);
419
471
  }
420
472
  catch (err) {
@@ -558,6 +610,180 @@ export function registerSoloCommands(program, io) {
558
610
  process.exitCode = 1;
559
611
  }
560
612
  });
613
+ // v3.1.2 Step 0.8 — Mechanical PreToolUse gate.
614
+ // Wire-installed by `peaks workspace init` (extends the existing hook
615
+ // installer). Exit code is the load-bearing contract:
616
+ // exit 0 → allow (with structured stdout describing the decision)
617
+ // exit 2 → block (stderr contains the BLOCKED: ... reason)
618
+ addJsonOption(solo
619
+ .command('gate-step-08')
620
+ .description('v3.1.2: PreToolUse gate for Step 0.8 — allow when job-shape.json exists; ' +
621
+ 'fail-closed backup regex when missing. Exit 0 = allow, exit 2 = block. ' +
622
+ 'When the decision says isJob=true AND progress.json exists, the stdout ' +
623
+ 'also carries `Next: slice #N+1 of M (<currentSlice>)` so the LLM cannot ' +
624
+ 'wake up cold.')
625
+ .requiredOption('--project <path>', 'target project root (the hook passes "." so resolveCanonicalProjectRoot promotes it to the git root)')
626
+ .option('--session-id <sid>', 'override session id (default: read from active presence)')
627
+ .option('--prompt <text>', 'explicit prompt text (default: read last-prompt.txt; stdin ignored)')).action((opts) => {
628
+ try {
629
+ const sessionId = opts.sessionId ?? readActiveSid(opts.project);
630
+ if (sessionId === null) {
631
+ // No session binding — treat as allow (single-rid mode). The
632
+ // LLM has not yet anchored; we have nothing to gate against.
633
+ const envelope = ok('solo.gate-step-08', {
634
+ allow: true,
635
+ mode: 'no-session',
636
+ decision: null,
637
+ nextSlice: null
638
+ }, [], [
639
+ 'No active session id; gate passes through (single-rid mode).'
640
+ ]);
641
+ printResult(io, envelope, opts.json);
642
+ return;
643
+ }
644
+ const evalInput = {
645
+ projectRoot: opts.project,
646
+ sessionId
647
+ };
648
+ if (opts.prompt !== undefined)
649
+ evalInput.prompt = opts.prompt;
650
+ const result = evaluateStep08(evalInput);
651
+ const verdict = result.verdict;
652
+ if (verdict.kind === 'allow-job') {
653
+ const envelope = ok('solo.gate-step-08', {
654
+ allow: true,
655
+ mode: 'job',
656
+ decision: verdict.decision,
657
+ progress: verdict.progress,
658
+ nextSlice: result.nextSliceLine
659
+ }, [], result.nextSliceLine !== null ? [result.nextSliceLine] : []);
660
+ printResult(io, envelope, opts.json);
661
+ return;
662
+ }
663
+ if (verdict.kind === 'allow-single') {
664
+ const envelope = ok('solo.gate-step-08', {
665
+ allow: true,
666
+ mode: 'single',
667
+ decision: null,
668
+ nextSlice: null
669
+ }, [], [
670
+ 'job-shape.json says isJob=false; single-rid mode (gate allows).'
671
+ ]);
672
+ printResult(io, envelope, opts.json);
673
+ return;
674
+ }
675
+ // block-missing-decision
676
+ if (verdict.promptHit) {
677
+ // Block: backup regex hit. Exit code 2 is the load-bearing
678
+ // signal for the PreToolUse hook.
679
+ const blockMessage = 'BLOCKED: prompt looks Job-shaped but peaks solo detect-job has not been called. Run `peaks solo detect-job --is-job true ...` to record your Job-shape verdict, then retry.';
680
+ const envelope = fail('solo.gate-step-08', 'STEP_08_BLOCKED', blockMessage, {
681
+ promptSource: verdict.promptSource,
682
+ backupRegex: STEP_08_BACKUP_REGEX.toString()
683
+ }, [
684
+ 'Run `peaks solo detect-job --is-job true --rationale <text> --suggested-job-id <slug>` to record the Job-shape verdict.',
685
+ 'Then re-run the Bash tool call.'
686
+ ]);
687
+ io.stderr(`${blockMessage}\n`);
688
+ printResult(io, envelope, opts.json);
689
+ process.exitCode = 2;
690
+ return;
691
+ }
692
+ // No decision + no regex hit → allow.
693
+ const envelope = ok('solo.gate-step-08', {
694
+ allow: true,
695
+ mode: 'undecided-no-regex-hit',
696
+ decision: null,
697
+ nextSlice: null,
698
+ promptSource: verdict.promptSource
699
+ }, [], [
700
+ 'No job-shape.json AND no backup-regex match on prompt → allow (most prompts are not Job-shaped).'
701
+ ]);
702
+ printResult(io, envelope, opts.json);
703
+ return;
704
+ }
705
+ catch (err) {
706
+ printResult(io, fail('solo.gate-step-08', 'GATE_STEP_08_FAILED', getErrorMessage(err), null, [
707
+ 'Verify the project path and try again'
708
+ ]), opts.json);
709
+ process.exitCode = 1;
710
+ }
711
+ });
712
+ // v3.1.2 Step 11 / final handoff — Size-fear ban.
713
+ // Refuses to emit a final handoff while a Job has remaining slices.
714
+ addJsonOption(solo
715
+ .command('emit-handoff')
716
+ .description('v3.1.2 Step 11 size-fear ban: under Job mode, refuse to emit a final ' +
717
+ 'handoff while remaining > 0. Exit 0 = allow, exit 1 = block. Pass ' +
718
+ '--force-under-job to override (requires explicit user approval).')
719
+ .requiredOption('--project <path>', 'target project root')
720
+ .option('--session-id <sid>', 'override session id (default: read from active presence)')
721
+ .option('--job-id <jid>', 'override job id (default: read from job-shape.json decision.suggestedJobId)')
722
+ .option('--force-under-job', 'override the remaining>0 block (explicit user approval required)')).action((opts) => {
723
+ try {
724
+ const sessionId = opts.sessionId ?? readActiveSid(opts.project);
725
+ if (sessionId === null) {
726
+ const envelope = ok('solo.emit-handoff', { allow: true, mode: 'no-session' }, [], [
727
+ 'No active session id; gate passes through (single-rid mode).'
728
+ ]);
729
+ printResult(io, envelope, opts.json);
730
+ return;
731
+ }
732
+ const evalInput = {
733
+ projectRoot: opts.project,
734
+ sessionId
735
+ };
736
+ if (opts.jobId !== undefined)
737
+ evalInput.jobId = opts.jobId;
738
+ if (opts.forceUnderJob === true)
739
+ evalInput.forceUnderJob = true;
740
+ const verdict = evaluateEmitHandoff(evalInput);
741
+ if (verdict.kind === 'allow-not-job') {
742
+ const envelope = ok('solo.emit-handoff', { allow: true, mode: 'single' }, [], [
743
+ 'job-shape.json says isJob=false (or absent); normal handoff allowed.'
744
+ ]);
745
+ printResult(io, envelope, opts.json);
746
+ return;
747
+ }
748
+ if (verdict.kind === 'allow-done') {
749
+ const envelope = ok('solo.emit-handoff', { allow: true, mode: 'job-done', remaining: verdict.remaining }, [], [
750
+ `Job is complete (remaining=0); handoff allowed.`
751
+ ]);
752
+ printResult(io, envelope, opts.json);
753
+ return;
754
+ }
755
+ if (verdict.kind === 'allow-force-override') {
756
+ const envelope = ok('solo.emit-handoff', { allow: true, mode: 'job-force-override', remaining: verdict.remaining }, [], [
757
+ `Job has ${verdict.remaining} remaining slices; --force-under-job override applied. Handoff allowed (explicit user approval).`
758
+ ]);
759
+ printResult(io, envelope, opts.json);
760
+ return;
761
+ }
762
+ if (verdict.kind === 'block-not-initialized') {
763
+ const envelope = fail('solo.emit-handoff', JOB_NOT_INITIALIZED, `Job ${verdict.jobId} has no state.json; peaks job init was skipped.`, { jobId: verdict.jobId }, [`Run \`peaks job init --job-id ${verdict.jobId} --slice-list <...>\` before emitting handoff.`]);
764
+ printResult(io, envelope, opts.json);
765
+ process.exitCode = 1;
766
+ return;
767
+ }
768
+ // block-remaining
769
+ const blockMessage = `BLOCKED: Job ${verdict.jobId} has ${verdict.remaining} remaining slices. Run \`peaks job status\`. Use --force-under-job only with explicit user approval.`;
770
+ const envelope = fail('solo.emit-handoff', JOB_REMAINING_BLOCKED, blockMessage, { jobId: verdict.jobId, remaining: verdict.remaining }, [
771
+ `Run \`peaks job status --job-id ${verdict.jobId}\` to see remaining slices.`,
772
+ 'Resume Step 0.81 (per-slice checkpoint loop) and continue until remaining === 0.',
773
+ 'Use --force-under-job only with explicit user approval (size-fear ban override).'
774
+ ]);
775
+ io.stderr(`${blockMessage}\n`);
776
+ printResult(io, envelope, opts.json);
777
+ process.exitCode = 1;
778
+ return;
779
+ }
780
+ catch (err) {
781
+ printResult(io, fail('solo.emit-handoff', 'EMIT_HANDOFF_FAILED', getErrorMessage(err), null, [
782
+ 'Verify the project path and try again'
783
+ ]), opts.json);
784
+ process.exitCode = 1;
785
+ }
786
+ });
561
787
  }
562
788
  function readActiveSid(projectRoot) {
563
789
  try {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * v3.1.2 — On-disk slice progress mirror.
3
+ *
4
+ * After each `peaks job checkpoint --state done`, the orchestrator writes
5
+ * `.peaks/_runtime/<sessionId>/job/<jid>/progress.json` so the next LLM
6
+ * turn (after compact or resume) can read it before any Bash call lands.
7
+ *
8
+ * Shape (schemaVersion 1):
9
+ * {
10
+ * jobId, done, total, currentSlice, lastCommitSha, updatedAt
11
+ * }
12
+ *
13
+ * The `peaks solo gate-step-08` hook reads this file in its case-1 path
14
+ * (job-shape.json says isJob=true) and surfaces `Next: slice #N+1 of M
15
+ * (<currentSlice>)` to stdout. The LLM cannot "wake up cold" — the
16
+ * next-slice context is mechanically injected on every Bash call.
17
+ *
18
+ * Karpathy §2 (Simplicity First): ~50 lines, single-purpose writer +
19
+ * reader. Schema is zod-validated on read so a stale on-disk copy
20
+ * from an earlier peaks-loop release fails loud (no silent fallback).
21
+ */
22
+ import { z } from 'zod';
23
+ export declare const JOB_PROGRESS_SCHEMA_VERSION: 1;
24
+ export declare const JobProgressSchema: z.ZodObject<{
25
+ schemaVersion: z.ZodLiteral<1>;
26
+ jobId: z.ZodString;
27
+ done: z.ZodNumber;
28
+ total: z.ZodNumber;
29
+ currentSlice: z.ZodString;
30
+ lastCommitSha: z.ZodNullable<z.ZodString>;
31
+ updatedAt: z.ZodString;
32
+ }, "strip", z.ZodTypeAny, {
33
+ updatedAt: string;
34
+ done: number;
35
+ schemaVersion: 1;
36
+ jobId: string;
37
+ total: number;
38
+ currentSlice: string;
39
+ lastCommitSha: string | null;
40
+ }, {
41
+ updatedAt: string;
42
+ done: number;
43
+ schemaVersion: 1;
44
+ jobId: string;
45
+ total: number;
46
+ currentSlice: string;
47
+ lastCommitSha: string | null;
48
+ }>;
49
+ export type JobProgress = z.infer<typeof JobProgressSchema>;
50
+ export interface WriteProgressInput {
51
+ readonly jobId: string;
52
+ readonly done: number;
53
+ readonly total: number;
54
+ readonly currentSlice: string;
55
+ readonly lastCommitSha: string | null;
56
+ readonly updatedAt?: string;
57
+ }
58
+ export declare function writeJobProgress(projectRoot: string, sessionId: string, input: WriteProgressInput): JobProgress;
59
+ export declare function readJobProgress(projectRoot: string, sessionId: string, jobId: string): JobProgress;
60
+ export declare function tryReadJobProgress(projectRoot: string, sessionId: string, jobId: string): JobProgress | null;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * v3.1.2 — On-disk slice progress mirror.
3
+ *
4
+ * After each `peaks job checkpoint --state done`, the orchestrator writes
5
+ * `.peaks/_runtime/<sessionId>/job/<jid>/progress.json` so the next LLM
6
+ * turn (after compact or resume) can read it before any Bash call lands.
7
+ *
8
+ * Shape (schemaVersion 1):
9
+ * {
10
+ * jobId, done, total, currentSlice, lastCommitSha, updatedAt
11
+ * }
12
+ *
13
+ * The `peaks solo gate-step-08` hook reads this file in its case-1 path
14
+ * (job-shape.json says isJob=true) and surfaces `Next: slice #N+1 of M
15
+ * (<currentSlice>)` to stdout. The LLM cannot "wake up cold" — the
16
+ * next-slice context is mechanically injected on every Bash call.
17
+ *
18
+ * Karpathy §2 (Simplicity First): ~50 lines, single-purpose writer +
19
+ * reader. Schema is zod-validated on read so a stale on-disk copy
20
+ * from an earlier peaks-loop release fails loud (no silent fallback).
21
+ */
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { z } from 'zod';
25
+ export const JOB_PROGRESS_SCHEMA_VERSION = 1;
26
+ export const JobProgressSchema = z.object({
27
+ schemaVersion: z.literal(JOB_PROGRESS_SCHEMA_VERSION),
28
+ jobId: z.string(),
29
+ done: z.number().int().nonnegative(),
30
+ total: z.number().int().positive(),
31
+ currentSlice: z.string(),
32
+ lastCommitSha: z.string().nullable(),
33
+ updatedAt: z.string().datetime()
34
+ });
35
+ export function writeJobProgress(projectRoot, sessionId, input) {
36
+ const dir = join(projectRoot, '.peaks', '_runtime', sessionId, 'job', input.jobId);
37
+ mkdirSync(dir, { recursive: true });
38
+ const record = {
39
+ schemaVersion: JOB_PROGRESS_SCHEMA_VERSION,
40
+ jobId: input.jobId,
41
+ done: input.done,
42
+ total: input.total,
43
+ currentSlice: input.currentSlice,
44
+ lastCommitSha: input.lastCommitSha,
45
+ updatedAt: input.updatedAt ?? new Date().toISOString()
46
+ };
47
+ const path = join(dir, 'progress.json');
48
+ writeFileSync(path, JSON.stringify(record, null, 2) + '\n', 'utf8');
49
+ return record;
50
+ }
51
+ export function readJobProgress(projectRoot, sessionId, jobId) {
52
+ const path = join(projectRoot, '.peaks', '_runtime', sessionId, 'job', jobId, 'progress.json');
53
+ if (!existsSync(path)) {
54
+ throw new Error(`JobProgressStore: no progress for ${jobId} at ${path}`);
55
+ }
56
+ const raw = readFileSync(path, 'utf8');
57
+ return JobProgressSchema.parse(JSON.parse(raw));
58
+ }
59
+ export function tryReadJobProgress(projectRoot, sessionId, jobId) {
60
+ const path = join(projectRoot, '.peaks', '_runtime', sessionId, 'job', jobId, 'progress.json');
61
+ if (!existsSync(path))
62
+ return null;
63
+ try {
64
+ const raw = readFileSync(path, 'utf8');
65
+ return JobProgressSchema.parse(JSON.parse(raw));
66
+ }
67
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
68
+ return null;
69
+ }
70
+ }
@@ -192,6 +192,7 @@ export declare const JobStateSchema: z.ZodObject<{
192
192
  }, "strip", z.ZodTypeAny, {
193
193
  sessionId: string;
194
194
  startedAt: string;
195
+ jobId: string;
195
196
  slices: {
196
197
  status: "pending" | "skipped" | "done" | "failed" | "blocked" | "in-progress";
197
198
  label: string;
@@ -202,7 +203,6 @@ export declare const JobStateSchema: z.ZodObject<{
202
203
  failureReason?: string | undefined;
203
204
  blockedReason?: string | undefined;
204
205
  }[];
205
- jobId: string;
206
206
  lastCheckpointAt: string;
207
207
  parallelismHint: "serial" | "llm-decides";
208
208
  exitPolicy: "strict" | "best-effort";
@@ -218,6 +218,7 @@ export declare const JobStateSchema: z.ZodObject<{
218
218
  }, {
219
219
  sessionId: string;
220
220
  startedAt: string;
221
+ jobId: string;
221
222
  slices: {
222
223
  status: "pending" | "skipped" | "done" | "failed" | "blocked" | "in-progress";
223
224
  label: string;
@@ -228,7 +229,6 @@ export declare const JobStateSchema: z.ZodObject<{
228
229
  repairCycles?: number | undefined;
229
230
  blockedReason?: string | undefined;
230
231
  }[];
231
- jobId: string;
232
232
  lastCheckpointAt: string;
233
233
  parallelismHint?: "serial" | "llm-decides" | undefined;
234
234
  exitPolicy?: "strict" | "best-effort" | undefined;
@@ -296,9 +296,9 @@ export declare const JobStatusSummarySchema: z.ZodObject<{
296
296
  done: number;
297
297
  failed: number;
298
298
  blocked: number;
299
+ total: number;
299
300
  mainLoopStrategy: "single" | "rotating";
300
301
  mainSessionCycle: number;
301
- total: number;
302
302
  lastCheckpoint: string;
303
303
  currentSlice?: string | undefined;
304
304
  etaSec?: number | undefined;
@@ -314,9 +314,9 @@ export declare const JobStatusSummarySchema: z.ZodObject<{
314
314
  done: number;
315
315
  failed: number;
316
316
  blocked: number;
317
+ total: number;
317
318
  mainLoopStrategy: "single" | "rotating";
318
319
  mainSessionCycle: number;
319
- total: number;
320
320
  lastCheckpoint: string;
321
321
  currentSlice?: string | undefined;
322
322
  etaSec?: number | undefined;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * v3.1.2 Step 11 / final handoff — Size-fear ban.
3
+ *
4
+ * Refuses to emit a final handoff while a Job has remaining slices.
5
+ * Closes the v3.1.0 incident pattern: LLM writes a fake-completion
6
+ * message at high context-fill before Job mode kicks in.
7
+ *
8
+ * Decision tree:
9
+ * - job-shape.json missing OR isJob === false → ALLOW (normal handoff)
10
+ * - isJob === true, no state.json → JOB_NOT_INITIALIZED (LLM
11
+ * skipped `peaks job init`)
12
+ * - isJob === true, state.json, remaining > 0,
13
+ * no --force-under-job → JOB_REMAINING_BLOCKED
14
+ * - isJob === true, state.json, remaining === 0 → ALLOW (Job done)
15
+ * - isJob === true, state.json, remaining > 0,
16
+ * --force-under-job → ALLOW (override path)
17
+ *
18
+ * Karpathy §2 (Simplicity First): ~70 lines, no LLM call, no regex.
19
+ * Pure read + arithmetic. The CLI wrapper in solo-commands.ts
20
+ * translates the verdict into exit code + JSON envelope.
21
+ */
22
+ export declare const JOB_NOT_INITIALIZED: "JOB_NOT_INITIALIZED";
23
+ export declare const JOB_REMAINING_BLOCKED: "JOB_REMAINING_BLOCKED";
24
+ export type EmitHandoffVerdict = {
25
+ readonly kind: 'allow-not-job';
26
+ } | {
27
+ readonly kind: 'allow-done';
28
+ readonly remaining: number;
29
+ } | {
30
+ readonly kind: 'allow-force-override';
31
+ readonly remaining: number;
32
+ } | {
33
+ readonly kind: 'block-not-initialized';
34
+ readonly code: typeof JOB_NOT_INITIALIZED;
35
+ readonly jobId: string;
36
+ } | {
37
+ readonly kind: 'block-remaining';
38
+ readonly code: typeof JOB_REMAINING_BLOCKED;
39
+ readonly jobId: string;
40
+ readonly remaining: number;
41
+ };
42
+ export interface EvaluateEmitHandoffInput {
43
+ readonly projectRoot: string;
44
+ readonly sessionId: string;
45
+ /** Suggested job id — read from job-shape.json when omitted. */
46
+ readonly jobId?: string;
47
+ readonly forceUnderJob?: boolean;
48
+ }
49
+ export declare function evaluateEmitHandoff(input: EvaluateEmitHandoffInput): EmitHandoffVerdict;