peaks-loop 3.1.0 → 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 +44 -0
- package/dist/src/cli/commands/job-commands.js +54 -1
- package/dist/src/cli/commands/solo-commands.js +375 -12
- package/dist/src/services/job/job-progress-store.d.ts +60 -0
- package/dist/src/services/job/job-progress-store.js +70 -0
- package/dist/src/services/job/job-types.d.ts +4 -4
- package/dist/src/services/solo/emit-handoff.d.ts +49 -0
- package/dist/src/services/solo/emit-handoff.js +79 -0
- package/dist/src/services/solo/job-shape-decision.d.ts +71 -0
- package/dist/src/services/solo/job-shape-decision.js +151 -0
- package/dist/src/services/solo/step-08-gate.d.ts +90 -0
- package/dist/src/services/solo/step-08-gate.js +136 -0
- package/dist/src/services/workspace/claude-settings-template.d.ts +6 -1
- package/dist/src/services/workspace/claude-settings-template.js +40 -1
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-solo/SKILL.md +49 -87
- package/skills/peaks-solo/references/runbook.md +46 -1
- package/skills/peaks-solo/references/step-0-8-gate.md +60 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
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
|
+
|
|
27
|
+
## 3.1.1 — 2026-07-03
|
|
28
|
+
|
|
29
|
+
### Added — Step 0.8 detector-as-recorder (LLM-judged, CLI-validated)
|
|
30
|
+
|
|
31
|
+
`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.
|
|
32
|
+
|
|
33
|
+
- 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.
|
|
34
|
+
- New CLI subcommands:
|
|
35
|
+
- `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`.
|
|
36
|
+
- `peaks solo read-job-shape` — downstream gate. Returns the current record or `JOB_SHAPE_NOT_DECIDED`.
|
|
37
|
+
- 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`).
|
|
38
|
+
|
|
39
|
+
### Hard red line #10 (v3.1.1)
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
43
|
+
### Memory
|
|
44
|
+
|
|
45
|
+
Incident + rationale: `.peaks/memory/2026-07-03-v3-1-0-job-trigger-miss.md`. This release closes the gap identified in that memory file.
|
|
46
|
+
|
|
3
47
|
## 3.1.0 — 2026-07-03
|
|
4
48
|
|
|
5
49
|
### Fixed — peaks-solo external-references authority declaration
|
|
@@ -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
|
|
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>')
|
|
@@ -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,9 @@ 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';
|
|
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';
|
|
26
31
|
const STEP_ORDER = [
|
|
27
32
|
'load-memory',
|
|
28
33
|
'standards-preflight',
|
|
@@ -380,38 +385,88 @@ export function registerSoloCommands(program, io) {
|
|
|
380
385
|
'without requiring the LLM to pass --prompt-size <bytes> manually. ' +
|
|
381
386
|
'Adapter-driven (no hard-coded IDE names): Claude Code is the MVP ' +
|
|
382
387
|
'implementation; trae / codex / cursor / qoder / tongyi-lingma / ' +
|
|
383
|
-
'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).')
|
|
384
392
|
.requiredOption('--project <path>', 'target project root')
|
|
385
|
-
.option('--session-id <sid>', 'override session id (default: read from active presence)')
|
|
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) => {
|
|
386
395
|
try {
|
|
387
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
|
+
}
|
|
388
416
|
const probe = readContextPercent({
|
|
389
417
|
projectRoot: opts.project,
|
|
390
418
|
sessionId: opts.sessionId ?? readActiveSid(opts.project) ?? 'unknown',
|
|
391
419
|
env: process.env
|
|
392
420
|
});
|
|
393
421
|
const ratioPct = (probe.ratio * 100).toFixed(1);
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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'
|
|
397
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.';
|
|
398
447
|
printResult(io, ok('solo.context-now', {
|
|
399
448
|
ratio: probe.ratio,
|
|
400
449
|
ratioPct: `${ratioPct}%`,
|
|
401
450
|
verdict,
|
|
451
|
+
action,
|
|
452
|
+
next,
|
|
453
|
+
jobMode: isJobMode,
|
|
402
454
|
source: probe.source,
|
|
403
455
|
ide: probe.ide,
|
|
404
456
|
capacityBytes: probe.capacityBytes,
|
|
405
457
|
rawBytes: probe.rawBytes ?? null,
|
|
406
458
|
capturedAt: probe.capturedAt
|
|
407
459
|
}, [], [
|
|
408
|
-
|
|
409
|
-
? `RED LINE: ≥ 95%.
|
|
410
|
-
:
|
|
411
|
-
? `
|
|
412
|
-
:
|
|
413
|
-
?
|
|
414
|
-
|
|
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
|
|
415
470
|
]), true);
|
|
416
471
|
}
|
|
417
472
|
catch (err) {
|
|
@@ -421,6 +476,314 @@ export function registerSoloCommands(program, io) {
|
|
|
421
476
|
process.exitCode = 1;
|
|
422
477
|
}
|
|
423
478
|
});
|
|
479
|
+
// v3.1.1 Step 0.8 — Job-shape decision recorder.
|
|
480
|
+
// The CLI is a *recorder*, not a judge. The LLM supplies the verdict
|
|
481
|
+
// (--is-job, --rationale, --suggested-job-id, --suggested-strategy,
|
|
482
|
+
// --confidence); the CLI validates the shape, stamps `decidedAt`
|
|
483
|
+
// server-side, and writes to `.peaks/_runtime/<sessionId>/job-shape.json`.
|
|
484
|
+
addJsonOption(solo
|
|
485
|
+
.command('detect-job')
|
|
486
|
+
.description('v3.1.1 Step 0.8: record the LLM\'s Job-shape judgement. CLI is a recorder, NOT a detector. ' +
|
|
487
|
+
'Pass --is-job true|false, --rationale <text>, --suggested-job-id <slug>. ' +
|
|
488
|
+
'Downstream steps call `peaks solo read-job-shape` and refuse to proceed if missing.')
|
|
489
|
+
.requiredOption('--is-job <bool>', 'true | false (the LLM\'s semantic verdict)')
|
|
490
|
+
.requiredOption('--rationale <text>', '1-3 sentences the LLM writes explaining the call')
|
|
491
|
+
.requiredOption('--suggested-job-id <jid>', 'LLM-chosen stable id, slug-safe (/^[a-z0-9][a-z0-9-]{2,40}$/)')
|
|
492
|
+
.option('--suggested-strategy <single|rotating>', 'single | rotating', 'single')
|
|
493
|
+
.option('--confidence <high|medium|low>', 'high | medium | low', 'medium')
|
|
494
|
+
.option('--force', 'overwrite an existing decision file')
|
|
495
|
+
.option('--session-id <sid>', 'override session id (default: read from active presence)')
|
|
496
|
+
.option('--project <path>', 'target project root (default: findProjectRoot(cwd))')
|
|
497
|
+
.option('--prompt <text>', 'override the prompt hashed into promptHash (default: read last-prompt.txt if present, else empty)')).action(async (opts) => {
|
|
498
|
+
try {
|
|
499
|
+
if (opts.isJob !== 'true' && opts.isJob !== 'false') {
|
|
500
|
+
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);
|
|
501
|
+
process.exitCode = 1;
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (opts.suggestedStrategy !== 'single' && opts.suggestedStrategy !== 'rotating') {
|
|
505
|
+
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);
|
|
506
|
+
process.exitCode = 1;
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (opts.confidence !== 'high' && opts.confidence !== 'medium' && opts.confidence !== 'low') {
|
|
510
|
+
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);
|
|
511
|
+
process.exitCode = 1;
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const jidRe = /^[a-z0-9][a-z0-9-]{2,40}$/;
|
|
515
|
+
if (!jidRe.test(opts.suggestedJobId)) {
|
|
516
|
+
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);
|
|
517
|
+
process.exitCode = 1;
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const projectRoot = opts.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
|
|
521
|
+
const sessionId = opts.sessionId ?? readActiveSid(projectRoot);
|
|
522
|
+
if (sessionId === null) {
|
|
523
|
+
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);
|
|
524
|
+
process.exitCode = 1;
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// Prompt source: explicit --prompt > last-prompt.txt > empty.
|
|
528
|
+
let promptText = opts.prompt ?? '';
|
|
529
|
+
if (opts.prompt === undefined) {
|
|
530
|
+
const lastPromptPath = join(projectRoot, '.peaks', '_runtime', sessionId, 'txt', 'last-prompt.txt');
|
|
531
|
+
if (existsSync(lastPromptPath)) {
|
|
532
|
+
try {
|
|
533
|
+
promptText = readFileSync(lastPromptPath, 'utf8');
|
|
534
|
+
}
|
|
535
|
+
catch { // TODO(g2): legacy silent catch — grace: 1 minor release
|
|
536
|
+
promptText = '';
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const record = writeJobShapeDecision(projectRoot, sessionId, {
|
|
541
|
+
isJob: opts.isJob === 'true',
|
|
542
|
+
rationale: opts.rationale,
|
|
543
|
+
suggestedJobId: opts.suggestedJobId,
|
|
544
|
+
suggestedStrategy: opts.suggestedStrategy,
|
|
545
|
+
confidence: opts.confidence,
|
|
546
|
+
prompt: promptText
|
|
547
|
+
}, { force: opts.force === true });
|
|
548
|
+
printResult(io, ok('solo.detect-job', {
|
|
549
|
+
sessionId: record.sessionId,
|
|
550
|
+
promptHash: record.promptHash,
|
|
551
|
+
decision: record.decision,
|
|
552
|
+
schemaVersion: record.schemaVersion
|
|
553
|
+
}, [], [
|
|
554
|
+
`Decision recorded: isJob=${record.decision.isJob} strategy=${record.decision.suggestedStrategy} confidence=${record.decision.confidence}`,
|
|
555
|
+
`File: .peaks/_runtime/${record.sessionId}/job-shape.json`
|
|
556
|
+
]), opts.json);
|
|
557
|
+
}
|
|
558
|
+
catch (err) {
|
|
559
|
+
if (err instanceof JobShapeDecisionError) {
|
|
560
|
+
const code = err.code === JOB_SHAPE_ALREADY_DECIDED ? JOB_SHAPE_ALREADY_DECIDED : err.code === JOB_SHAPE_NOT_DECIDED ? JOB_SHAPE_NOT_DECIDED : err.code;
|
|
561
|
+
printResult(io, fail('solo.detect-job', code, err.message, err.details ?? null, [
|
|
562
|
+
err.code === JOB_SHAPE_ALREADY_DECIDED
|
|
563
|
+
? 'Re-run with --force to overwrite the existing decision.'
|
|
564
|
+
: 'Re-run with valid flags.'
|
|
565
|
+
]), opts.json);
|
|
566
|
+
process.exitCode = 1;
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
printResult(io, fail('solo.detect-job', 'DETECT_JOB_FAILED', getErrorMessage(err), null, ['Verify the flags and try again']), opts.json);
|
|
570
|
+
process.exitCode = 1;
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
// v3.1.1 Step 0.8 — read-only validator. Downstream steps call this
|
|
574
|
+
// to refuse to proceed if the LLM has not yet recorded a decision.
|
|
575
|
+
addJsonOption(solo
|
|
576
|
+
.command('read-job-shape')
|
|
577
|
+
.description('v3.1.1 Step 0.8: return the recorded Job-shape decision. ' +
|
|
578
|
+
'Returns JOB_SHAPE_NOT_DECIDED when the LLM has not yet recorded a verdict.')
|
|
579
|
+
.option('--session-id <sid>', 'override session id (default: read from active presence)')
|
|
580
|
+
.option('--project <path>', 'target project root (default: findProjectRoot(cwd))')).action((opts) => {
|
|
581
|
+
try {
|
|
582
|
+
const projectRoot = opts.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
|
|
583
|
+
const sessionId = opts.sessionId ?? readActiveSid(projectRoot);
|
|
584
|
+
if (sessionId === null) {
|
|
585
|
+
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);
|
|
586
|
+
process.exitCode = 1;
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const record = readJobShapeDecision(projectRoot, sessionId);
|
|
590
|
+
printResult(io, ok('solo.read-job-shape', {
|
|
591
|
+
sessionId: record.sessionId,
|
|
592
|
+
promptHash: record.promptHash,
|
|
593
|
+
decision: record.decision,
|
|
594
|
+
schemaVersion: record.schemaVersion
|
|
595
|
+
}, [], [
|
|
596
|
+
`Decision on file: isJob=${record.decision.isJob} strategy=${record.decision.suggestedStrategy} confidence=${record.decision.confidence}`
|
|
597
|
+
]), opts.json);
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
if (err instanceof JobShapeDecisionError) {
|
|
601
|
+
printResult(io, fail('solo.read-job-shape', err.code, err.message, err.details ?? null, [
|
|
602
|
+
err.code === JOB_SHAPE_NOT_DECIDED
|
|
603
|
+
? 'Run `peaks solo detect-job` to record a decision.'
|
|
604
|
+
: 'Investigate the decision file integrity.'
|
|
605
|
+
]), opts.json);
|
|
606
|
+
process.exitCode = 1;
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
printResult(io, fail('solo.read-job-shape', 'READ_JOB_SHAPE_FAILED', getErrorMessage(err), null, ['Verify the project path and try again']), opts.json);
|
|
610
|
+
process.exitCode = 1;
|
|
611
|
+
}
|
|
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
|
+
});
|
|
424
787
|
}
|
|
425
788
|
function readActiveSid(projectRoot) {
|
|
426
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;
|