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 +20 -0
- package/dist/src/cli/commands/solo-commands.js +137 -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/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 +16 -3
- package/skills/peaks-solo/references/runbook.md +26 -1
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.
|
|
1
|
+
export declare const CLI_VERSION = "3.1.1";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "3.1.
|
|
1
|
+
export const CLI_VERSION = "3.1.1";
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|