peaks-loop 4.0.47 → 4.0.48

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.
Files changed (104) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/agents/karpathy-reviewer.md +11 -10
  5. package/dist/cli/cli-helpers.d.ts +34 -0
  6. package/dist/cli/cli-helpers.js +57 -0
  7. package/dist/cli/commands/code-job-shape-commands.js +8 -0
  8. package/dist/cli/commands/code-runtime-commands.js +48 -8
  9. package/dist/cli/commands/compact-command.js +112 -0
  10. package/dist/cli/commands/config-commands.js +15 -9
  11. package/dist/cli/commands/dashboard-long-run.js +6 -0
  12. package/dist/cli/commands/dispatch-commands.js +11 -1
  13. package/dist/cli/commands/doctor/invoke-from-code.js +6 -0
  14. package/dist/cli/commands/hooks-commands.js +4 -4
  15. package/dist/cli/commands/job-commands.js +8 -0
  16. package/dist/cli/commands/loop-eval-commands.js +15 -0
  17. package/dist/cli/commands/perf-audit-commands.js +2 -0
  18. package/dist/cli/commands/playwright-commands.js +12 -0
  19. package/dist/cli/commands/prd-commands.js +1 -1
  20. package/dist/cli/commands/qa-commands.js +22 -0
  21. package/dist/cli/commands/request-commands.js +8 -0
  22. package/dist/cli/commands/scan-commands.js +1 -1
  23. package/dist/cli/commands/security-audit-commands.js +2 -0
  24. package/dist/cli/commands/slice-integrate-commands.js +5 -0
  25. package/dist/cli/commands/statusline-commands.js +44 -4
  26. package/dist/cli/commands/sub-agent/detached.d.ts +14 -1
  27. package/dist/cli/commands/sub-agent/detached.js +47 -22
  28. package/dist/cli/commands/sub-agent-shutdown-commands.js +11 -0
  29. package/dist/cli/commands/verdict-aggregate-command.js +95 -13
  30. package/dist/cli/commands/workflow-commands.js +1 -1
  31. package/dist/cli/index.js +5 -45
  32. package/dist/services/artifacts/artifact-prerequisites.d.ts +38 -7
  33. package/dist/services/artifacts/artifact-prerequisites.js +130 -65
  34. package/dist/services/artifacts/request-artifact-service.d.ts +8 -0
  35. package/dist/services/artifacts/request-artifact-service.js +18 -8
  36. package/dist/services/artifacts/request-artifact-state-helpers.d.ts +57 -0
  37. package/dist/services/artifacts/request-artifact-state-helpers.js +91 -10
  38. package/dist/services/audit-independent/perf-audit-service.d.ts +9 -0
  39. package/dist/services/audit-independent/perf-audit-service.js +27 -5
  40. package/dist/services/audit-independent/security-audit-service.d.ts +12 -2
  41. package/dist/services/audit-independent/security-audit-service.js +28 -6
  42. package/dist/services/code/auto-compact-lifecycle.d.ts +119 -0
  43. package/dist/services/code/auto-compact-lifecycle.js +169 -0
  44. package/dist/services/code/auto-compact-orchestrator.js +13 -2
  45. package/dist/services/code/compact-event-settle.d.ts +122 -0
  46. package/dist/services/code/compact-event-settle.js +219 -0
  47. package/dist/services/compact-history/compact-history-service.d.ts +14 -0
  48. package/dist/services/config/config-restore.d.ts +12 -1
  49. package/dist/services/config/config-restore.js +35 -4
  50. package/dist/services/config/config-rollback.js +6 -1
  51. package/dist/services/context/harness-context-witness.d.ts +310 -0
  52. package/dist/services/context/harness-context-witness.js +606 -0
  53. package/dist/services/evidence/evidence-generator.js +86 -49
  54. package/dist/services/final-review/final-review-service.d.ts +9 -0
  55. package/dist/services/final-review/final-review-service.js +36 -12
  56. package/dist/services/ide/ide-registry.d.ts +19 -0
  57. package/dist/services/ide/ide-registry.js +21 -0
  58. package/dist/services/job/job-state-store.js +7 -0
  59. package/dist/services/polyrepo/polyrepo-dispatcher.js +11 -0
  60. package/dist/services/prd/handoff-auto-regen.js +31 -27
  61. package/dist/services/prd/handoff-frontmatter.d.ts +44 -0
  62. package/dist/services/prd/handoff-frontmatter.js +75 -0
  63. package/dist/services/prd/handoff-service.d.ts +41 -2
  64. package/dist/services/prd/handoff-service.js +81 -8
  65. package/dist/services/prd/handoff-types.d.ts +3 -2
  66. package/dist/services/prd/handoff-types.js +3 -2
  67. package/dist/services/qa/qa-business-review-state.js +9 -0
  68. package/dist/services/scan/karpathy-service.js +2 -2
  69. package/dist/services/session/session-checkpoint-service.js +8 -0
  70. package/dist/services/skill/resume-detector.js +29 -11
  71. package/dist/services/skills/hooks-codegate-superpowers.d.ts +6 -0
  72. package/dist/services/skills/hooks-codegate-superpowers.js +61 -2
  73. package/dist/services/skills/hooks-settings-service.js +14 -4
  74. package/dist/services/skills/session-start-hook-constants.d.ts +45 -0
  75. package/dist/services/skills/session-start-hook-constants.js +45 -0
  76. package/dist/services/skills/skill-statusline-service.d.ts +14 -0
  77. package/dist/services/slice/slice-check-service.js +29 -11
  78. package/dist/services/slice/slice-review-state.js +8 -0
  79. package/dist/services/workflow/pipeline-verify-gate-support.d.ts +47 -10
  80. package/dist/services/workflow/pipeline-verify-gate-support.js +212 -93
  81. package/dist/services/workflow/pipeline-verify-service.js +24 -23
  82. package/dist/services/workflow/pipeline-verify-types.d.ts +10 -3
  83. package/dist/services/workspace/claude-settings-template.d.ts +56 -8
  84. package/dist/services/workspace/claude-settings-template.js +98 -20
  85. package/dist/services/workspace/workspace-claude-settings-materializer.js +78 -7
  86. package/package.json +6 -6
  87. package/skills/bee/peaks-prd/SKILL.md +7 -5
  88. package/skills/bee/peaks-qa/SKILL.md +5 -5
  89. package/skills/bee/peaks-qa/references/qa-runbook.md +2 -2
  90. package/skills/bee/peaks-qa/references/qa-transition-gates.md +7 -7
  91. package/skills/bee/peaks-rd/SKILL.md +8 -6
  92. package/skills/bee/peaks-rd/references/artifact-per-request.md +2 -2
  93. package/skills/bee/peaks-rd/references/parallel-review-fanout.md +7 -5
  94. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +13 -13
  95. package/skills/bee/peaks-rd/references/rd-runbook.md +9 -5
  96. package/skills/bee/peaks-rd/references/rd-transition-gates.md +9 -7
  97. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -6
  98. package/skills/peaks-code/SKILL.md +1 -1
  99. package/skills/peaks-code/references/a2a-artifact-mapping.md +3 -3
  100. package/skills/peaks-code/references/local-artifact-workspace.md +1 -1
  101. package/skills/peaks-code/references/resume-detection.md +13 -7
  102. package/skills/peaks-code/references/runbook.md +3 -2
  103. package/skills/peaks-code/references/session-overload-signal-index.md +2 -1
  104. package/skills/peaks-code/references/workflow-gates-and-types.md +8 -6
