peaks-loop 3.0.3 → 3.1.1

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,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.1.1 — 2026-07-03
4
+
5
+ ### Added — Step 0.8 detector-as-recorder (LLM-judged, CLI-validated)
6
+
7
+ `peaks-solo` Step 0.8 is no longer prose-only: the LLM makes the Job-shape judgement, the CLI records it. The CLI is a **recorder and gate**, not a detector — no keyword regex anywhere in the service or CLI (the LLM is the source of truth). Downstream steps refuse to proceed until the decision file exists.
8
+
9
+ - New service: `src/services/solo/job-shape-decision.ts` — `readJobShapeDecision` / `writeJobShapeDecision` / `validateJobShapeDecision`. Persists to `.peaks/_runtime/<sessionId>/job-shape.json`; server-side stamps `decidedAt` so the LLM cannot back-date; throws `JOB_SHAPE_NOT_DECIDED` on missing / unreadable / malformed.
10
+ - New CLI subcommands:
11
+ - `peaks solo detect-job --is-job <bool> --rationale <text> --suggested-job-id <jid> [--suggested-strategy single|rotating] [--confidence high|medium|low] [--force]` — recorder. Validates `suggestedJobId` against `/^[a-z0-9][a-z0-9-]{2,40}$/`, hashes the user prompt, writes the file. Refuses overwrite without `--force`.
12
+ - `peaks solo read-job-shape` — downstream gate. Returns the current record or `JOB_SHAPE_NOT_DECIDED`.
13
+ - New tests: `tests/unit/solo/job-shape-decision.test.ts` (validate happy/sad, server-stamp `decidedAt`, force semantics, missing/unreadable/malformed, round-trip, promptHash determinism), `tests/integration/solo-detect-job-command.test.ts` (CLI spawn happy / bad-flag / read-back / fresh / force), `tests/unit/solo/solo-step-08-block-guard.test.ts` (locks the SKILL.md / runbook contract: `BLOCKING on LLM judgement`, `peaks solo detect-job`, `peaks solo read-job-shape`, and runbook ordering before `# After Step 7`).
14
+
15
+ ### Hard red line #10 (v3.1.1)
16
+
17
+ The LLM MUST NOT skip `peaks solo detect-job` even when the trigger is obvious from context. If the next step's `read-job-shape` throws `JOB_SHAPE_NOT_DECIDED`, Solo MUST record a decision before proceeding. Keyword-based "I already know it's a Job" is not a substitute — the LLM is the only authority for the Job-shape judgement, and the CLI is the only place that records it.
18
+
19
+ ### Memory
20
+
21
+ Incident + rationale: `.peaks/memory/2026-07-03-v3-1-0-job-trigger-miss.md`. This release closes the gap identified in that memory file.
22
+
23
+ ## 3.1.0 — 2026-07-03
24
+
25
+ ### Fixed — peaks-solo external-references authority declaration
26
+
27
+ - `skills/peaks-solo/SKILL.md` External-references paragraph now ends with `Peaks-Loop Solo gates and artifacts remain authoritative.` to satisfy `tests/unit/skill-external-invocation.test.ts:63` (`PEAKS_AUTHORITATIVE_PATTERN`). One-line surgical patch; no behaviour change.
28
+
29
+ ### Added — Peaks-Loop Job
30
+
31
+ - New `peaks job *` subcommand family: init / status / checkpoint / continue / resume / block / handoff / rotate-now / subagent-cleanup (9 commands).
32
+ - New CLI flag `--main-loop-strategy single|rotating` on `job init`, with rotating-mode hard default for ≥3 slices and `rotateEvery=3` cadence.
33
+ - New `--watch` poll mode + statusline event stub (`emitJobEvent`) for ambient progress visibility. Real statusline wire-up deferred to a follow-up slice.
34
+ - Sub-agent wrapper `SubAgentJobWrapper` enforces `--budget-mb 512` default + cleanup gate before slice checkpoint.
35
+ - New peaks-solo SKILL.md Steps 0.8 / 0.81 / 0.85 / 0.86 / 0.87 wrapping the existing single-rid runbook for multi-slice jobs.
36
+ - 9 hard-red-line rules embedded in the skill prose (cost re-ask ban, slice-coalesce ban, fake-completion ban, detached-mode ban, cleanup-skip ban, rotate-skip ban, etc).
37
+
38
+ ### New services
39
+
40
+ - `src/services/job/job-types.ts` — Zod schemas + types for SliceState, JobState, ResourceSnapshot, JobStatusSummary, plus CLI input schemas (JobInitInput, JobCheckpointInput, JobBlockInput).
41
+ - `src/services/job/job-state-store.ts` — on-disk state store with per-job lockfile.
42
+ - `src/services/job/job-orchestrator.ts` — state machine with strict / best-effort exit policies.
43
+ - `src/services/job/job-rotation.ts` — main-session rotation cadence (single / rotating mode).
44
+ - `src/services/job/subagent-job-wrapper.ts` — budget + cleanup gate.
45
+ - `src/services/job/job-resource-snapshot.ts` — cpu / mem / disk / context collector.
46
+ - `src/services/job/job-event-emitter.ts` — stderr-stub event emit (real statusline wire-up pending).
47
+
48
+ ### Migration
49
+
50
+ - Existing single-rid flows are unchanged. The Job is opt-in via peaks-solo Step 0.8 detection; users who do not invoke multi-slice requests see no behavior difference.
51
+ - The `peaks session rotate` and `peaks session cycle-summary` subcommands referenced in the spec are NOT shipped in v1; the rotation path uses constructor-injected stub callbacks. M6.5 follow-up will add the real subcommands.
52
+
53
+ ### Spec / plan
54
+
55
+ - Design: `docs/superpowers/specs/2026-07-03-peaks-loop-job-design.md` (v3).
56
+ - Plans: `docs/superpowers/plans/2026-07-03-peaks-job/` (M1-M7 + README).
57
+
3
58
  ## [3.0.3] — 2026-07-02 — auto-compact zero-pause (ide-native pathway)
