peaks-loop 3.1.1 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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;
@@ -0,0 +1,136 @@
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
+ import { existsSync, readFileSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { readJobShapeDecision, JobShapeDecisionError, JOB_SHAPE_NOT_DECIDED } from './job-shape-decision.js';
38
+ export const STEP_08_GATE_FILE_NAME = 'job-shape.json';
39
+ export const STEP_08_PROGRESS_FILE_NAME = 'progress.json';
40
+ /**
41
+ * Fail-closed backup regex — see module docstring case 3 for why this
42
+ * exists. Kept short and explicit; if the prompt mentions any of these
43
+ * triggers and `job-shape.json` is missing, the LLM is treated as
44
+ * having skipped `peaks solo detect-job` and the gate blocks.
45
+ *
46
+ * The list mirrors the v3.1.0 / v3.1.1 incident triggers verbatim:
47
+ * 继续执行下个 slice (until next slice) → until / 直到
48
+ * 全部添加完 / all of them → 全部 / all of them
49
+ * 不用考虑费用 → 不用考虑费用 / disavow cost
50
+ * until all done → until all done
51
+ */
52
+ export const STEP_08_BACKUP_REGEX = /直到|全部|until all done|disavow cost|不用考虑费用|all of them/i;
53
+ export function runtimeSessionDir(projectRoot, sessionId) {
54
+ return join(projectRoot, '.peaks', '_runtime', sessionId);
55
+ }
56
+ function progressPath(projectRoot, sessionId, jid) {
57
+ // The progress file lives under job/<jid>/progress.json. The jid
58
+ // comes from job-shape.json (decision.suggestedJobId) — there is
59
+ // exactly one canonical jid per session under Job mode, so we read
60
+ // the matching path directly rather than enumerating the job/ dir.
61
+ // (Rotating mode spawns new jids but only one is "active" at a time;
62
+ // the active jid is the latest write to job-shape.json.)
63
+ return join(runtimeSessionDir(projectRoot, sessionId), 'job', jid, STEP_08_PROGRESS_FILE_NAME);
64
+ }
65
+ function readProgressIfAny(projectRoot, sessionId, jid) {
66
+ const path = progressPath(projectRoot, sessionId, jid);
67
+ if (!existsSync(path))
68
+ return null;
69
+ try {
70
+ const raw = readFileSync(path, 'utf8');
71
+ const parsed = JSON.parse(raw);
72
+ if (typeof parsed.jobId === 'string' &&
73
+ typeof parsed.done === 'number' &&
74
+ typeof parsed.total === 'number' &&
75
+ typeof parsed.currentSlice === 'string' &&
76
+ typeof parsed.updatedAt === 'string') {
77
+ return {
78
+ jobId: parsed.jobId,
79
+ done: parsed.done,
80
+ total: parsed.total,
81
+ currentSlice: parsed.currentSlice,
82
+ lastCommitSha: typeof parsed.lastCommitSha === 'string' ? parsed.lastCommitSha : null,
83
+ updatedAt: parsed.updatedAt
84
+ };
85
+ }
86
+ return null;
87
+ }
88
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
89
+ return null;
90
+ }
91
+ }
92
+ function readPromptFromLastPromptFile(projectRoot, sessionId) {
93
+ const path = join(runtimeSessionDir(projectRoot, sessionId), 'txt', 'last-prompt.txt');
94
+ if (!existsSync(path))
95
+ return '';
96
+ try {
97
+ return readFileSync(path, 'utf8');
98
+ }
99
+ catch {
100
+ return '';
101
+ }
102
+ }
103
+ /**
104
+ * Pure evaluator — does NOT write, does NOT exit. CLI wrapper in
105
+ * `src/cli/commands/solo-commands.ts` reads `allow` to set the process
106
+ * exit code (0 allow / 2 block) and prints the structured envelope.
107
+ */
108
+ export function evaluateStep08(input) {
109
+ try {
110
+ const record = readJobShapeDecision(input.projectRoot, input.sessionId);
111
+ if (record.decision.isJob) {
112
+ const progress = readProgressIfAny(input.projectRoot, input.sessionId, record.decision.suggestedJobId);
113
+ const nextSliceLine = progress !== null
114
+ ? `Next: slice #${progress.done + 1} of ${progress.total} (${progress.currentSlice})`
115
+ : null;
116
+ return {
117
+ allow: true,
118
+ verdict: { kind: 'allow-job', decision: record.decision, progress },
119
+ nextSliceLine
120
+ };
121
+ }
122
+ return { allow: true, verdict: { kind: 'allow-single' }, nextSliceLine: null };
123
+ }
124
+ catch (err) {
125
+ if (err instanceof JobShapeDecisionError && err.code === JOB_SHAPE_NOT_DECIDED) {
126
+ const promptText = input.prompt ?? readPromptFromLastPromptFile(input.projectRoot, input.sessionId);
127
+ const hit = promptText.length > 0 && STEP_08_BACKUP_REGEX.test(promptText);
128
+ const source = input.prompt !== undefined ? 'flag' : promptText.length > 0 ? 'last-prompt-file' : 'stdin-empty';
129
+ if (hit) {
130
+ return { allow: false, verdict: { kind: 'block-missing-decision', promptHit: true, promptSource: source }, nextSliceLine: null };
131
+ }
132
+ return { allow: true, verdict: { kind: 'block-missing-decision', promptHit: false, promptSource: source }, nextSliceLine: null };
133
+ }
134
+ throw err;
135
+ }
136
+ }
@@ -50,8 +50,13 @@ export declare const CLAUDE_SETTINGS_LOCAL_FILENAME = ".claude/settings.local.js
50
50
  * 1.2.0 — removed the `Bash` matcher; Edit/Write fact-forcing