@@ -7,7 +7,7 @@ import { findProjectRoot } from '../../services/config/config-safety.js';
7
7
  import { applyHookInstall, planHookInstall, readHookStatus, readInstalledEntriesFromSettings, removeHookInstall, listSuperpowersDenyEntries } from '../../services/skills/hooks-settings-service.js';
8
8
  import { readJsonObjectFile } from '../../services/ide/shared/atomic-json.js';
9
9
  import { detectIdeFromContext } from '../../services/ide/hook-translator.js';
10
- import { getAdapter } from '../../services/ide/ide-registry.js';
10
+ import { getAdapter, resolveIdeOptionHelp } from '../../services/ide/ide-registry.js';
11
11
  /**
12
12
  * This module's own directory — `<root>/src/cli/commands` in the source tree,
13
13
  * `<root>/dist/cli/commands` in a build. Same reason as
@@ -153,7 +153,7 @@ export function registerHooksCommands(program, io) {
153
153
  .description(`Install the peaks-managed gate-enforce hook entry into the adapter's settings.json. Slice #014: only the gate-enforce entry is installed; the legacy progress-start entry is no longer installed. Idempotent: re-runs are no-ops. Project scope by default.`)
154
154
  .option('--global', 'install into the user-level ~/.claude/settings.json instead of the project')
155
155
  .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
156
- .option('--ide <id>', "target adapter id (claude-code | trae); default: auto-detect from env/cwd")
156
+ .option('--ide <id>', resolveIdeOptionHelp())
157
157
  .option('--dry-run', 'show what would change without writing')
158
158
  .option('--no-progress', 'skip the progress-start PreToolUse hook entry; install ONLY the gate-enforce entry')).action((options) => {
159
159
  const scope = resolveScope(options);
@@ -282,7 +282,7 @@ export function registerHooksCommands(program, io) {
282
282
  .description("Remove the peaks-managed gate-enforce hook entry from the target settings.json. Any legacy progress-start entry that a pre-#014 install left behind is also removed (sentinel-based scan). Third-party hooks are preserved.")
283
283
  .option('--global', 'remove from the user-level ~/.claude/settings.json instead of the project')
284
284
  .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
285
- .option('--ide <id>', 'target adapter id (claude-code | trae); default: auto-detect from env/cwd')).action((options) => {
285
+ .option('--ide <id>', resolveIdeOptionHelp())).action((options) => {
286
286
  const scope = resolveScope(options);
287
287
  const projectRoot = resolveProjectRoot(scope, options.project);
288
288
  const ide = resolveIdeForCommand(options, projectRoot);
@@ -310,7 +310,7 @@ export function registerHooksCommands(program, io) {
310
310
  .description('Report which peaks-managed hook entries are installed.')
311
311
  .option('--global', 'inspect the user-level ~/.claude/settings.json instead of the project')
312
312
  .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
313
- .option('--ide <id>', 'target adapter id (claude-code | trae); default: auto-detect from env/cwd')).action((options) => {
313
+ .option('--ide <id>', resolveIdeOptionHelp())).action((options) => {
314
314
  const scope = resolveScope(options);
315
315
  const projectRoot = resolveProjectRoot(scope, options.project);
316
316
  const ide = resolveIdeForCommand(options, projectRoot);
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { Command } from 'commander';
5
5
  import { fail, ok } from 'peaks-loop-shared/result';
6
6
  import { addJsonOption, printResult } from '../cli-helpers.js';
7
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
7
8
  import { JobStateStore } from '../../services/job/job-state-store.js';
8
9
  import { JobOrchestrator } from '../../services/job/job-orchestrator.js';
9
10
  import { writeJobProgress, readJobProgress, tryReadJobProgress } from '../../services/job/job-progress-store.js';
@@ -70,6 +71,13 @@ function resolveJobStateRoot(opts, jobId) {
70
71
  if (!sessionId) {
71
72
  throw new Error('NO_ACTIVE_SESSION: peaks job requires --session-id or an active peaks-code session via peaks workspace init');
72
73
  }
74
+ // Sid axis — the sibling of the jobId guard in `JobStateStore.jobDir`. A
75
+ // caller-supplied `--session-id` reaches this join unmodified, so a
76
+ // traversal value lands `job/<id>/state.json` outside every project root
77
+ // while the envelope still reads `ok: true`.
78
+ if (isUnsafePathInput(sessionId)) {
79
+ throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
80
+ }
73
81
  const rootDir = join(project, '.peaks', '_runtime', sessionId, 'job');
74
82
  if (jobId && !existsSync(join(rootDir, jobId, 'state.json'))) {
75
83
  const other = findSessionHoldingJob(project, jobId);
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
3
4
  import { resolveWorkflow, planWorkflow, planWorkflowRun } from '../../services/workflow/workflow-loader.js';
4
5
  import { dispatchEvaluator } from '../../services/loop/evaluator-dispatcher.js';
5
6
  import { DEFAULT_MONOTONIC_THRESHOLD } from '../../services/loop/monotonic-guard.js';
@@ -165,6 +166,14 @@ export function registerWorkflowEvalCommands(program, io) {
165
166
  process.exitCode = 1;
166
167
  return;
167
168
  }
169
+ // Sid axis. `--session` reaches two joins below (`dir` and, through
170
+ // `nextEvalCaptureIndex`, the same literal), so one guard here covers
171
+ // the whole `--capture-score` write path.
172
+ if (isUnsafePathInput(options.session)) {
173
+ printResult(io, fail('loop.eval', 'INVALID_SESSION_ID', `Invalid session id: ${options.session} (must be a single path segment)`, { provided: options.session }, ['Pass a session id that is a single path segment']), options.json);
174
+ process.exitCode = 1;
175
+ return;
176
+ }
168
177
  const score = envelope.degraded ? 0.25 : (envelope.gateAction === 'pass' ? 1.0 : envelope.gateAction === 'warn' ? 0.5 : 0.0);
169
178
  const dir = join(projectRoot, '.peaks', '_runtime', options.session, 'loop', rid, 'cycles');
170
179
  const n = nextEvalCaptureIndex(projectRoot, options.session, rid);
@@ -475,6 +484,12 @@ export function registerWorkflowEvalCommands(program, io) {
475
484
  * writes. The run-driver also writes to the same dir; the next
476
485
  * index = max(prior)+1, or 1. */