4
59
 
5
60
  **PATCH bump from 3.0.2**. Closes the user-pause gap on long Claude Code sessions: at context ratio ≥ 0.95 the runner now compacts itself via a PreToolUse hook, no human intervention required.
@@ -0,0 +1,3 @@
1
+ import { Command } from 'commander';
2
+ import { type ProgramIO } from '../cli-helpers.js';
3
+ export declare function registerJobCommands(program: Command, io?: ProgramIO): void;
@@ -0,0 +1,236 @@
1
+ // src/cli/commands/job-commands.ts
2
+ import { join } from 'node:path';
3
+ import { Command } from 'commander';
4
+ import { fail, ok } from '../../shared/result.js';
5
+ import { addJsonOption, printResult } from '../cli-helpers.js';
6
+ import { JobStateStore } from '../../services/job/job-state-store.js';
7
+ import { JobOrchestrator } from '../../services/job/job-orchestrator.js';
8
+ import { JobRotation } from '../../services/job/job-rotation.js';
9
+ import { SubAgentJobWrapper } from '../../services/job/subagent-job-wrapper.js';
10
+ import { emitJobEvent } from '../../services/job/job-event-emitter.js';
11
+ import { JobInitInputSchema, JobCheckpointInputSchema, JobBlockInputSchema, } from '../../services/job/job-types.js';
12
+ import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
13
+ function projectRoot(opts) {
14
+ // Reuse the workspace root resolver from peaks CLI; for now, CWD as a safe placeholder.
15
+ return opts.project ?? process.cwd();
16
+ }
17
+ /**
18
+ * Resolves the on-disk root for Job state files.
19
+ *
20
+ * Per spec §3.3 + §4.5 (2.7.1 single-scope-axis layout), Job state lives at:
21
+ * `<projectRoot>/.peaks/_runtime/<sessionId>/job/<jobId>/state.json`
22
+ *
23
+ * The `JobStateStore` itself only knows its `rootDir` + `jobId` and joins them. We
24
+ * compute the canonical root here (per-call) so the store can stay layout-agnostic.
25
+ *
26
+ * Resolution order:
27
+ * 1. `--session-id` flag (explicit override)
28
+ * 2. `getCurrentSessionId(project)` — reads `.peaks/_runtime/session.json` per peaks-solo
29
+ * 3. Error (NO_ACTIVE_SESSION) — must never silently fall back to a random uuid
30
+ */
31
+ function resolveJobStateRoot(opts) {
32
+ const project = projectRoot(opts);
33
+ const sessionId = opts.sessionId ?? getCurrentSessionId(project);
34
+ if (!sessionId) {
35
+ throw new Error('NO_ACTIVE_SESSION: peaks job requires --session-id or an active peaks-solo session via peaks workspace init');
36
+ }
37
+ const rootDir = join(project, '.peaks', '_runtime', sessionId, 'job');
38
+ return { rootDir, sessionId, projectRoot: project };
39
+ }
40
+ export function registerJobCommands(program, io = { stdout: (t) => process.stdout.write(t), stderr: (t) => process.stderr.write(t) }) {
41
+ const job = new Command('job').description('Drive long multi-slice work as one Job (peaks-solo Step 0.8+)');
42
+ job
43
+ .command('init')
44
+ .requiredOption('--job-id <jid>')
45
+ .requiredOption('--slice-list <list>')
46
+ .option('--parallelism-hint <serial|llm-decides>', 'llm-decides')
47
+ .option('--exit-policy <strict|best-effort>', 'strict')
48
+ .option('--main-loop-strategy <single|rotating>', 'rotating')
49
+ .option('--rotate-every <n>', 'rotate every N slices (rotating mode)', '3')
50
+ .option('--session-id <sid>', 'session id (default: read from .peaks/_runtime/session.json; required to land in the 2.7.1 single-scope-axis layout)')
51
+ .option('--project <repo>')
52
+ .action(async (opts) => {
53
+ const project = projectRoot(opts);
54
+ // Resolve sessionId: explicit flag > canonical session binding > FAIL.
55
+ // Per spec §3.3, Job state lives at .peaks/_runtime/<sessionId>/job/<jobId>/state.json —
56
+ // a random UUID would scatter state across dirs and break resume/auto-compact.
57
+ let sessionId = opts.sessionId ?? getCurrentSessionId(project);
58
+ if (!sessionId) {
59
+ return printResult(io, fail('init', 'NO_ACTIVE_SESSION', 'peaks job init requires --session-id (or an active peaks-solo session via peaks workspace init)', { project }, [
60
+ 'Re-run with --session-id <sid>',
61
+ 'Or run `peaks workspace init` to create a session first'
62
+ ]), opts);
63
+ }
64
+ const parsed = JobInitInputSchema.safeParse({
65
+ jobId: opts.jobId,
66
+ sessionId,
67
+ sliceList: opts.sliceList.split(',').map((s) => s.trim()).filter(Boolean),
68
+ parallelismHint: opts.parallelismHint,
69
+ exitPolicy: opts.exitPolicy,
70
+ mainLoopStrategy: opts.mainLoopStrategy,
71
+ rotateEvery: Number(opts.rotateEvery),
72
+ project,
73
+ json: opts.json,
74
+ });
75
+ if (!parsed.success)
76
+ return printResult(io, fail('init', 'INVALID_INIT', parsed.error.message, {}), opts);
77
+ const jobRoot = resolveJobStateRoot(opts);
78
+ const store = new JobStateStore(jobRoot.rootDir);
79
+ const orch = new JobOrchestrator(store);
80
+ const state = orch.init({
81
+ jobId: parsed.data.jobId,
82
+ sessionId: parsed.data.sessionId,
83
+ sliceList: parsed.data.sliceList,
84
+ parallelismHint: parsed.data.parallelismHint,
85
+ exitPolicy: parsed.data.exitPolicy,
86
+ mainLoopStrategy: parsed.data.mainLoopStrategy,
87
+ rotateEvery: parsed.data.rotateEvery,
88
+ });
89
+ try {
90
+ emitJobEvent({ kind: 'job-started', jobId: state.jobId, total: state.slices.length, strategy: state.mainLoopStrategy });
91
+ }
92
+ catch (e) {
93
+ // best-effort: event emission failures must not abort job init
94
+ void e;
95
+ }
96
+ printResult(io, ok('init', { jobId: state.jobId, sliceCount: state.slices.length, statePath: `${jobRoot.rootDir}/${state.jobId}/state.json` }), opts);
97
+ });
98
+ addJsonOption(job.commands.find(c => c.name() === 'init'));
99
+ job
100
+ .command('status')
101
+ .requiredOption('--job-id <jid>')
102
+ .option('--watch', 'poll every 3s')
103
+ .option('--show-cost', 'overlay cost from peaks budget')
104
+ .option('--project <repo>')
105
+ .action(async (opts) => {
106
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
107
+ const orch = new JobOrchestrator(store);
108
+ const s = orch.status(opts.jobId);
109
+ if (opts.watch) {
110
+ const draw = () => {
111
+ const bar = `[${'='.repeat(s.done)}${' '.repeat(s.total - s.done)}]`;
112
+ process.stdout.write(`\rjob ${opts.jobId}: ${bar} ${s.done}/${s.total}${s.currentSlice ? ` next=${s.currentSlice}` : ''} `);
113
+ };
114
+ draw();
115
+ const iv = setInterval(() => { const u = orch.status(opts.jobId); Object.assign(s, u); draw(); if (u.done + u.failed + u.skipped + u.blocked >= u.total)
116
+ clearInterval(iv); }, 3000);
117
+ process.on('SIGINT', () => { clearInterval(iv); process.stdout.write('\n'); process.exit(0); });
118
+ return;
119
+ }
120
+ try {
121
+ emitJobEvent({ kind: 'job-progress', jobId: opts.jobId, done: s.done, total: s.total, ...(s.currentSlice ? { currentSlice: s.currentSlice } : {}) });
122
+ }
123
+ catch (e) {
124
+ // best-effort: event emission failures must not abort job status
125
+ void e;
126
+ }
127
+ printResult(io, ok('status', s), opts);
128
+ });
129
+ addJsonOption(job.commands.find(c => c.name() === 'status'));
130
+ // M4.2: wire rotate-now to JobRotation (session-rotate callbacks are stubs pending M6.5 batch-fix).
131
+ job.command('rotate-now')
132
+ .requiredOption('--job-id <jid>')
133
+ .option('--project <repo>')
134
+ .action(async (opts) => {
135
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
136
+ const rotation = new JobRotation(store, async (_jid) => { /* delegate to peaks session rotate — implementation wired in M6.5 batch-fix */ return { rotated: true }; }, async (jid) => ({ jobId: jid, cycle: 0 }));
137
+ const r = await rotation.rotateNow(opts.jobId);
138
+ printResult(io, ok('rotate-now', r), opts);
139
+ });
140
+ addJsonOption(job.commands.find(c => c.name() === 'rotate-now'));
141
+ job.command('subagent-cleanup')
142
+ .requiredOption('--job-id <jid>')
143
+ .requiredOption('--batch-id <bid>')
144
+ .option('--force')
145
+ .option('--project <repo>')
146
+ .action(async (opts) => {
147
+ const wrapper = new SubAgentJobWrapper(new JobStateStore(resolveJobStateRoot(opts).rootDir), async () => ({ batchId: opts.batchId }));
148
+ const r = await wrapper.cleanup({ jobId: opts.jobId, batchId: opts.batchId, force: !!opts.force });
149
+ printResult(io, ok('subagent-cleanup', r), opts);
150
+ });
151
+ addJsonOption(job.commands.find(c => c.name() === 'subagent-cleanup'));
152
+ // M3.2: wire the remaining 5 subcommand slots — block, checkpoint, continue, handoff, resume.
153
+ job
154
+ .command('checkpoint')
155
+ .requiredOption('--job-id <jid>')
156
+ .requiredOption('--slice-id <rid>')
157
+ .requiredOption('--state <done|failed|skipped>')
158
+ .option('--commit-sha <sha>')
159
+ .option('--reason <text>')
160
+ .option('--project <repo>')
161
+ .action(async (opts) => {
162
+ const parsed = JobCheckpointInputSchema.safeParse({
163
+ jobId: opts.jobId, sliceId: opts.sliceId, state: opts.state,
164
+ commitSha: opts.commitSha, reason: opts.reason,
165
+ project: projectRoot(opts), json: opts.json,
166
+ });
167
+ if (!parsed.success)
168
+ return printResult(io, fail('checkpoint', 'INVALID_CHECKPOINT', parsed.error.message, {}), opts);
169
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
170
+ const orch = new JobOrchestrator(store);
171
+ if (parsed.data.state === 'done') {
172
+ await orch.checkpointDone({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, ...(parsed.data.commitSha ? { commitSha: parsed.data.commitSha } : {}) });
173
+ }
174
+ else if (parsed.data.state === 'skipped') {
175
+ await orch.checkpointSkipped({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, reason: parsed.data.reason });
176
+ }
177
+ else {
178
+ await orch.checkpointFailed({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, reason: parsed.data.reason });
179
+ }
180
+ printResult(io, ok('checkpoint', { sliceId: parsed.data.sliceId, status: parsed.data.state }), opts);
181
+ });
182
+ addJsonOption(job.commands.find(c => c.name() === 'checkpoint'));
183
+ job
184
+ .command('block')
185
+ .requiredOption('--job-id <jid>')
186
+ .requiredOption('--slice-id <rid>')
187
+ .requiredOption('--reason <text>')
188
+ .option('--project <repo>')
189
+ .action(async (opts) => {
190
+ const parsed = JobBlockInputSchema.safeParse({
191
+ jobId: opts.jobId, sliceId: opts.sliceId, reason: opts.reason,
192
+ project: projectRoot(opts), json: opts.json,
193
+ });
194
+ if (!parsed.success)
195
+ return printResult(io, fail('block', 'INVALID_BLOCK', parsed.error.message, {}), opts);
196
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
197
+ const orch = new JobOrchestrator(store);
198
+ await orch.blockSlice(parsed.data);
199
+ printResult(io, ok('block', { blocked: parsed.data.sliceId, reason: parsed.data.reason }), opts);
200
+ });
201
+ addJsonOption(job.commands.find(c => c.name() === 'block'));
202
+ job
203
+ .command('continue')
204
+ .requiredOption('--job-id <jid>')
205
+ .option('--project <repo>')
206
+ .action(async (opts) => {
207
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
208
+ const orch = new JobOrchestrator(store);
209
+ const r = orch.continueNow(opts.jobId);
210
+ printResult(io, ok('continue', r), opts);
211
+ });
212
+ addJsonOption(job.commands.find(c => c.name() === 'continue'));
213
+ job
214
+ .command('resume')
215
+ .requiredOption('--job-id <jid>')
216
+ .option('--project <repo>')
217
+ .action(async (opts) => {
218
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
219
+ const orch = new JobOrchestrator(store);
220
+ const s = orch.status(opts.jobId);
221
+ printResult(io, ok('resume', { resumed: opts.jobId, ...s }), opts);
222
+ });
223
+ addJsonOption(job.commands.find(c => c.name() === 'resume'));
224
+ job
225
+ .command('handoff')
226
+ .requiredOption('--job-id <jid>')
227
+ .option('--project <repo>')
228
+ .action(async (opts) => {
229
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
230
+ const orch = new JobOrchestrator(store);
231
+ const s = orch.status(opts.jobId);
232
+ printResult(io, ok('handoff', { handoffFor: opts.jobId, ...s }), opts);
233
+ });
234
+ addJsonOption(job.commands.find(c => c.name() === 'handoff'));
235
+ program.addCommand(job);
236
+ }
@@ -15,6 +15,8 @@
15
15
  * - `peaks solo should-pause --step <step> --mode <mode>` (D5)
