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.
@@ -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;
@@ -0,0 +1,79 @@
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
+ import { existsSync, readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { readJobShapeDecision, JobShapeDecisionError, JOB_SHAPE_NOT_DECIDED } from './job-shape-decision.js';
25
+ export const JOB_NOT_INITIALIZED = 'JOB_NOT_INITIALIZED';
26
+ export const JOB_REMAINING_BLOCKED = 'JOB_REMAINING_BLOCKED';
27
+ function countRemaining(statePath) {
28
+ let raw;
29
+ try {
30
+ raw = readFileSync(statePath, 'utf8');
31
+ }
32
+ catch {
33
+ return Number.NaN;
34
+ }
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(raw);
38
+ }
39
+ catch {
40
+ return Number.NaN;
41
+ }
42
+ if (!Array.isArray(parsed.slices))
43
+ return Number.NaN;
44
+ return parsed.slices.filter((sl) => sl.status !== 'done' && sl.status !== 'skipped').length;
45
+ }
46
+ function statePath(projectRoot, sessionId, jobId) {
47
+ return join(projectRoot, '.peaks', '_runtime', sessionId, 'job', jobId, 'state.json');
48
+ }
49
+ export function evaluateEmitHandoff(input) {
50
+ let decision = null;
51
+ try {
52
+ const record = readJobShapeDecision(input.projectRoot, input.sessionId);
53
+ decision = record.decision;
54
+ }
55
+ catch (err) {
56
+ if (err instanceof JobShapeDecisionError && err.code === JOB_SHAPE_NOT_DECIDED) {
57
+ return { kind: 'allow-not-job' };
58
+ }
59
+ throw err;
60
+ }
61
+ if (!decision.isJob)
62
+ return { kind: 'allow-not-job' };
63
+ const jobId = input.jobId ?? decision.suggestedJobId;
64
+ const path = statePath(input.projectRoot, input.sessionId, jobId);
65
+ if (!existsSync(path)) {
66
+ return { kind: 'block-not-initialized', code: JOB_NOT_INITIALIZED, jobId };
67
+ }
68
+ const remaining = countRemaining(path);
69
+ if (!Number.isFinite(remaining)) {
70
+ return { kind: 'block-not-initialized', code: JOB_NOT_INITIALIZED, jobId };
71
+ }
72
+ if (remaining === 0) {
73
+ return { kind: 'allow-done', remaining };
74
+ }
75
+ if (input.forceUnderJob === true) {
76
+ return { kind: 'allow-force-override', remaining };
77
+ }
78
+ return { kind: 'block-remaining', code: JOB_REMAINING_BLOCKED, jobId, remaining };
79
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * v3.1.1 Step 0.8 — Job-shape decision recorder.
3
+ *
4
+ * The CLI is a *recorder and gate*, not a judge. The LLM makes the
5
+ * semantic call ("is this a Job?") and writes the verdict via
6
+ * `peaks solo detect-job`; this service persists the verdict to
7
+ * `.peaks/_runtime/<sessionId>/job-shape.json` and refuses downstream
8
+ * steps that need a decision when the file is missing.
9
+ *
10
+ * Karpathy §2 (Simplicity First): ~100 lines, pure validation + fs
11
+ * writes, NO keyword regex, NO LLM call inside. The LLM is the
12
+ * source of truth for whether the request is Job-shaped; this file
13
+ * just enforces that a decision was made at all.
14
+ *
15
+ * Decision file lives at `.peaks/_runtime/<sessionId>/job-shape.json`
16
+ * (sibling of `session.json`, NOT under `job/<jid>/` because no jid
17
+ * exists yet at the moment of Step 0.8).
18
+ */
19
+ export type JobStrategy = 'single' | 'rotating';
20
+ export type JobConfidence = 'high' | 'medium' | 'low';
21
+ export interface JobShapeDecision {
22
+ readonly isJob: boolean;
23
+ readonly rationale: string;
24
+ readonly suggestedJobId: string;
25
+ readonly suggestedStrategy: JobStrategy;
26
+ readonly confidence: JobConfidence;
27
+ /** ISO 8601; stamped server-side in `writeJobShapeDecision`. */
28
+ readonly decidedAt: string;
29
+ }
30
+ export interface JobShapeRecord {
31
+ readonly sessionId: string;
32
+ /** sha1 of the user prompt (first 16 hex). */
33
+ readonly promptHash: string;
34
+ readonly decision: JobShapeDecision;
35
+ readonly schemaVersion: 1;
36
+ }
37
+ export declare const JOB_SHAPE_SCHEMA_VERSION: 1;
38
+ export declare const JOB_SHAPE_FILE_NAME = "job-shape.json";
39
+ export declare const JOB_SHAPE_ALREADY_DECIDED: "JOB_SHAPE_ALREADY_DECIDED";
40
+ export declare const JOB_SHAPE_NOT_DECIDED: "JOB_SHAPE_NOT_DECIDED";
41
+ export declare const JOB_SHAPE_INVALID: "JOB_SHAPE_INVALID";
42
+ export declare class JobShapeDecisionError extends Error {
43
+ readonly code: typeof JOB_SHAPE_NOT_DECIDED | typeof JOB_SHAPE_ALREADY_DECIDED | typeof JOB_SHAPE_INVALID;
44
+ readonly details?: unknown;
45
+ constructor(opts: {
46
+ code: typeof JobShapeDecisionError.prototype.code;
47
+ message: string;
48
+ details?: unknown;
49
+ });
50
+ }
51
+ /** Pure validator; throws on bad input. No fs IO. */
52
+ export declare function validateJobShapeDecision(input: unknown): JobShapeDecision;
53
+ /** Read the canonical record; throws JOB_SHAPE_NOT_DECIDED if absent or malformed. */
54
+ export declare function readJobShapeDecision(projectRoot: string, sessionId: string): JobShapeRecord;
55
+ export interface WriteJobShapeDecisionInput {
56
+ readonly isJob: boolean;
57
+ readonly rationale: string;
58
+ readonly suggestedJobId: string;
59
+ readonly suggestedStrategy: JobStrategy;
60
+ readonly confidence: JobConfidence;
61
+ readonly prompt: string;
62
+ }
63
+ /**
64
+ * Persist a new decision; refuses overwrite unless `force: true`.
65
+ * Stamps `decidedAt` server-side via `now` (defaults to `new Date()`)
66
+ * so the LLM cannot back-date.
67
+ */
68
+ export declare function writeJobShapeDecision(projectRoot: string, sessionId: string, decision: WriteJobShapeDecisionInput, opts?: {
69
+ force?: boolean;
70
+ now?: () => Date;
71
+ }): JobShapeRecord;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * v3.1.1 Step 0.8 — Job-shape decision recorder.
3
+ *
4
+ * The CLI is a *recorder and gate*, not a judge. The LLM makes the
5
+ * semantic call ("is this a Job?") and writes the verdict via
6
+ * `peaks solo detect-job`; this service persists the verdict to
7
+ * `.peaks/_runtime/<sessionId>/job-shape.json` and refuses downstream
8
+ * steps that need a decision when the file is missing.
9
+ *
10
+ * Karpathy §2 (Simplicity First): ~100 lines, pure validation + fs
11
+ * writes, NO keyword regex, NO LLM call inside. The LLM is the
12
+ * source of truth for whether the request is Job-shaped; this file
13
+ * just enforces that a decision was made at all.
14
+ *
15
+ * Decision file lives at `.peaks/_runtime/<sessionId>/job-shape.json`
16
+ * (sibling of `session.json`, NOT under `job/<jid>/` because no jid
17
+ * exists yet at the moment of Step 0.8).
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { z } from 'zod';
23
+ export const JOB_SHAPE_SCHEMA_VERSION = 1;
24
+ export const JOB_SHAPE_FILE_NAME = 'job-shape.json';
25
+ export const JOB_SHAPE_ALREADY_DECIDED = 'JOB_SHAPE_ALREADY_DECIDED';
26
+ export const JOB_SHAPE_NOT_DECIDED = 'JOB_SHAPE_NOT_DECIDED';
27
+ export const JOB_SHAPE_INVALID = 'JOB_SHAPE_INVALID';
28
+ const SUGGESTED_JID_RE = /^[a-z0-9][a-z0-9-]{2,40}$/;
29
+ const JobShapeDecisionSchema = z.object({
30
+ isJob: z.boolean(),
31
+ rationale: z.string().min(1).max(2000),
32
+ suggestedJobId: z.string().regex(SUGGESTED_JID_RE, 'suggestedJobId must match /^[a-z0-9][a-z0-9-]{2,40}$/'),
33
+ suggestedStrategy: z.enum(['single', 'rotating']),
34
+ confidence: z.enum(['high', 'medium', 'low']),
35
+ decidedAt: z.string().datetime()
36
+ });
37
+ const JobShapeRecordSchema = z.object({
38
+ sessionId: z.string().min(1),
39
+ promptHash: z.string().regex(/^[a-f0-9]{16}$/, 'promptHash must be 16 hex chars'),
40
+ decision: JobShapeDecisionSchema,
41
+ schemaVersion: z.literal(JOB_SHAPE_SCHEMA_VERSION)
42
+ });
43
+ export class JobShapeDecisionError extends Error {
44
+ code;
45
+ details;
46
+ constructor(opts) {
47
+ super(opts.message);
48
+ this.name = 'JobShapeDecisionError';
49
+ this.code = opts.code;
50
+ if (opts.details !== undefined)
51
+ this.details = opts.details;
52
+ }
53
+ }
54
+ function runtimeDir(projectRoot, sessionId) {
55
+ return join(projectRoot, '.peaks', '_runtime', sessionId);
56
+ }
57
+ function decisionPath(projectRoot, sessionId) {
58
+ return join(runtimeDir(projectRoot, sessionId), JOB_SHAPE_FILE_NAME);
59
+ }
60
+ function sha1Prefix16(input) {
61
+ return createHash('sha1').update(input, 'utf8').digest('hex').slice(0, 16);
62
+ }
63
+ /** Pure validator; throws on bad input. No fs IO. */
64
+ export function validateJobShapeDecision(input) {
65
+ return JobShapeDecisionSchema.parse(input);
66
+ }
67
+ function validateRecord(input) {
68
+ return JobShapeRecordSchema.parse(input);
69
+ }
70
+ /** Read the canonical record; throws JOB_SHAPE_NOT_DECIDED if absent or malformed. */
71
+ export function readJobShapeDecision(projectRoot, sessionId) {
72
+ const path = decisionPath(projectRoot, sessionId);
73
+ if (!existsSync(path)) {
74
+ throw new JobShapeDecisionError({
75
+ code: JOB_SHAPE_NOT_DECIDED,
76
+ message: `No Job-shape decision recorded for session ${sessionId}. Run \`peaks solo detect-job\` to record a decision.`,
77
+ details: { path }
78
+ });
79
+ }
80
+ let raw;
81
+ try {
82
+ raw = readFileSync(path, 'utf8');
83
+ }
84
+ catch (err) {
85
+ throw new JobShapeDecisionError({
86
+ code: JOB_SHAPE_NOT_DECIDED,
87
+ message: `Job-shape decision file is unreadable for session ${sessionId}. Run \`peaks solo detect-job\` to re-record.`,
88
+ details: { path, cause: err instanceof Error ? err.message : String(err) }
89
+ });
90
+ }
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(raw);
94
+ }
95
+ catch (err) {
96
+ throw new JobShapeDecisionError({
97
+ code: JOB_SHAPE_NOT_DECIDED,
98
+ message: `Job-shape decision file is malformed JSON for session ${sessionId}. Run \`peaks solo detect-job --force\` to overwrite.`,
99
+ details: { path, cause: err instanceof Error ? err.message : String(err) }
100
+ });
101
+ }
102
+ try {
103
+ return validateRecord(parsed);
104
+ }
105
+ catch (err) {
106
+ throw new JobShapeDecisionError({
107
+ code: JOB_SHAPE_NOT_DECIDED,
108
+ message: `Job-shape decision file is schema-invalid for session ${sessionId}. Run \`peaks solo detect-job --force\` to overwrite.`,
109
+ details: { path, cause: err instanceof Error ? err.message : String(err) }
110
+ });
111
+ }
112
+ }
113
+ /**
114
+ * Persist a new decision; refuses overwrite unless `force: true`.
115
+ * Stamps `decidedAt` server-side via `now` (defaults to `new Date()`)
116
+ * so the LLM cannot back-date.
117
+ */
118
+ export function writeJobShapeDecision(projectRoot, sessionId, decision, opts = {}) {
119
+ const path = decisionPath(projectRoot, sessionId);
120
+ if (existsSync(path) && opts.force !== true) {
121
+ throw new JobShapeDecisionError({
122
+ code: JOB_SHAPE_ALREADY_DECIDED,
123
+ message: `Job-shape decision already exists for session ${sessionId}. Re-run with --force to overwrite.`,
124
+ details: { path }
125
+ });
126
+ }
127
+ // Validate the LLM-supplied fields first so a bad call fails loud
128
+ // before we touch the filesystem.
129
+ const partial = {
130
+ isJob: decision.isJob,
131
+ rationale: decision.rationale,
132
+ suggestedJobId: decision.suggestedJobId,
133
+ suggestedStrategy: decision.suggestedStrategy,
134
+ confidence: decision.confidence
135
+ };
136
+ // Validate the LLM-supplied shape (decidedAt is server-stamped).
137
+ JobShapeDecisionSchema.omit({ decidedAt: true }).parse(partial);
138
+ const now = opts.now ?? (() => new Date());
139
+ const stamped = { ...partial, decidedAt: now().toISOString() };
140
+ const promptHash = sha1Prefix16(decision.prompt);
141
+ const record = {
142
+ sessionId,
143
+ promptHash,
144
+ decision: stamped,
145
+ schemaVersion: JOB_SHAPE_SCHEMA_VERSION
146
+ };
147
+ const dir = runtimeDir(projectRoot, sessionId);
148
+ mkdirSync(dir, { recursive: true });
149
+ writeFileSync(path, JSON.stringify(record, null, 2) + '\n', 'utf8');
150
+ return record;
151
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * v3.1.2 Step 0.8 — Mechanical PreToolUse gate.
3
+ *
4
+ * Wire-installed by `peaks workspace init` (extends the existing hook
5
+ * installer; does NOT replace the Write|Edit|MultiEdit fact-forcing
6
+ * bypass). The hook runs `peaks solo gate-step-08 --project .` before
7
+ * every Bash tool call. Exit code is the load-bearing contract:
8
+ *
9
+ * exit 0 → allow (with structured stdout describing the decision)
10
+ * exit 2 → block (stderr contains the BLOCKED: ... reason)
11
+ *
12
+ * Three decision paths:
13
+ *
14
+ * 1. `job-shape.json` exists with `decision.isJob === true`.
15
+ * → allow, print `{ ok, allow, mode: 'job', decision }`. When a
16
+ * matching `job/<jid>/progress.json` exists, also print
17
+ * `Next: slice #<N+1> of <M> (<currentSlice>)` so the LLM sees
18
+ * its resume context BEFORE any Bash call lands.
19
+ * 2. `job-shape.json` exists with `decision.isJob === false`.
20
+ * → allow, print `{ ok, allow, mode: 'single' }`.
21
+ * 3. `job-shape.json` is MISSING — fail-closed guard. The LLM is the
22
+ * source of truth, but if it never recorded a decision we run a
23
+ * lightweight backup regex against the user's prompt:
24
+ * /until|全部|until all done|disavow cost|不用考虑费用|all of them/i
25
+ * match → block (exit 2) with the BLOCKED: stderr message.
26
+ * no match → allow (exit 0) — most prompts are not Job-shaped.
27
+ *
28
+ * Karpathy §2 (Simplicity First): ~120 lines, no LLM call inside. The
29
+ * regex is the FIRST time peaks-loop accepts hardcoded keywords —
30
+ * explicitly scoped as a *fail-closed backup* (case 3), NOT a primary
31
+ * judgement. The LLM still owns the semantic call via
32
+ * `peaks solo detect-job`. The regex is a safety net for the v3.1.1
33
+ * incident class (LLM under load never records the decision at all).
34
+ */
35
+ export declare const STEP_08_GATE_FILE_NAME: "job-shape.json";
36
+ export declare const STEP_08_PROGRESS_FILE_NAME: "progress.json";
37
+ /**
38
+ * Fail-closed backup regex — see module docstring case 3 for why this
39
+ * exists. Kept short and explicit; if the prompt mentions any of these
40
+ * triggers and `job-shape.json` is missing, the LLM is treated as
41
+ * having skipped `peaks solo detect-job` and the gate blocks.
42
+ *
43
+ * The list mirrors the v3.1.0 / v3.1.1 incident triggers verbatim:
44
+ * 继续执行下个 slice (until next slice) → until / 直到
45
+ * 全部添加完 / all of them → 全部 / all of them
46
+ * 不用考虑费用 → 不用考虑费用 / disavow cost
47
+ * until all done → until all done
48
+ */
49
+ export declare const STEP_08_BACKUP_REGEX: RegExp;
50
+ export interface Step08Progress {
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 type Step08Verdict = {
59
+ readonly kind: 'allow-job';
60
+ readonly decision: import('./job-shape-decision.js').JobShapeDecision;
61
+ readonly progress: Step08Progress | null;
62
+ } | {
63
+ readonly kind: 'allow-single';
64
+ } | {
65
+ readonly kind: 'block-missing-decision';
66
+ readonly promptHit: boolean;
67
+ readonly promptSource: 'flag' | 'last-prompt-file' | 'stdin-empty';
68
+ };
69
+ export declare function runtimeSessionDir(projectRoot: string, sessionId: string): string;
70
+ export interface EvaluateStep08Input {
71
+ readonly projectRoot: string;
72
+ readonly sessionId: string;
73
+ /**
74
+ * Explicit prompt text. When omitted, falls back to
75
+ * `.peaks/_runtime/<sid>/txt/last-prompt.txt`, then to stdin (the
76
+ * caller passes stdin verbatim — usually empty in tests).
77
+ */
78
+ readonly prompt?: string;
79
+ }
80
+ export interface EvaluateStep08Result {
81
+ readonly allow: boolean;
82
+ readonly verdict: Step08Verdict;
83
+ readonly nextSliceLine: string | null;
84
+ }
85
+ /**
86
+ * Pure evaluator — does NOT write, does NOT exit. CLI wrapper in
87
+ * `src/cli/commands/solo-commands.ts` reads `allow` to set the process
88
+ * exit code (0 allow / 2 block) and prints the structured envelope.
89
+ */
90
+ export declare function evaluateStep08(input: EvaluateStep08Input): EvaluateStep08Result;