477
486
  function nextEvalCaptureIndex(projectRoot, sid, rid) {
487
+ // Sid axis: this function is the join for the capture-score cycle dir. The
488
+ // action guards `options.session` before calling in, but the seam is callable
489
+ // on its own, so the join states its own contract.
490
+ if (isUnsafePathInput(sid)) {
491
+ throw new Error(`Invalid session id: ${sid} (must be a single path segment)`);
492
+ }
478
493
  const dir = join(projectRoot, '.peaks', '_runtime', sid, 'loop', rid, 'cycles');
479
494
  if (!existsSync(dir))
480
495
  return 1;
@@ -46,6 +46,7 @@ export function registerPerfAuditCommands(program, io) {
46
46
  .option('--project <path>', 'project root (default: cwd)')).action((options) => {
47
47
  const projectRoot = options.project ?? process.cwd();
48
48
  const sid = options.sid;
49
+ const rid = options.rid;
49
50
  if (sid === undefined || sid.length === 0) {
50
51
  printResult(io, fail('perf-audit.detect', 'SID_REQUIRED', 'session id (--sid) is required for perf-audit.detect', { state: 'sid-missing' }, ['Pass --sid <session-id> (e.g. 2026-06-27-session-...)', 'Run `peaks session info --active --json` to find the active sid.']), options.json);
51
52
  process.exitCode = 1;
@@ -55,6 +56,7 @@ export function registerPerfAuditCommands(program, io) {
55
56
  const detect = detectPerfAudit({
56
57
  projectRoot,
57
58
  sessionId: sid,
59
+ ...(rid !== undefined ? { requestId: rid } : {}),
58
60
  });
59
61
  const envelope = detect.state === 'ready'
60
62
  ? ok('perf-audit.detect', detect, [...detect.warnings], [...detect.nextActions])
@@ -33,6 +33,7 @@ import { spawn } from 'node:child_process';
33
33
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs';
34
34
  import { join, dirname, resolve } from 'node:path';
35
35
  import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
36
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
36
37
  import { getErrorMessage } from '../cli-helpers.js';
37
38
  import { runBrowserAction } from '../../services/qa/browser-wrapper-service.js';
38
39
  export const DEFAULT_PORT = 8931;
@@ -43,6 +44,17 @@ export function playwrightSessionsDir(projectRoot) {
43
44
  return join(projectRoot, '.peaks', '_runtime', PLAYWRIGHT_SESSIONS_DIR);
44
45
  }
45
46
  export function sessionFilePath(projectRoot, terminalId) {
47
+ // Terminal-id axis. The derived id is already sanitised (`deriveTerminalId`),
48
+ // but `--terminal` is caller-supplied and reaches this join unsanitised.
49
+ // Measured (repair cycle 1, item 1): `playwright stop --terminal ../../../../X`
50
+ // read a session record planted OUTSIDE the project root, sent SIGTERM to the
51
+ // pid it named, unlinked that file, and returned `ok: true` — arbitrary
52
+ // process termination plus an arbitrary file delete, one flag away. This is
53
+ // the join every reader/writer of a session record goes through, so this is
54
+ // where the invariant lives.
55
+ if (isUnsafePathInput(terminalId)) {
56
+ throw new Error(`Invalid terminal id: ${terminalId} (must be a single path segment)`);
57
+ }
46
58
  return join(playwrightSessionsDir(projectRoot), `${terminalId}.json`);
47
59
  }
48
60
  /**
@@ -44,7 +44,7 @@ export function registerPrdCommands(program, io) {
44
44
  // peaks prd handoff init
45
45
  addJsonOption(handoff
46
46
  .command('init')
47
- .description('Initialize an immutable handoff: sha256(body) → frontmatter → write to .peaks/_runtime/<sid>/prd/handoff.md')
47
+ .description('Initialize an immutable handoff: sha256(body) → frontmatter → write to .peaks/_runtime/<sid>/prd/handoff-<rid>.md')
48
48
  .requiredOption('--rid <request-id>', 'request id (e.g. 001-v2-11-cc-group-b)')
49
49
  .requiredOption('--sid <session-id>', 'session id (e.g. 2026-06-26-session-a28d69)')
50
50
  .requiredOption('--body <body>', 'handoff body markdown, or @<file> to read from disk')
@@ -29,6 +29,7 @@ import { join, resolve } from 'node:path';
29
29
  import { addJsonOption, printResult } from '../cli-helpers.js';
30
30
  import { fail, ok } from 'peaks-loop-shared/result';
31
31
  import { archiveScreenshots } from '../../services/qa/screenshot-archive-service.js';
32
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
32
33
  import { getSessionId } from '../../services/session/session-manager.js';
33
34
  import { BrowserRestartDetector } from '../../services/qa/browser-restart-detector.js';
34
35
  import { BrowserEventLogger } from '../../services/qa/browser-event-logger.js';
@@ -68,6 +69,12 @@ async function ensureContextForQa(goal, project, sid) {
68
69
  }
69
70
  export const DEFAULT_MAX_BROWSER_RESTARTS = 3;
70
71
  export function runQaSlice(input) {
72
+ // Sid axis: this is the join for `qa/browser-events.jsonl`. The `qa gate run`
73
+ // action guards before calling in, but the function is exported for tests and
74
+ // states its own contract at the join.
75
+ if (isUnsafePathInput(input.sessionId)) {
76
+ throw new Error(`Invalid session id: ${input.sessionId} (must be a single path segment)`);
77
+ }
71
78
  const detector = new BrowserRestartDetector({
72
79
  maxRestarts: input.maxRestarts,
73
80
  enabled: input.detectorEnabled
@@ -221,6 +228,15 @@ export function registerQaCommands(program, io) {
221
228
  // when the user did not supply one.
222
229
  const qaProject = options.project;
223
230
  const qaSid = options.sessionId ?? 'ad-hoc';
231
+ // Sid axis. `--session-id` reaches three consumers below — the context
232
+ // pre-build, `loadMutReport`, and `runQaSlice`'s
233
+ // `qa/browser-events.jsonl` join — so one guard at resolution covers all
234
+ // three. (`peaks qa archive-screenshots` has its own, separate join.)
235
+ if (isUnsafePathInput(qaSid)) {
236
+ printResult(io, fail('qa.gate', 'INVALID_SESSION_ID', `Invalid session id: ${qaSid} (must be a single path segment)`, { provided: qaSid }, ['Pass a session id that is a single path segment']), options.json);
237
+ process.exitCode = 1;
238
+ return;
239
+ }
224
240
  await ensureContextForQa('qa gate run', qaProject, qaSid);
225
241
  // Plan 2 / Task 8 — load peaks-mut's report (MUT.sig) if present.
226
242
  // Returns null when peaks-mut was not run; the gate treats that
@@ -289,6 +305,12 @@ export function registerQaCommands(program, io) {
289
305
  try {
290
306
  const projectRoot = resolve(options.project);
291
307
  const sid = options.sessionId?.trim() || getSessionId(projectRoot) || 'ad-hoc';
308
+ // Sid axis. Guarded after resolution so the flag value and the session
309
+ // binding are both covered. `archiveScreenshots` cannot do this itself:
310
+ // it only ever sees a fully-resolved `targetDir`.
311
+ if (isUnsafePathInput(sid)) {
312
+ throw new Error(`Invalid session id: ${sid} (must be a single path segment)`);
313
+ }
292
314
  const targetDir = join(projectRoot, '.peaks', '_runtime', sid, 'qa', 'screenshots');
293
315
  const envelope = archiveScreenshots({ sourceDir: options.source, targetDir });
294
316
  printResult(io, ok('qa.archive-screenshots', envelope, [], envelope.moved.length === 0
@@ -10,6 +10,7 @@ import { fail, ok } from 'peaks-loop-shared/result';
10
10
  import { triggerBestPracticeScan } from '../../services/prd/best-practice-auto-trigger.js';
11
11
  import { refreshCodegraphAfterSlice, } from '../../services/codegraph/codegraph-autorefresh.js';
12
12
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
13
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
13
14
  /**
14
15
  * Slice 2026-09-10-context-audit-and-discipline (Slice B): bounded view of
15
16
  * `request list`. `count` is the true total; `names` carries
@@ -189,6 +190,13 @@ export function registerRequestCommands(program, io) {
189
190
  // non-existent .peaks/default/ dir and crashes with ENOENT, so when
190
191
  // --session-id is omitted we look the artifact up to find its session.
191
192
  let resolvedSessionId = options.sessionId;
193
+ // Sid axis: either arm of `resolvedSessionId` can be a caller-supplied
194
+ // `--session-id`, and it is joined into the bypass-counter root below.
195
+ if (resolvedSessionId !== undefined && isUnsafePathInput(resolvedSessionId)) {
196
+ printResult(io, fail('request.transition', 'INVALID_SESSION_ID', `Invalid session id: ${resolvedSessionId} (must be a single path segment)`, { provided: resolvedSessionId }, ['Pass a session id that is a single path segment']), options.json);
197
+ process.exitCode = 1;
198
+ return;
199
+ }
192
200
  if (resolvedSessionId === undefined) {
193
201
  const { showRequestArtifact: showForSession } = await import('../../services/artifacts/request-artifact-service.js');
194
202
  const located = await showForSession({ projectRoot: options.project, role, requestId });
@@ -408,7 +408,7 @@ export function registerScanCommands(program, io) {
408
408
  });
409
409
  const nextActions = [];
410
410
  if (report.gateAction === 'block') {
411
- nextActions.push('Karpathy review file missing under scope=all. Per karpathy §1 Think Before Coding, create rd/karpathy-review.md before requesting qa-handoff.');
411
+ nextActions.push('Karpathy review file missing under scope=all. Per karpathy §1 Think Before Coding, the rd:qa-handoff gate wants rd/karpathy-review-<rid>.md; this scanner has no rid and reads only the back-compat name rd/karpathy-review.md, so it cannot go green on the rid-scoped name alone. `peaks request transition --state qa-handoff` is the authoritative gate.');
412
412
  }
413
413
  else if (report.gateAction === 'warn') {
414
414
  nextActions.push(`Karpathy review emitted ${report.totalViolations} violation(s). Review the warnings and re-run after cleanup (karpathy §3 Surgical Changes).`);
@@ -46,6 +46,7 @@ export function registerSecurityAuditCommands(program, io) {
46
46
  .option('--project <path>', 'project root (default: cwd)')).action((options) => {
47
47
  const projectRoot = options.project ?? process.cwd();
48
48
  const sid = options.sid;
49
+ const rid = options.rid;
49
50
  if (sid === undefined || sid.length === 0) {
50
51
  printResult(io, fail('security-audit.detect', 'SID_REQUIRED', 'session id (--sid) is required for security-audit.detect', { state: 'sid-missing' }, ['Pass --sid <session-id> (e.g. 2026-06-27-session-...)', 'Run `peaks session info --active --json` to find the active sid.']), options.json);
51
52
  process.exitCode = 1;
@@ -55,6 +56,7 @@ export function registerSecurityAuditCommands(program, io) {
55
56
  const detect = detectSecurityAudit({
56
57
  projectRoot,
57
58
  sessionId: sid,
59
+ ...(rid !== undefined ? { requestId: rid } : {}),
58
60
  });
59
61
  const envelope = detect.state === 'ready'
60
62
  ? ok('security-audit.detect', detect, [...detect.warnings], [...detect.nextActions])
@@ -9,10 +9,15 @@
9
9
  import { existsSync, readFileSync } from 'node:fs';
10
10
  import { join, resolve } from 'node:path';
11
11
  import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
12
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
12
13
  import { integrateSlices } from '../../services/slice/slice-integration.js';
13
14
  import { fail, ok } from 'peaks-loop-shared/result';
14
15
  import { addJsonOption, printResult } from '../cli-helpers.js';
15
16
  function loadContractsForSlices(projectRoot, sessionId, sliceIds) {
17
+ // Sid axis: `--session-id` reaches this join unmodified.
18
+ if (isUnsafePathInput(sessionId)) {
19
+ throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
20
+ }
16
21
  const dir = resolve(projectRoot, '.peaks', '_runtime', sessionId, 'dispatch', 'contracts');
17
22
  if (!existsSync(dir))
18
23
  return [];
@@ -6,11 +6,39 @@ import { renderStatusLine, resolveStatusLineCapability, } from '../../services/s
6
6
  import { applyStatusLineInstall, planStatusLineInstall, removeStatusLineInstall } from '../../services/skills/statusline-settings-service.js';
7
7
  import { readHookStatus as readSettingsStatus } from '../../services/skills/hooks-settings-service.js';
8
8
  import { detectIdeFromContext } from '../../services/ide/hook-translator.js';
9
+ import { resolveIdeOptionHelp } from '../../services/ide/ide-registry.js';
9
10
  import { decideCompactStatusline, renderCompactStatusline, } from '../../services/compact-statusline/compact-statusline-service.js';
10
11
  import { getSessionIdCanonical } from '../../services/session/session-manager.js';
12
+ import { writeHarnessWitness } from '../../services/context/harness-context-witness.js';
11
13
  const STDIN_READ_TIMEOUT_MS = 250;
12
- /** Read piped stdin if present; resolve quickly with '' when attached to a TTY. */
14
+ /**
15
+ * Read piped stdin if present; resolve quickly with '' when attached to a TTY.
16
+ *
17
+ * `PEAKS_STATUSLINE_STDIN` is a test seam (same shape as the `PEAKS_HOOK_STDIN`
18
+ * seam in `gate-commands.ts`): the harness payload is the only channel that
19
+ * carries the harness's own context numbers, and a test that cannot supply one
20
+ * cannot verify the capture at all.
21
+ *
22
+ * WHY THIS SEAM IS ACCEPTABLE ON A DIFFERENT ARGUMENT (repair cycle 3). The
23
+ * precedent does not transfer by itself: `PEAKS_HOOK_STDIN` is defended in
24
+ * `.peaks/memory/peaks-hook-stdin-test-seam-pattern.md` because its payload
25
+ * still routes through the `enforceBashCommand` SOP gate. This payload is
26
+ * routed through no gate — it is PERSISTED verbatim as the record the witness
27
+ * guard then reads. So the compensating control is different, not absent: the
28
+ * record is observation-only and one-way. It reaches only `data` and the
29
+ * `warnings` / `nextActions` arrays of `peaks code context-now`; the compact
30
+ * `action` there is computed from `probe.ratio` alone, before and independently
31
+ * of the witness. A forged payload can therefore make the instrument lie —
32
+ * measured: a hand-written record yields a confident false `agree`, or a
33
+ * self-contradictory false `disagree` — and can do nothing else. What it cannot
34
+ * do is fire, block or delay a compact. Unconditional rather than NODE_ENV-
35
+ * gated, for the reason already recorded for the sibling seam.
36
+ */
13
37
  function readStdin() {
38
+ const override = process.env['PEAKS_STATUSLINE_STDIN'];
39
+ if (override !== undefined) {
40
+ return Promise.resolve(override);
41
+ }
14
42
  return new Promise((resolve) => {
15
43
  if (process.stdin.isTTY) {
16
44
  resolve('');
@@ -82,6 +110,18 @@ export async function runDefaultStatuslineRender(options, io) {
82
110
  // subprocess sat descheduled. Production callers omit the flag.
83
111
  const now = options.now !== undefined ? Number(options.now) : Date.now();
84
112
  const model = buildStatusLineModel(seeded, now);
113
+ // Slice 2026-09-13-statusline-window-witness (AC1): capture the harness's OWN
114
+ // context numbers from the payload it just piped in. The project root and the
115
+ // canonical session id are taken from the model that was JUST built, so the
116
+ // witness lands under the same session this render already resolved — a second
117
+ // resolution here could disagree with it. Never throws; never changes the
118
+ // rendered line.
119
+ writeHarnessWitness({
120
+ projectRoot: model.projectRoot,
121
+ sessionId: model.sessionId,
122
+ stdin,
123
+ nowMs: now,
124
+ });
85
125
  const capability = resolveStatusLineCapability({
86
126
  env: process.env,
87
127
  isTTY: Boolean(process.stdout.isTTY),
@@ -136,7 +176,7 @@ export function registerStatusLineCommands(program, io) {
136
176
  .description("Install the Peaks status line into the adapter's settings.json (project scope by default).")
137
177
  .option('--global', 'install into the user-level ~/.claude/settings.json instead of the project')
138
178
  .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
139
- .option('--ide <id>', 'target adapter id (claude-code | trae); default: auto-detect from env/cwd')
179
+ .option('--ide <id>', resolveIdeOptionHelp())
140
180
  .option('--force', 'overwrite an existing non-Peaks statusLine entry')
141
181
  .option('--dry-run', 'show what would change without writing')).action((options) => {
142
182
  const scope = resolveScope(options);
@@ -173,7 +213,7 @@ export function registerStatusLineCommands(program, io) {
173
213
  .description("Remove the Peaks status line from the adapter's settings.json.")
174
214
  .option('--global', 'remove from the user-level ~/.claude/settings.json instead of the project')
175
215
  .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
176
- .option('--ide <id>', 'target adapter id (claude-code | trae); default: auto-detect from env/cwd')).action((options) => {
216
+ .option('--ide <id>', resolveIdeOptionHelp())).action((options) => {
177
217
  const scope = resolveScope(options);
178
218
  const projectRoot = scope === 'project'
179
219
  ? (options.project ?? findProjectRoot(process.cwd()) ?? process.cwd())
@@ -194,7 +234,7 @@ export function registerStatusLineCommands(program, io) {
194
234
  .description('Report whether the Peaks status line is installed in the adapter settings.json.')
195
235
  .option('--global', 'inspect the user-level ~/.claude/settings.json instead of the project')
196
236
  .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
197
- .option('--ide <id>', 'target adapter id (claude-code | trae); default: auto-detect from env/cwd')).action((options) => {
237
+ .option('--ide <id>', resolveIdeOptionHelp())).action((options) => {
198
238
  const scope = resolveScope(options);
199
239
  const projectRoot = scope === 'project'
200
240
  ? (options.project ?? findProjectRoot(process.cwd()) ?? process.cwd())
@@ -15,14 +15,27 @@ export declare function dispatch(f: DispatchFlags): Promise<{
15
15
  ok: boolean;
16
16
  command: string;
17
17
  data: {
18
+ expectedCompletionSeconds: number;
19
+ orchestratorVisibleHint: string;
18
20
  mode: string;
19
21
  vendor: "claude" | "codex" | "copilot" | undefined;
20
22
  pid: number;
21
23
  dispatchRecordPath: string;
22
24
  maxConcurrent: number;
23
25
  noThrottle: boolean;
24
- orchestratorVisibleHint: string;
26
+ } | {
25
27
  expectedCompletionSeconds: number;
28
+ spawnError: {
29
+ code: string | undefined;
30
+ message: string;
31
+ };
32
+ orchestratorVisibleHint: string;
33
+ mode: string;
34
+ vendor: "claude" | "codex" | "copilot" | undefined;
35
+ pid: number;
36
+ dispatchRecordPath: string;
37
+ maxConcurrent: number;
38
+ noThrottle: boolean;
26
39
  };
27
40
  warnings: string[];
28
41
  nextActions: string[];
@@ -25,15 +25,26 @@ export async function dispatch(f) {
25
25
  if (f.noThrottle) {
26
26
  warnings.push('user-overrode: --no-throttle (peak runtime may exceed performance ceiling)');
27
27
  }
28
- // rid-001 fix (F1 follow-up): the vendor CLI's ChildProcess is owned by
29
- // peaks-loop-internal-runtime/dispatch.dispatchDetached, which now exposes
30
- // it via DispatchResult.child. When the vendor binary is not on PATH the
31
- // spawn fires an async 'error' event after dispatchDetached returns; we
32
- // attach a per-child handler that swallows ENOENT (expected when the user
33
- // hasn't installed claude/codex/copilot) and logs anything else. This is
34
- // the canonical pattern (matches codegraph-process-runner.ts) — no more
35
- // process-level uncaughtException swallow.
28
+ // The vendor CLI's ChildProcess is owned by
29
+ // peaks-loop-internal-runtime/dispatch.dispatchDetached, and this handler
30
+ // deliberately attaches NO 'error' listener to it.
31
+ //
32
+ // The handler that used to live here — an 'error' listener attached to
33
+ // `r.child` after the await — could never fire. `ProcessSupervisor.spawn`
34
+ // attaches its own 'error' + 'spawn' listeners in the SAME synchronous turn
35
+ // the child is created, and `dispatchDetached` awaits the resulting `settled` promise
36
+ // before returning. A missing binary is emitted on the nextTick queue,
37
+ // which drains BEFORE the awaiting caller resumes — so by the time this
38
+ // function holds `r`, the event has already been consumed and turned into
39
+ // the typed `spawnError` value below. Attaching a listener here is not
40
+ // merely late; it is unreachable by construction.
36
41
  const sid = process.env.PEAKS_SESSION_ID ?? 'local';
42
+ // `spawnError` is optional in this annotation, not because the value is
43
+ // uncertain but because the type this resolves against is: the workspace
44
+ // package's `types` entry points at `dist/index.d.ts`, and a `dist` built
45
+ // before the field was added does not declare it. Reading it defensively
46
+ // (`?? null`, below) is correct in both worlds; requiring it here would make
47
+ // the handler fail to compile against a stale build.
37
48
  let r;
38
49
  r = await dispatchDetached({
39
50
  sid,
@@ -46,15 +57,17 @@ export async function dispatch(f) {
46
57
  runtimeDir: `.peaks/_runtime/${sid}/detached`,
47
58
  subAgentsDir: `.peaks/_sub_agents/${sid}`,
48
59
  });
49
- r.child?.on('error', (err) => {
50
- if (err && err.code === 'ENOENT')
51
- return;
52
- // Non-ENOENT child errors are surfaced to stderr; the dispatch envelope
53
- // has already been written and the detached child is fire-and-forget.
54
- console.error('[peaks sub-agent dispatch] detached child error:', err);
55
- });
60
+ // The launch outcome is the envelope's `ok`, not a footnote. A vendor CLI
61
+ // that is not installed is an expected environment, and the dispatch record
62
+ // already says `status: 'failed'`; the envelope used to say `ok: true` with
63
+ // `pid: -1` and a "⏳ Spawning …" hint, so the two surfaces disagreed and the
64
+ // orchestrator read a launch that never happened as a running one.
65
+ //
66
+ // `?? null` covers a DispatchResult-shaped value produced by a test double
67
+ // that omits the field; a real `dispatchDetached` always sets it.
68
+ const spawnError = r.spawnError ?? null;
56
69
  return {
57
- ok: true,
70
+ ok: spawnError === null,
58
71
  command: 'sub-agent.dispatch.detached',
59
72
  data: {
60
73
  mode: 'detached',
@@ -63,14 +76,26 @@ export async function dispatch(f) {
63
76
  dispatchRecordPath: r.dispatchRecordPath,
64
77
  maxConcurrent,
65
78
  noThrottle: f.noThrottle ?? false,
66
- orchestratorVisibleHint: `⏳ Spawning detached sub-agent via ${f.vendor ?? 'claude'}: rid=${f.requestId} (ETA ~60s)`,
79
+ ...(spawnError === null
80
+ ? {
81
+ orchestratorVisibleHint: `⏳ Spawning detached sub-agent via ${f.vendor ?? 'claude'}: rid=${f.requestId} (ETA ~60s)`
82
+ }
83
+ : {
84
+ spawnError: { code: spawnError.code, message: spawnError.message },
85
+ orchestratorVisibleHint: `❌ Could not launch detached sub-agent via ${f.vendor ?? 'claude'}: rid=${f.requestId} (${spawnError.code})`
86
+ }),
67
87
  expectedCompletionSeconds: 60,
68
88
  },
69
89
  warnings,
70
- nextActions: [
71
- 'Sub-agent runs as detached OS process. Status at .peaks/_runtime/<sid>/detached/<rid>/status.json',
72
- 'Use `peaks sub-agent list --mode detached` to monitor.',
73
- 'Run `peaks sub-agent cleanup --orphan` to reap orphan processes (RL-15: user-only decision).',
74
- ],
90
+ nextActions: spawnError === null
91
+ ? [
92
+ 'Sub-agent runs as detached OS process. Status at .peaks/_runtime/<sid>/detached/<rid>/status.json',
93
+ 'Use `peaks sub-agent list --mode detached` to monitor.',
94
+ 'Run `peaks sub-agent cleanup --orphan` to reap orphan processes (RL-15: user-only decision).'
95
+ ]
96
+ : [
97
+ `The ${f.vendor ?? 'claude'} CLI is not runnable from this shell (${spawnError.code}); nothing was launched and no sub-agent is running.`,
98
+ 'Either install the vendor CLI on PATH, or re-dispatch without --mode detached to use the in-process path.'
99
+ ]
75
100
  };
76
101
  }
@@ -2,9 +2,20 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { fail, getErrorMessage, ok } from 'peaks-loop-shared/result';
4
4
  import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
5
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
5
6
  import { printResult } from '../cli-helpers.js';
6
7
  const REGISTRATIONS_FILE = 'service-registrations.json';
7
8
  function registrationsPath(input) {
9
+ // Both id axes reach this join. `dispatchId` is `--dispatch-id` (falling back
10
+ // to PEAKS_DISPATCH_ID), and `sessionId` is the session binding; neither was
11
+ // checked, and `register` writes through this path. Guarding the whole join
12
+ // rather than only the flag keeps the function's contract single-stated.
13
+ if (isUnsafePathInput(input.dispatchId)) {
14
+ throw new Error(`Invalid dispatch id: ${input.dispatchId} (must be a single path segment)`);
15
+ }
16
+ if (isUnsafePathInput(input.sessionId)) {
17
+ throw new Error(`Invalid session id: ${input.sessionId} (must be a single path segment)`);
18
+ }
8
19
  return join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'dispatch', input.dispatchId, REGISTRATIONS_FILE);
9
20
  }
10
21
  function readAll(file) {