16
16
  * - `peaks solo post-compact-detect --project <path>` (D7)
17
17
  */
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
18
20
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
19
21
  import { fail, ok } from '../../shared/result.js';
20
22
  import { emitObservabilityEvent } from '../../services/observability/observability-service.js';
@@ -23,6 +25,7 @@ import { GATED_STEPS, isHardFloorCategory, isSoloMode, isCommitBoundaryAction, s
23
25
  import { getSkillPresence, checkStalePresence } from '../../services/skills/skill-presence-service.js';
24
26
  import { detectPostCompactResume, formatPostCompactResumeLogLine } from '../../services/solo/post-compact-detector.js';
25
27
  import { runAutoCompact } from '../../services/solo/auto-compact-orchestrator.js';
28
+ import { readJobShapeDecision, writeJobShapeDecision, JobShapeDecisionError, JOB_SHAPE_NOT_DECIDED, JOB_SHAPE_ALREADY_DECIDED } from '../../services/solo/job-shape-decision.js';
26
29
  const STEP_ORDER = [
27
30
  'load-memory',
28
31
  'standards-preflight',
@@ -421,6 +424,140 @@ export function registerSoloCommands(program, io) {
421
424
  process.exitCode = 1;
422
425
  }
423
426
  });
427
+ // v3.1.1 Step 0.8 — Job-shape decision recorder.
428
+ // The CLI is a *recorder*, not a judge. The LLM supplies the verdict
429
+ // (--is-job, --rationale, --suggested-job-id, --suggested-strategy,
430
+ // --confidence); the CLI validates the shape, stamps `decidedAt`
431
+ // server-side, and writes to `.peaks/_runtime/<sessionId>/job-shape.json`.
432
+ addJsonOption(solo
433
+ .command('detect-job')
434
+ .description('v3.1.1 Step 0.8: record the LLM\'s Job-shape judgement. CLI is a recorder, NOT a detector. ' +
435
+ 'Pass --is-job true|false, --rationale <text>, --suggested-job-id <slug>. ' +
436
+ 'Downstream steps call `peaks solo read-job-shape` and refuse to proceed if missing.')
437
+ .requiredOption('--is-job <bool>', 'true | false (the LLM\'s semantic verdict)')
438
+ .requiredOption('--rationale <text>', '1-3 sentences the LLM writes explaining the call')
439
+ .requiredOption('--suggested-job-id <jid>', 'LLM-chosen stable id, slug-safe (/^[a-z0-9][a-z0-9-]{2,40}$/)')
440
+ .option('--suggested-strategy <single|rotating>', 'single | rotating', 'single')
441
+ .option('--confidence <high|medium|low>', 'high | medium | low', 'medium')
442
+ .option('--force', 'overwrite an existing decision file')
443
+ .option('--session-id <sid>', 'override session id (default: read from active presence)')
444
+ .option('--project <path>', 'target project root (default: findProjectRoot(cwd))')
445
+ .option('--prompt <text>', 'override the prompt hashed into promptHash (default: read last-prompt.txt if present, else empty)')).action(async (opts) => {
446
+ try {
447
+ if (opts.isJob !== 'true' && opts.isJob !== 'false') {
448
+ printResult(io, fail('solo.detect-job', 'INVALID_FLAG', `--is-job must be true | false (got "${opts.isJob}")`, { provided: opts.isJob }, ['Pass --is-job true or --is-job false']), opts.json);
449
+ process.exitCode = 1;
450
+ return;
451
+ }
452
+ if (opts.suggestedStrategy !== 'single' && opts.suggestedStrategy !== 'rotating') {
453
+ printResult(io, fail('solo.detect-job', 'INVALID_FLAG', `--suggested-strategy must be single | rotating (got "${opts.suggestedStrategy}")`, { provided: opts.suggestedStrategy }, ['Pass --suggested-strategy single or --suggested-strategy rotating']), opts.json);
454
+ process.exitCode = 1;
455
+ return;
456
+ }
457
+ if (opts.confidence !== 'high' && opts.confidence !== 'medium' && opts.confidence !== 'low') {
458
+ printResult(io, fail('solo.detect-job', 'INVALID_FLAG', `--confidence must be high | medium | low (got "${opts.confidence}")`, { provided: opts.confidence }, ['Pass --confidence high | medium | low']), opts.json);
459
+ process.exitCode = 1;
460
+ return;
461
+ }
462
+ const jidRe = /^[a-z0-9][a-z0-9-]{2,40}$/;
463
+ if (!jidRe.test(opts.suggestedJobId)) {
464
+ printResult(io, fail('solo.detect-job', 'INVALID_FLAG', `--suggested-job-id must match /^[a-z0-9][a-z0-9-]{2,40}$/ (got "${opts.suggestedJobId}")`, { provided: opts.suggestedJobId }, ['Pass a slug-safe id (lowercase, digits, dashes; 3-41 chars)']), opts.json);
465
+ process.exitCode = 1;
466
+ return;
467
+ }
468
+ const projectRoot = opts.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
469
+ const sessionId = opts.sessionId ?? readActiveSid(projectRoot);
470
+ if (sessionId === null) {
471
+ printResult(io, fail('solo.detect-job', 'NO_ACTIVE_SESSION', 'no active session id; pass --session-id or set presence via `peaks skill presence:set peaks-solo`', null, ['Re-run with --session-id <sid>']), opts.json);
472
+ process.exitCode = 1;
473
+ return;
474
+ }
475
+ // Prompt source: explicit --prompt > last-prompt.txt > empty.
476
+ let promptText = opts.prompt ?? '';
477
+ if (opts.prompt === undefined) {
478
+ const lastPromptPath = join(projectRoot, '.peaks', '_runtime', sessionId, 'txt', 'last-prompt.txt');
479
+ if (existsSync(lastPromptPath)) {
480
+ try {
481
+ promptText = readFileSync(lastPromptPath, 'utf8');
482
+ }
483
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release
484
+ promptText = '';
485
+ }
486
+ }
487
+ }
488
+ const record = writeJobShapeDecision(projectRoot, sessionId, {
489
+ isJob: opts.isJob === 'true',
490
+ rationale: opts.rationale,
491
+ suggestedJobId: opts.suggestedJobId,
492
+ suggestedStrategy: opts.suggestedStrategy,
493
+ confidence: opts.confidence,
494
+ prompt: promptText
495
+ }, { force: opts.force === true });
496
+ printResult(io, ok('solo.detect-job', {
497
+ sessionId: record.sessionId,
498
+ promptHash: record.promptHash,
499
+ decision: record.decision,
500
+ schemaVersion: record.schemaVersion
501
+ }, [], [
502
+ `Decision recorded: isJob=${record.decision.isJob} strategy=${record.decision.suggestedStrategy} confidence=${record.decision.confidence}`,
503
+ `File: .peaks/_runtime/${record.sessionId}/job-shape.json`
504
+ ]), opts.json);
505
+ }
506
+ catch (err) {
507
+ if (err instanceof JobShapeDecisionError) {
508
+ const code = err.code === JOB_SHAPE_ALREADY_DECIDED ? JOB_SHAPE_ALREADY_DECIDED : err.code === JOB_SHAPE_NOT_DECIDED ? JOB_SHAPE_NOT_DECIDED : err.code;
509
+ printResult(io, fail('solo.detect-job', code, err.message, err.details ?? null, [
510
+ err.code === JOB_SHAPE_ALREADY_DECIDED
511
+ ? 'Re-run with --force to overwrite the existing decision.'
512
+ : 'Re-run with valid flags.'
513
+ ]), opts.json);
514
+ process.exitCode = 1;
515
+ return;
516
+ }
517
+ printResult(io, fail('solo.detect-job', 'DETECT_JOB_FAILED', getErrorMessage(err), null, ['Verify the flags and try again']), opts.json);
518
+ process.exitCode = 1;
519
+ }
520
+ });
521
+ // v3.1.1 Step 0.8 — read-only validator. Downstream steps call this
522
+ // to refuse to proceed if the LLM has not yet recorded a decision.
523
+ addJsonOption(solo
524
+ .command('read-job-shape')
525
+ .description('v3.1.1 Step 0.8: return the recorded Job-shape decision. ' +
526
+ 'Returns JOB_SHAPE_NOT_DECIDED when the LLM has not yet recorded a verdict.')
527
+ .option('--session-id <sid>', 'override session id (default: read from active presence)')
528
+ .option('--project <path>', 'target project root (default: findProjectRoot(cwd))')).action((opts) => {
529
+ try {
530
+ const projectRoot = opts.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
531
+ const sessionId = opts.sessionId ?? readActiveSid(projectRoot);
532
+ if (sessionId === null) {
533
+ printResult(io, fail('solo.read-job-shape', 'NO_ACTIVE_SESSION', 'no active session id; pass --session-id or set presence via `peaks skill presence:set peaks-solo`', null, ['Re-run with --session-id <sid>']), opts.json);
534
+ process.exitCode = 1;
535
+ return;
536
+ }
537
+ const record = readJobShapeDecision(projectRoot, sessionId);
538
+ printResult(io, ok('solo.read-job-shape', {
539
+ sessionId: record.sessionId,
540
+ promptHash: record.promptHash,
541
+ decision: record.decision,
542
+ schemaVersion: record.schemaVersion
543
+ }, [], [
544
+ `Decision on file: isJob=${record.decision.isJob} strategy=${record.decision.suggestedStrategy} confidence=${record.decision.confidence}`
545
+ ]), opts.json);
546
+ }
547
+ catch (err) {
548
+ if (err instanceof JobShapeDecisionError) {
549
+ printResult(io, fail('solo.read-job-shape', err.code, err.message, err.details ?? null, [
550
+ err.code === JOB_SHAPE_NOT_DECIDED
551
+ ? 'Run `peaks solo detect-job` to record a decision.'
552
+ : 'Investigate the decision file integrity.'
553
+ ]), opts.json);
554
+ process.exitCode = 1;
555
+ return;
556
+ }
557
+ printResult(io, fail('solo.read-job-shape', 'READ_JOB_SHAPE_FAILED', getErrorMessage(err), null, ['Verify the project path and try again']), opts.json);
558
+ process.exitCode = 1;
559
+ }
560
+ });
424
561
  }
