peaks-loop 3.1.0 → 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,25 @@
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
+
3
23
  ## 3.1.0 — 2026-07-03
4
24
 
5
25
  ### Fixed — peaks-solo external-references authority declaration
@@ -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 {
@@ -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
+ }
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "3.1.0";
1
+ export declare const CLI_VERSION = "3.1.1";
@@ -1 +1 @@
1
- export const CLI_VERSION = "3.1.0";
1
+ export const CLI_VERSION = "3.1.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "license": "MIT",
@@ -97,11 +97,24 @@ Per the "one-key completion" tenet, peaks-loop 2.0 detects 1.x consumers and pro
97
97
 
98
98
  → see `references/step-0-55-1x-detection.md` for the detection algorithm + AskUserQuestion options + persistence contract.
99
99
 
100
- ### Peaks-Loop Step 0.8 — Job 启动
100
+ ### Peaks-Loop Step 0.8 — Job 启动 (BLOCKING on LLM judgement — v3.1.1 patch)
101
101
 
102
- Trigger: user mentions N parallel targets, "全部完成"/"until all done", or disavows cost.
102
+ The CLI does NOT judge whether the request is Job-shaped — that's the LLM's job. Keyword regexes are wrong here (too brittle, miss natural-language variants, shift the LLM's semantic understanding into code). The CLI is a **recorder and gate**: the LLM makes the judgement, writes it via `peaks solo detect-job`, and downstream steps refuse to proceed until the decision file exists.
103
103
 
104
- Action: parse slice list → choose strategy (≤2 single / ≥3 rotating) → `peaks job init --job-id <jid> --slice-list <...> --main-loop-strategy rotating --rotate-every 3` → Step 1.
104
+ LLM judgement criteria (read once, then call the recorder):
105
+ - N parallel targets (`app/components/*`, `app/modules/*`, `for each subdir`, "以目录为维度", "把剩下的也跑了").
106
+ - Completion phrase implying batch continuation (`until all done`, "继续执行下个 slice", "全部添加完", "all of them").
107
+ - Cost / length disavowal (`不用考虑费用`, "don't worry about cost", "一直跑").
108
+ - Explicit slice count (`25 slices`, "切分成 N 段", "拆成 N 块").
109
+
110
+ Action:
111
+ 1. LLM reads the user prompt and applies judgement → `isJob: boolean + rationale + suggestedJobId + suggestedStrategy + confidence`.
112
+ 2. LLM calls `peaks solo detect-job --is-job <bool> --rationale <text> --suggested-job-id <jid> --suggested-strategy <single|rotating> --confidence <high|medium|low> [--force]`.
113
+ 3. CLI records to `.peaks/_runtime/<sessionId>/job-shape.json`.
114
+ 4. If `isJob: true` → `peaks job init --job-id <jid> --slice-list <...> --main-loop-strategy <strategy> --rotate-every 3` (BEFORE Step 1).
115
+ 5. Downstream steps (Step 1, Step 0.81, etc.) call `read-job-shape` to refuse if missing.
116
+
117
+ **Hard rule (v3.1.1 red-line #10):** 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.
105
118
 
106
119
  ### Peaks-Loop Step 0.81 — per-slice 收尾
107
120
 
@@ -192,7 +192,32 @@ Repair loop details: see `## Mandatory RD QA repair loop` in SKILL.md for the fu
192
192
 
193
193
  # Peaks-Loop Default runbook — Job path (excerpt; full flow in references/job-loop.md)
194
194
 
195
- # After Step 7 (RD+QA commit) lands AND the user request was Job-shaped (Step 0.8 triggered):
195
+ # Step 0.8 (BLOCKING): LLM judges Job-shape, CLI records it.
196
+ # This is a RECORDER, not a detector. The LLM supplies --is-job + --rationale;
197
+ # the CLI writes .peaks/_runtime/<sid>/job-shape.json. Downstream steps call
198
+ # `peaks solo read-job-shape` to enforce the decision exists.
199
+
200
+ # (The LLM does the semantic judgement, e.g.:)
201
+ # "The user named N=35 parallel app/ subdirs, said 'continue until all done',
202
+ # and disavowed cost. isJob=true, rationale=..., suggestedJobId=app-ut-batch,
203
+ # suggestedStrategy=rotating, confidence=high."
204
+ peaks solo detect-job \
205
+ --is-job true \
206
+ --rationale "35 parallel app/ subdirs + 'until all done' + '不用考虑费用'" \
207
+ --suggested-job-id app-ut-batch \
208
+ --suggested-strategy rotating \
209
+ --confidence high
210
+
211
+ # Step 0.81-init: if Job-shaped, init BEFORE Step 1.
212
+ DECISION=$(peaks solo read-job-shape --json)
213
+ if [ "$(echo "$DECISION" | jq -r '.data.decision.isJob')" = "true" ]; then
214
+ JID=$(echo "$DECISION" | jq -r '.data.decision.suggestedJobId')
215
+ STRATEGY=$(echo "$DECISION" | jq -r '.data.decision.suggestedStrategy')
216
+ # LLM-derived slice list (not CLI-derived).
217
+ peaks job init --job-id "$JID" --slice-list "<LLM_DERIVED>" --main-loop-strategy "$STRATEGY" --rotate-every 3 --json
218
+ fi
219
+
220
+ # After Step 7 (RD+QA commit) lands AND Step 0.8 fired (state.json exists):
196
221
  peaks job checkpoint --slice-id <rid> --state done --commit-sha $(git rev-parse HEAD)
197
222
  peaks job status --job-id <jid> --json
198
223
  peaks job subagent-cleanup --job-id <jid> --batch-id <bid> --force # Step 0.87 gate