51
51
  * bypass is the only emit. Bash enforcement is owned by
52
52
  * `peaks gate enforce` in `settings.json`.
53
+ * 1.3.0 — added the v3.1.2 `Bash` PreToolUse matcher that runs
54
+ * `peaks solo gate-step-08 --project .` (Step 0.8 mechanical
55
+ * gate). The existing Write|Edit|MultiEdit matcher is
56
+ * preserved. The new matcher's exit code is the load-bearing
57
+ * signal: 0 = allow, 2 = block (with stderr BLOCKED reason).
53
58
  */
54
- export declare const TEMPLATE_VERSION = "1.2.0";
59
+ export declare const TEMPLATE_VERSION = "1.3.0";
55
60
  /**
56
61
  * Compare two serialized template strings for semantic equivalence.
57
62
  *
@@ -50,8 +50,13 @@ export const CLAUDE_SETTINGS_LOCAL_FILENAME = '.claude/settings.local.json';
50
50
  * 1.2.0 — removed the `Bash` matcher; Edit/Write fact-forcing
51
51
  * bypass is the only emit. Bash enforcement is owned by
52
52
  * `peaks gate enforce` in `settings.json`.
53
+ * 1.3.0 — added the v3.1.2 `Bash` PreToolUse matcher that runs
54
+ * `peaks solo gate-step-08 --project .` (Step 0.8 mechanical
55
+ * gate). The existing Write|Edit|MultiEdit matcher is
56
+ * preserved. The new matcher's exit code is the load-bearing
57
+ * signal: 0 = allow, 2 = block (with stderr BLOCKED reason).
53
58
  */
54
- export const TEMPLATE_VERSION = '1.2.0';
59
+ export const TEMPLATE_VERSION = '1.3.0';
55
60
  /**
56
61
  * Compare two serialized template strings for semantic equivalence.
57
62
  *
@@ -208,8 +213,42 @@ export function buildClaudeSettingsLocalJson() {
208
213
  command: buildWriteHookCommand()
209
214
  }
210
215
  ]
216
+ },
217
+ {
218
+ // v3.1.2 Step 0.8 — Mechanical PreToolUse gate. Runs
219
+ // `peaks solo gate-step-08 --project .` before every Bash
220
+ // tool call. Exit 0 = allow (with structured stdout
221
+ // describing the decision + optional `Next: slice #N+1 of
222
+ // M (<currentSlice>)` line when progress.json exists). Exit
223
+ // 2 = block (stderr contains the BLOCKED: ... reason).
224
+ // The existing Write|Edit|MultiEdit matcher is preserved.
225
+ matcher: 'Bash',
226
+ hooks: [
227
+ {
228
+ type: 'command',
229
+ command: buildBashGateStep08Command()
230
+ }
231
+ ]
211
232
  }
212
233
  ]
213
234
  }
214
235
  };
215
236
  }
237
+ /**
238
+ * v3.1.2: build the Bash matcher command that runs `peaks solo
239
+ * gate-step-08`. We invoke the CLI directly (not via `node -e "..."`)
240
+ * because the CLI is the only legitimate source of the structured
241
+ * decision + Next: slice context. Exit code is the load-bearing
242
+ * signal — 0 = allow, 2 = block. The hook installer is idempotent:
243
+ * `templateContentMatches` compares the parsed matcher entries, so
244
+ * re-running `peaks workspace init` on a project that already has the
245
+ * Bash hook is a no-op (already-current).
246
+ */
247
+ function buildBashGateStep08Command() {
248
+ // The hook receives the tool call on stdin. We ignore stdin and
249
+ // delegate entirely to `peaks solo gate-step-08`, which reads
250
+ // .peaks/_runtime/<sessionId>/job-shape.json and last-prompt.txt.
251
+ // `${CLAUDE_PROJECT_DIR}` resolves to the consumer project's root
252
+ // (Claude Code's standard convention).
253
+ return 'peaks solo gate-step-08 --project "${CLAUDE_PROJECT_DIR}"';
254
+ }
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "3.1.1";
1
+ export declare const CLI_VERSION = "3.1.2";
@@ -1 +1 @@
1
- export const CLI_VERSION = "3.1.1";
1
+ export const CLI_VERSION = "3.1.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "license": "MIT",