425
562
  function readActiveSid(projectRoot) {
426
563
  try {
@@ -74,6 +74,8 @@ import { registerObservabilityCommands } from './commands/observability-commands
74
74
  // CLI primitives. See src/services/compact/ and
75
75
  // src/cli/commands/compact-command.ts.
76
76
  import { registerCompactCommands } from './commands/compact-command.js';
77
+ // Slice 2026-07-01-job-m3.1: `peaks job init|status|...` — Job orchestration CLI surface.
78
+ import { registerJobCommands } from './commands/job-commands.js';
77
79
  import { applyRetention } from '../services/log/retention.js';
78
80
  import { writeLogEntry, maybeWriteStderr } from '../services/log/logger.js';
79
81
  export { printResult } from './cli-helpers.js';
@@ -341,5 +343,9 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
341
343
  // suggest|recommend|survival|dry-run|force` — strategic-compact CLI
342
344
  // primitives. See src/cli/commands/compact-command.ts.
343
345
  registerCompactCommands(program, io);
346
+ // Slice 2026-07-01-job-m3.1: `peaks job init|status|...` — Job
347
+ // orchestration CLI surface. M3.1 wires init + status + 2 stubs;
348
+ // the remaining 5 subcommands land in M3.2.
349
+ registerJobCommands(program, io);
344
350
  return program;
345
351
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Stub Job event emitter. Logs to stderr so Job lifecycle is visible during CLI runs.
3
+ * M7.5 batch-fix task will wire to a real statusline event bus if/when the project
4
+ * adds one. Until then, this is the only Job-event surface.
5
+ */
6
+ export type JobEvent = {
7
+ kind: 'job-started';
8
+ jobId: string;
9
+ total: number;
10
+ strategy: 'single' | 'rotating';
11
+ } | {
12
+ kind: 'job-progress';
13
+ jobId: string;
14
+ done: number;
15
+ total: number;
16
+ currentSlice?: string;
17
+ } | {
18
+ kind: 'job-blocked';
19
+ jobId: string;
20
+ sliceId: string;
21
+ reason: string;
22
+ } | {
23
+ kind: 'job-completed';
24
+ jobId: string;
25
+ done: number;
26
+ failed: number;
27
+ blocked: number;
28
+ skipped: number;
29
+ };
30
+ export declare function emitJobEvent(event: JobEvent): void;
@@ -0,0 +1,5 @@
1
+ export function emitJobEvent(event) {
2
+ // Write to stderr so it doesn't pollute the JSON-envelope stdout that the CLI emits.
3
+ // Prepend a tag so downstream tools can grep for it.
4
+ process.stderr.write(`[job-event] ${JSON.stringify(event)}\n`);
5
+ }
@@ -0,0 +1,50 @@
1
+ import { JobStateStore, type JobInitInput } from './job-state-store.js';
2
+ import { type JobState, type JobStatusSummary } from './job-types.js';
3
+ export interface CheckpointDoneInput {
4
+ jobId: string;
5
+ sliceId: string;
6
+ commitSha?: string;
7
+ }
8
+ export interface CheckpointSkipInput {
9
+ jobId: string;
10
+ sliceId: string;
11
+ reason: string;
12
+ }
13
+ export interface BlockInput {
14
+ jobId: string;
15
+ sliceId: string;
16
+ reason: string;
17
+ }
18
+ export declare class JobOrchestrator {
19
+ private readonly store;
20
+ constructor(store: JobStateStore);
21
+ init(input: JobInitInput): JobState;
22
+ /**
23
+ * Atomically apply a mutation to a job's state under a single-process lock.
24
+ *
25
+ * Uses the M2.1 single-process lock model (lock file checked with `existsSync`).
26
+ * State is loaded before lock acquisition, so `fn` receives a consistent snapshot;
27
+ * if `tryAcquireLock` throws because the job is already locked, `fn` is never called
28
+ * and the error bubbles to the caller. Concurrent `mutate` calls in the same process
29
+ * will collide, so callers MUST serialize externally or wrap calls in a higher-level
30
+ * mutex if multi-process support is added later.
31
+ *
32
+ * @param jobId - The job identifier whose state will be mutated.
33
+ * @param fn - Pure transform applied to the loaded state to produce the next state.
34
+ * @returns The new state after the mutation is persisted.
35
+ */
36
+ private mutate;
37
+ checkpointDone(input: CheckpointDoneInput): Promise<JobState>;
38
+ checkpointSkipped(input: CheckpointSkipInput): Promise<JobState>;
39
+ checkpointFailed(input: {
40
+ jobId: string;
41
+ sliceId: string;
42
+ reason: string;
43
+ }): Promise<JobState>;
44
+ blockSlice(input: BlockInput): Promise<JobState>;
45
+ status(jobId: string): JobStatusSummary;
46
+ continueNow(jobId: string): {
47
+ remaining: number;
48
+ next: string | undefined;
49
+ };
50
+ }