peaks-loop 4.0.36 → 4.0.37

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 (88) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/bin/peaks.js +71 -1
  5. package/dist/cli/cli-helpers.js +7 -0
  6. package/dist/cli/commands/_register.js +2 -0
  7. package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
  8. package/dist/cli/commands/best-practice-scan-command.js +67 -9
  9. package/dist/cli/commands/code-runtime-commands.js +21 -5
  10. package/dist/cli/commands/hooks-commands.js +10 -1
  11. package/dist/cli/commands/job-commands.js +107 -25
  12. package/dist/cli/commands/scan-commands.js +1 -1
  13. package/dist/cli/commands/web-commands.d.ts +28 -0
  14. package/dist/cli/commands/web-commands.js +327 -0
  15. package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
  16. package/dist/cli/commands/web-lifecycle-commands.js +321 -0
  17. package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
  18. package/dist/services/best-practice/scan-orchestrator.js +14 -5
  19. package/dist/services/code/orchestrator-can-do.js +27 -4
  20. package/dist/services/context/context-audit-hint.d.ts +79 -0
  21. package/dist/services/context/context-audit-hint.js +150 -0
  22. package/dist/services/hooks/auto-compact-hook-install.js +10 -1
  23. package/dist/services/hooks/write-gate.js +88 -0
  24. package/dist/services/lint/detect-eslint.d.ts +2 -0
  25. package/dist/services/lint/detect-eslint.js +23 -9
  26. package/dist/services/lint/npx-resolver.d.ts +6 -0
  27. package/dist/services/lint/npx-resolver.js +38 -14
  28. package/dist/services/release/version-precheck-service.js +9 -2
  29. package/dist/services/scan/file-size-scan.d.ts +29 -0
  30. package/dist/services/scan/file-size-scan.js +63 -0
  31. package/dist/services/session/caller-binding-service.d.ts +24 -0
  32. package/dist/services/session/caller-binding-service.js +34 -0
  33. package/dist/services/session/getSessionDir.js +15 -10
  34. package/dist/services/skills/hooks-codegate-superpowers.d.ts +33 -0
  35. package/dist/services/skills/hooks-codegate-superpowers.js +34 -3
  36. package/dist/services/skills/hooks-settings-service.d.ts +10 -0
  37. package/dist/services/skills/hooks-settings-service.js +152 -61
  38. package/dist/services/slice/slice-check-service.d.ts +14 -0
  39. package/dist/services/slice/slice-check-service.js +110 -50
  40. package/dist/services/slice/slice-check-types.d.ts +12 -7
  41. package/dist/services/slice/slice-check-types.js +8 -3
  42. package/dist/services/slice/slice-decompose-runners.js +24 -21
  43. package/dist/services/sop/sop-check-service.js +12 -1
  44. package/dist/services/web/bounded-output.d.ts +34 -0
  45. package/dist/services/web/bounded-output.js +68 -0
  46. package/dist/services/web/browser-acquire.d.ts +14 -0
  47. package/dist/services/web/browser-acquire.js +84 -0
  48. package/dist/services/web/browser-session-manager.d.ts +111 -0
  49. package/dist/services/web/browser-session-manager.js +413 -0
  50. package/dist/services/web/daemon-entry.d.ts +1 -0
  51. package/dist/services/web/daemon-entry.js +65 -0
  52. package/dist/services/web/daemon-registry.d.ts +42 -0
  53. package/dist/services/web/daemon-registry.js +164 -0
  54. package/dist/services/web/daemon-supervisor.d.ts +144 -0
  55. package/dist/services/web/daemon-supervisor.js +455 -0
  56. package/dist/services/web/playwright-loader.d.ts +89 -0
  57. package/dist/services/web/playwright-loader.js +253 -0
  58. package/dist/services/web/snapshot-pruner.d.ts +48 -0
  59. package/dist/services/web/snapshot-pruner.js +241 -0
  60. package/dist/services/web/untrusted-envelope.d.ts +27 -0
  61. package/dist/services/web/untrusted-envelope.js +44 -0
  62. package/dist/services/web/web-artifact-paths.d.ts +79 -0
  63. package/dist/services/web/web-artifact-paths.js +163 -0
  64. package/dist/services/web/web-client.d.ts +19 -0
  65. package/dist/services/web/web-client.js +55 -0
  66. package/dist/services/web/web-daemon-service.d.ts +38 -0
  67. package/dist/services/web/web-daemon-service.js +416 -0
  68. package/dist/services/web/web-fallback.d.ts +70 -0
  69. package/dist/services/web/web-fallback.js +121 -0
  70. package/dist/services/web/web-install-service.d.ts +91 -0
  71. package/dist/services/web/web-install-service.js +346 -0
  72. package/dist/services/web/web-login-profile.d.ts +89 -0
  73. package/dist/services/web/web-login-profile.js +612 -0
  74. package/dist/services/web/web-login-staging.d.ts +27 -0
  75. package/dist/services/web/web-login-staging.js +173 -0
  76. package/dist/services/web/web-protocol.d.ts +58 -0
  77. package/dist/services/web/web-protocol.js +58 -0
  78. package/dist/services/web/web-status-report.d.ts +33 -0
  79. package/dist/services/web/web-status-report.js +47 -0
  80. package/dist/services/workspace/claude-settings-template.d.ts +41 -5
  81. package/dist/services/workspace/claude-settings-template.js +116 -64
  82. package/dist/services/workspace/workspace-claude-settings-materializer.js +5 -1
  83. package/dist/services/workspace/workspace-service.js +33 -0
  84. package/package.json +5 -5
  85. package/scripts/copy-templates.mjs +12 -0
  86. package/scripts/sync-version.mjs +20 -0
  87. package/skills/peaks-code/SKILL.md +10 -0
  88. package/skills/peaks-code/references/browser-workflow.md +10 -1
@@ -1,4 +1,5 @@
1
1
  // src/cli/commands/job-commands.ts
2
+ import { existsSync, readdirSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { Command } from 'commander';
4
5
  import { fail, ok } from 'peaks-loop-shared/result';
@@ -18,6 +19,30 @@ function projectRoot(opts) {
18
19
  // Reuse the workspace root resolver from peaks CLI; for now, CWD as a safe placeholder.
19
20
  return opts.project ?? process.cwd();
20
21
  }
22
+ /**
23
+ * D6: every job subcommand resolves a job root, so every one of them must be
24
+ * able to name the session that holds the job. Same precedence as the rest of
25
+ * the CLI (`peaks sub-agent dispatch`, `peaks web *`, `peaks share *`), except
26
+ * that job state has no "unknown-sid" location to land in — an unresolvable
27
+ * session is an error, not a silent fallback.
28
+ */
29
+ const SESSION_ID_HELP = 'session id (default: resolve from .peaks/_runtime/session.json; falls back to PEAKS_SESSION_ID env var; final fallback: NO_ACTIVE_SESSION error)';
30
+ /**
31
+ * The session (a direct child of `<project>/.peaks/_runtime/`) that holds
32
+ * `jobId`, or null. Only used to explain a miss: a job that lives in another
33
+ * session must be reported by name so the caller can re-run with --session-id.
34
+ */
35
+ function findSessionHoldingJob(project, jobId) {
36
+ const runtimeDir = join(project, '.peaks', '_runtime');
37
+ if (!existsSync(runtimeDir))
38
+ return null;
39
+ for (const entry of readdirSync(runtimeDir, { withFileTypes: true })) {
40
+ if (entry.isDirectory() && existsSync(join(runtimeDir, entry.name, 'job', jobId, 'state.json'))) {
41
+ return entry.name;
42
+ }
43
+ }
44
+ return null;
45
+ }
21
46
  /**
22
47
  * Resolves the on-disk root for Job state files.
23
48
  *
@@ -27,20 +52,50 @@ function projectRoot(opts) {
27
52
  * The `JobStateStore` itself only knows its `rootDir` + `jobId` and joins them. We
28
53
  * compute the canonical root here (per-call) so the store can stay layout-agnostic.
29
54
  *
30
- * Resolution order:
55
+ * Resolution order (D6 — a job must stay addressable while the single per-project
56
+ * `.peaks/_runtime/session.json` binding points at another session):
31
57
  * 1. `--session-id` flag (explicit override)
32
- * 2. `getCurrentSessionId(project)` — reads `.peaks/_runtime/session.json` per peaks-code
33
- * 3. Error (NO_ACTIVE_SESSION) — must never silently fall back to a random uuid
58
+ * 2. `PEAKS_SESSION_ID` env var
59
+ * 3. `getCurrentSessionId(project)` — reads `.peaks/_runtime/session.json`
60
+ * 4. Error (NO_ACTIVE_SESSION) — must never silently fall back to a random uuid
61
+ *
62
+ * When `jobId` is passed and it is absent from the resolved session, the thrown
63
+ * error names the session that does hold it (if any), instead of leaving the
64
+ * caller with a bare "no state for <job> at <other-sid>" path.
34
65
  */
35
- function resolveJobStateRoot(opts) {
66
+ function resolveJobStateRoot(opts, jobId) {
36
67
  const project = projectRoot(opts);
37
- const sessionId = opts.sessionId ?? getCurrentSessionId(project);
68
+ const sessionId = opts.sessionId ?? process.env.PEAKS_SESSION_ID ?? getCurrentSessionId(project);
38
69
  if (!sessionId) {
39
70
  throw new Error('NO_ACTIVE_SESSION: peaks job requires --session-id or an active peaks-code session via peaks workspace init');
40
71
  }
41
72
  const rootDir = join(project, '.peaks', '_runtime', sessionId, 'job');
73
+ if (jobId && !existsSync(join(rootDir, jobId, 'state.json'))) {
74
+ const other = findSessionHoldingJob(project, jobId);
75
+ throw new Error(other
76
+ ? `JOB_NOT_IN_SESSION: no job "${jobId}" in session "${sessionId}"; it lives in session "${other}" — re-run with --session-id ${other}`
77
+ : `JOB_NOT_IN_SESSION: no job "${jobId}" in session "${sessionId}" (and no other session under .peaks/_runtime/ has it)`);
78
+ }
42
79
  return { rootDir, sessionId, projectRoot: project };
43
80
  }
81
+ /**
82
+ * D7: slices are keyed `slice-NNN`, but `peaks job init --slice-list "S1,…"`
83
+ * takes labels, so the natural string to pass back to `--slice-id` is that same
84
+ * label. Resolve an exact sliceId OR an exact label to the canonical sliceId;
85
+ * anything else is a hard error listing the valid ids, so a mistyped
86
+ * `slice-04` can never be accepted as a silent no-op that leaves the slice
87
+ * pending.
88
+ */
89
+ function resolveSliceId(store, jobId, sliceId) {
90
+ const slices = store.load(jobId).slices;
91
+ const hit = slices.find((sl) => sl.sliceId === sliceId || sl.label === sliceId);
92
+ if (hit)
93
+ return { sliceId: hit.sliceId };
94
+ return {
95
+ message: `no slice "${sliceId}" in job ${jobId}; valid ids: ${slices.map((sl) => `${sl.sliceId} (${sl.label})`).join(', ')}`,
96
+ validSliceIds: slices.map((sl) => sl.sliceId),
97
+ };
98
+ }
44
99
  export function registerJobCommands(program, io = { stdout: (t) => process.stdout.write(t), stderr: (t) => process.stderr.write(t) }) {
45
100
  const job = new Command('job').description('Drive long multi-slice work as one Job (peaks-code Step 0.8+)');
46
101
  job
@@ -51,14 +106,14 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
51
106
  .option('--exit-policy <strict|best-effort>', 'strict')
52
107
  .option('--main-loop-strategy <single|rotating>', 'rotating')
53
108
  .option('--rotate-every <n>', 'rotate every N slices (rotating mode)', '3')
54
- .option('--session-id <sid>', 'session id (default: read from .peaks/_runtime/session.json; required to land in the 2.7.1 single-scope-axis layout)')
109
+ .option('--session-id <sid>', SESSION_ID_HELP)
55
110
  .option('--project <repo>')
56
111
  .action(async (opts) => {
57
112
  const project = projectRoot(opts);
58
- // Resolve sessionId: explicit flag > canonical session binding > FAIL.
113
+ // Resolve sessionId: explicit flag > PEAKS_SESSION_ID > canonical session binding > FAIL.
59
114
  // Per spec §3.3, Job state lives at .peaks/_runtime/<sessionId>/job/<jobId>/state.json —
60
115
  // a random UUID would scatter state across dirs and break resume/auto-compact.
61
- let sessionId = opts.sessionId ?? getCurrentSessionId(project);
116
+ let sessionId = opts.sessionId ?? process.env.PEAKS_SESSION_ID ?? getCurrentSessionId(project);
62
117
  if (!sessionId) {
63
118
  return printResult(io, fail('init', 'NO_ACTIVE_SESSION', 'peaks job init requires --session-id (or an active peaks-code session via peaks workspace init)', { project }, [
64
119
  'Re-run with --session-id <sid>',
@@ -105,9 +160,10 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
105
160
  .requiredOption('--job-id <jid>')
106
161
  .option('--watch', 'poll every 3s')
107
162
  .option('--show-cost', 'overlay cost from peaks budget')
163
+ .option('--session-id <sid>', SESSION_ID_HELP)
108
164
  .option('--project <repo>')
109
165
  .action(async (opts) => {
110
- const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
166
+ const store = new JobStateStore(resolveJobStateRoot(opts, opts.jobId).rootDir);
111
167
  const orch = new JobOrchestrator(store);
112
168
  const s = orch.status(opts.jobId);
113
169
  if (opts.watch) {
@@ -134,9 +190,10 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
134
190
  // M4.2: wire rotate-now to JobRotation (session-rotate callbacks are stubs pending M6.5 batch-fix).
135
191
  job.command('rotate-now')
136
192
  .requiredOption('--job-id <jid>')
193
+ .option('--session-id <sid>', SESSION_ID_HELP)
137
194
  .option('--project <repo>')
138
195
  .action(async (opts) => {
139
- const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
196
+ const store = new JobStateStore(resolveJobStateRoot(opts, opts.jobId).rootDir);
140
197
  const rotation = new JobRotation(store, async (_jid) => { /* delegate to peaks session rotate — implementation wired in M6.5 batch-fix */ return { rotated: true }; }, async (jid) => ({ jobId: jid, cycle: 0 }));
141
198
  const r = await rotation.rotateNow(opts.jobId);
142
199
  printResult(io, ok('rotate-now', r), opts);
@@ -146,9 +203,10 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
146
203
  .requiredOption('--job-id <jid>')
147
204
  .requiredOption('--batch-id <bid>')
148
205
  .option('--force')
206
+ .option('--session-id <sid>', SESSION_ID_HELP)
149
207
  .option('--project <repo>')
150
208
  .action(async (opts) => {
151
- const wrapper = new SubAgentJobWrapper(new JobStateStore(resolveJobStateRoot(opts).rootDir), async () => ({ batchId: opts.batchId }));
209
+ const wrapper = new SubAgentJobWrapper(new JobStateStore(resolveJobStateRoot(opts, opts.jobId).rootDir), async () => ({ batchId: opts.batchId }));
152
210
  const r = await wrapper.cleanup({ jobId: opts.jobId, batchId: opts.batchId, force: !!opts.force });
153
211
  printResult(io, ok('subagent-cleanup', r), opts);
154
212
  });
@@ -161,6 +219,7 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
161
219
  .requiredOption('--state <done|failed|skipped>')
162
220
  .option('--commit-sha <sha>')
163
221
  .option('--reason <text>')
222
+ .option('--session-id <sid>', SESSION_ID_HELP)
164
223
  .option('--project <repo>')
165
224
  .action(async (opts) => {
166
225
  const parsed = JobCheckpointInputSchema.safeParse({
@@ -170,15 +229,25 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
170
229
  });
171
230
  if (!parsed.success)
172
231
  return printResult(io, fail('checkpoint', 'INVALID_CHECKPOINT', parsed.error.message, {}), opts);
173
- const jobRoot = resolveJobStateRoot(opts);
232
+ const jobRoot = resolveJobStateRoot(opts, opts.jobId);
174
233
  const store = new JobStateStore(jobRoot.rootDir);
234
+ // D7: `--slice-id` accepts the canonical `slice-NNN` or the slice's label
235
+ // ("S1"); an id that matches no slice is rejected here, BEFORE any write,
236
+ // so progress.json is never touched by a checkpoint that matched nothing.
237
+ const slice = resolveSliceId(store, parsed.data.jobId, parsed.data.sliceId);
238
+ if ('message' in slice) {
239
+ return printResult(io, fail('checkpoint', 'SLICE_NOT_FOUND', slice.message, {
240
+ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, validSliceIds: slice.validSliceIds,
241
+ }, ['Re-run with one of the valid slice ids']), opts);
242
+ }
243
+ const sliceId = slice.sliceId;
175
244
  const orch = new JobOrchestrator(store);
176
245
  // 2026-09-03-codegraph-autorefresh: set on --state done so the ok
177
246
  // envelope carries a non-blocking `codegraph` result; null for
178
247
  // failed/skipped (no slice-complete boundary).
179
248
  let codegraph = null;
180
249
  if (parsed.data.state === 'done') {
181
- await orch.checkpointDone({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, ...(parsed.data.commitSha ? { commitSha: parsed.data.commitSha } : {}) });
250
+ await orch.checkpointDone({ jobId: parsed.data.jobId, sliceId, ...(parsed.data.commitSha ? { commitSha: parsed.data.commitSha } : {}) });
182
251
  // v3.1.2: after each --state done, mirror slice progress to
183
252
  // .peaks/_runtime/<sessionId>/job/<jid>/progress.json so the
184
253
  // next LLM turn (or peaks code gate-step-08 hook) can read it.
@@ -203,12 +272,12 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
203
272
  }
204
273
  }
205
274
  else if (parsed.data.state === 'skipped') {
206
- await orch.checkpointSkipped({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, reason: parsed.data.reason });
275
+ await orch.checkpointSkipped({ jobId: parsed.data.jobId, sliceId, reason: parsed.data.reason });
207
276
  }
208
277
  else {
209
- await orch.checkpointFailed({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, reason: parsed.data.reason });
278
+ await orch.checkpointFailed({ jobId: parsed.data.jobId, sliceId, reason: parsed.data.reason });
210
279
  }
211
- printResult(io, ok('checkpoint', { sliceId: parsed.data.sliceId, status: parsed.data.state, codegraph }), opts);
280
+ printResult(io, ok('checkpoint', { sliceId, status: parsed.data.state, codegraph }), opts);
212
281
  });
213
282
  addJsonOption(job.commands.find(c => c.name() === 'checkpoint'));
214
283
  job
@@ -216,6 +285,7 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
216
285
  .requiredOption('--job-id <jid>')
217
286
  .requiredOption('--slice-id <rid>')
218
287
  .requiredOption('--reason <text>')
288
+ .option('--session-id <sid>', SESSION_ID_HELP)
219
289
  .option('--project <repo>')
220
290
  .action(async (opts) => {
221
291
  const parsed = JobBlockInputSchema.safeParse({
@@ -224,18 +294,27 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
224
294
  });
225
295
  if (!parsed.success)
226
296
  return printResult(io, fail('block', 'INVALID_BLOCK', parsed.error.message, {}), opts);
227
- const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
297
+ const store = new JobStateStore(resolveJobStateRoot(opts, opts.jobId).rootDir);
298
+ // D7 (same silent no-op as checkpoint): resolve label → sliceId, reject a
299
+ // miss before any write.
300
+ const slice = resolveSliceId(store, parsed.data.jobId, parsed.data.sliceId);
301
+ if ('message' in slice) {
302
+ return printResult(io, fail('block', 'SLICE_NOT_FOUND', slice.message, {
303
+ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, validSliceIds: slice.validSliceIds,
304
+ }, ['Re-run with one of the valid slice ids']), opts);
305
+ }
228
306
  const orch = new JobOrchestrator(store);
229
- await orch.blockSlice(parsed.data);
230
- printResult(io, ok('block', { blocked: parsed.data.sliceId, reason: parsed.data.reason }), opts);
307
+ await orch.blockSlice({ ...parsed.data, sliceId: slice.sliceId });
308
+ printResult(io, ok('block', { blocked: slice.sliceId, reason: parsed.data.reason }), opts);
231
309
  });
232
310
  addJsonOption(job.commands.find(c => c.name() === 'block'));
233
311
  job
234
312
  .command('continue')
235
313
  .requiredOption('--job-id <jid>')
314
+ .option('--session-id <sid>', SESSION_ID_HELP)
236
315
  .option('--project <repo>')
237
316
  .action(async (opts) => {
238
- const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
317
+ const store = new JobStateStore(resolveJobStateRoot(opts, opts.jobId).rootDir);
239
318
  const orch = new JobOrchestrator(store);
240
319
  const r = orch.continueNow(opts.jobId);
241
320
  printResult(io, ok('continue', r), opts);
@@ -244,9 +323,10 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
244
323
  job
245
324
  .command('resume')
246
325
  .requiredOption('--job-id <jid>')
326
+ .option('--session-id <sid>', SESSION_ID_HELP)
247
327
  .option('--project <repo>')
248
328
  .action(async (opts) => {
249
- const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
329
+ const store = new JobStateStore(resolveJobStateRoot(opts, opts.jobId).rootDir);
250
330
  const orch = new JobOrchestrator(store);
251
331
  const s = orch.status(opts.jobId);
252
332
  printResult(io, ok('resume', { resumed: opts.jobId, ...s }), opts);
@@ -261,11 +341,12 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
261
341
  .description('v3.1.2: read the on-disk slice progress mirror (.peaks/_runtime/<sid>/job/<jid>/progress.json). ' +
262
342
  'Returns { jobId, done, total, currentSlice, lastCommitSha, updatedAt }.')
263
343
  .requiredOption('--job-id <jid>')
344
+ .option('--session-id <sid>', SESSION_ID_HELP)
264
345
  .option('--project <repo>')
265
346
  .option('--allow-missing', 'return done=0/total=0 envelope instead of failing when progress.json is absent')
266
347
  .action(async (opts) => {
267
348
  try {
268
- const jobRoot = resolveJobStateRoot(opts);
349
+ const jobRoot = resolveJobStateRoot(opts, opts.jobId);
269
350
  const sessId = jobRoot.sessionId;
270
351
  const project = projectRoot(opts);
271
352
  const progress = opts.allowMissing === true
@@ -292,9 +373,10 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
292
373
  job
293
374
  .command('handoff')
294
375
  .requiredOption('--job-id <jid>')
376
+ .option('--session-id <sid>', SESSION_ID_HELP)
295
377
  .option('--project <repo>')
296
378
  .action(async (opts) => {
297
- const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
379
+ const store = new JobStateStore(resolveJobStateRoot(opts, opts.jobId).rootDir);
298
380
  const orch = new JobOrchestrator(store);
299
381
  const s = orch.status(opts.jobId);
300
382
  printResult(io, ok('handoff', { handoffFor: opts.jobId, ...s }), opts);
@@ -305,10 +387,10 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
305
387
  .description('Read the slice\'s rd/karpathy-review.md and decide whether to downgrade a block gateAction to warn (slice 2026-07-30-karpathy-cost-self-review).')
306
388
  .requiredOption('--review-file <path>', 'path to rd/karpathy-review.md (or its .json sibling if the file is JSON)')
307
389
  .option('--project <repo>')
308
- .option('--session-id <sid>')
390
+ .option('--session-id <sid>', SESSION_ID_HELP)
309
391
  .action(async (opts) => {
310
392
  const project = projectRoot(opts);
311
- const sessionId = opts.sessionId ?? getCurrentSessionId(project);
393
+ const sessionId = opts.sessionId ?? process.env.PEAKS_SESSION_ID ?? getCurrentSessionId(project);
312
394
  if (!sessionId) {
313
395
  return printResult(io, fail('karpathy-cost-check', 'NO_ACTIVE_SESSION', 'karpathy-cost-check requires --session-id (or an active peaks-code session)', { project }, [
314
396
  'Re-run with --session-id <sid>',
@@ -198,7 +198,7 @@ export function registerScanCommands(program, io) {
198
198
  });
199
199
  addJsonOption(scan
200
200
  .command('file-size')
201
- .description('Check git diff for files exceeding a line count threshold (karpathy-skills "Simplicity First")')
201
+ .description('Check git diff for files exceeding a line count threshold (karpathy-skills "Simplicity First"; tool output, lockfiles and append-only records such as CHANGELOG.md excluded)')
202
202
  .requiredOption('--project <path>', 'target project root')
203
203
  .option('--base-ref <ref>', 'compare working tree against this git ref (default: HEAD)')
204
204
  .option('--threshold <n>', `line count threshold (default: ${DEFAULT_FILE_SIZE_THRESHOLD})`)).action((options) => {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `peaks web open|text|snap|click|shot|metrics` — the S1 command surface.
3
+ *
4
+ * Layer rule (tech-doc §1.1): this file owns commander wiring, option parsing,
5
+ * `--json`, `printResult` and `process.exitCode`. It owns ZERO path
6
+ * construction and ZERO Playwright calls — those live in `src/services/web/*`.
7
+ *
8
+ * The envelope is built once, in `runWebOp`, and that is also the single place
9
+ * `WRAPPED_OPS` is applied — one place, not six. Every page- or daemon-derived
10
+ * string that leaves this process (payload, diagnostic, warning) is capped and
11
+ * wrapped here, because this is the boundary the model actually reads.
12
+ */
13
+ import type { Command } from 'commander';
14
+ import type { WebOp } from '../../services/web/web-protocol.js';
15
+ import { type ProgramIO } from '../cli-helpers.js';
16
+ export declare function registerWebCommands(program: Command, io: ProgramIO): void;
17
+ /**
18
+ * Resolve the session, ensure a daemon, invoke one op, and emit exactly one
19
+ * envelope. `WRAPPED_OPS` is applied here, after the byte caps the daemon
20
+ * already imposed, so the UNTRUSTED markers are never themselves truncated.
21
+ *
22
+ * The `PEAKS_WEB_DISABLED` gate is the FIRST thing that happens (tech-doc §5.1
23
+ * step 1, AC5). Before the session lookup, so a project with no binding still
24
+ * gets `WEB_DISABLED` rather than `NO_SESSION`; before `ensureDaemon`, so
25
+ * nothing is spawned and no lock is taken; and therefore before anything that
26
+ * could touch the browser cache.
27
+ */
28
+ export declare function runWebOp(io: ProgramIO, op: WebOp, args: Record<string, unknown>, asJson: boolean): Promise<void>;
@@ -0,0 +1,327 @@
1
+ import { fail, getErrorMessage, ok } from 'peaks-loop-shared/result';
2
+ import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
3
+ import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
4
+ import { capText, MAX_TEXT_BYTES } from '../../services/web/bounded-output.js';
5
+ import { ensureDaemon } from '../../services/web/daemon-supervisor.js';
6
+ import { wrapUntrusted, WRAPPED_OPS } from '../../services/web/untrusted-envelope.js';
7
+ import { WebDaemonClient } from '../../services/web/web-client.js';
8
+ import { degradedEnvelope } from '../../services/web/web-fallback.js';
9
+ import { isWebDisabled } from '../../services/web/web-install-service.js';
10
+ import { cappedEcho, resolveProfileName } from '../../services/web/web-login-profile.js';
11
+ import { addJsonOption, printResult, redactSensitiveErrorMessage } from '../cli-helpers.js';
12
+ import { registerWebLifecycleCommands } from './web-lifecycle-commands.js';
13
+ /** A browser op is user-visible latency; 30 s is generous but bounded. */
14
+ const OP_TIMEOUT_MS = 30_000;
15
+ /**
16
+ * The option text names the read-only half out loud: a caller who browses with a
17
+ * profile must not assume the profile was refreshed by it.
18
+ */
19
+ const PROFILE_OPTION_DESCRIPTION = 'navigate with a saved login profile (`peaks web login --profile <name>`): [a-z0-9._-], ' +
20
+ '1-64 chars, upper case folds to lower. The profile is READ-ONLY here — this run loads it ' +
21
+ 'and never writes it back, so browser activity is not saved into it.';
22
+ const WEB_VERBS = [
23
+ {
24
+ name: 'open',
25
+ op: 'open',
26
+ description: 'Navigate the dispatch browser context to <url>. With --profile, the page loads that ' +
27
+ 'saved login.',
28
+ argument: { name: '<url>', description: 'absolute URL to load' },
29
+ takesProfile: true,
30
+ toArgs: (positional, profile) => ({
31
+ url: positional[0] ?? '',
32
+ ...(profile === undefined ? {} : { profile })
33
+ })
34
+ },
35
+ {
36
+ name: 'text',
37
+ op: 'text',
38
+ description: 'Return the visible text of the page (or of [selector]), byte-capped.',
39
+ argument: { name: '[selector]', description: 'CSS selector (default: body)' },
40
+ toArgs: (positional) => ({ selector: positional[0] })
41
+ },
42
+ {
43
+ name: 'snap',
44
+ op: 'snap',
45
+ description: 'Return a pruned ARIA snapshot of the page (or of [selector]), byte-capped.',
46
+ argument: { name: '[selector]', description: 'CSS selector (default: body)' },
47
+ toArgs: (positional) => ({ selector: positional[0] })
48
+ },
49
+ {
50
+ name: 'click',
51
+ op: 'click',
52
+ description: 'Click the element matching <selector> in the dispatch browser context.',
53
+ argument: { name: '<selector>', description: 'CSS selector to click' },
54
+ toArgs: (positional) => ({ selector: positional[0] ?? '' })
55
+ },
56
+ {
57
+ name: 'shot',
58
+ op: 'shot',
59
+ description: 'Screenshot the page (or [selector]) into the session web/ directory.',
60
+ argument: { name: '[selector]', description: 'CSS selector (default: full page)' },
61
+ toArgs: (positional) => ({ selector: positional[0] })
62
+ },
63
+ {
64
+ name: 'metrics',
65
+ op: 'metrics',
66
+ description: 'Return Core Web Vitals for the dispatch page, or why they are unavailable.',
67
+ argument: null,
68
+ toArgs: () => ({})
69
+ }
70
+ ];
71
+ export function registerWebCommands(program, io) {
72
+ const web = program
73
+ .command('web')
74
+ .description('Bounded, isolated browser access driven by a pinned local Playwright. This is the primary ' +
75
+ 'browser path; `peaks playwright` is kept as the MCP fallback. Every artifact lands under ' +
76
+ '.peaks/_runtime/<sessionId>/web/ — never in the project root.');
77
+ for (const verb of WEB_VERBS) {
78
+ const takesArgument = verb.argument !== null;
79
+ let command = web.command(verb.name).description(verb.description);
80
+ if (verb.argument !== null) {
81
+ command = command.argument(verb.argument.name, verb.argument.description);
82
+ }
83
+ if (verb.takesProfile === true) {
84
+ command = command.option('--profile <name>', PROFILE_OPTION_DESCRIPTION);
85
+ }
86
+ command = addJsonOption(command);
87
+ // Commander calls the handler as (…declaredArgs, options, command), so the
88
+ // options object sits at the declared-argument count — not at the end.
89
+ command.action(async (...actionArgs) => {
90
+ const options = actionArgs[takesArgument ? 1 : 0];
91
+ const rawArgument = actionArgs[0];
92
+ const positional = takesArgument
93
+ ? [typeof rawArgument === 'string' ? rawArgument : undefined]
94
+ : [];
95
+ await runWebOp(io, verb.op, verb.toArgs(positional, options?.profile), options?.json === true);
96
+ });
97
+ }
98
+ registerWebLifecycleCommands(web, io);
99
+ }
100
+ /**
101
+ * Resolve the session, ensure a daemon, invoke one op, and emit exactly one
102
+ * envelope. `WRAPPED_OPS` is applied here, after the byte caps the daemon
103
+ * already imposed, so the UNTRUSTED markers are never themselves truncated.
104
+ *
105
+ * The `PEAKS_WEB_DISABLED` gate is the FIRST thing that happens (tech-doc §5.1
106
+ * step 1, AC5). Before the session lookup, so a project with no binding still
107
+ * gets `WEB_DISABLED` rather than `NO_SESSION`; before `ensureDaemon`, so
108
+ * nothing is spawned and no lock is taken; and therefore before anything that
109
+ * could touch the browser cache.
110
+ */
111
+ export async function runWebOp(io, op, args, asJson) {
112
+ const command = `peaks.web.${op}`;
113
+ // Declared outside the try so the CATCH reports the fold too (S4's F5/S5 rule
114
+ // for `login`, applied here): a run that dies after the name was resolved
115
+ // knows the canonical name just as well as a successful one.
116
+ let foldWarnings = [];
117
+ try {
118
+ if (isWebDisabled(process.env)) {
119
+ // The gate is statement #1, so a `--profile` has NOT been through the
120
+ // resolver yet and is still unbounded caller text. `degradedEnvelope`
121
+ // carries every string arg into the payload, so it is capped here — the
122
+ // same cap the `login` gate applies, for the same reason (S1's bounded
123
+ // output is a property of the envelope, not only of stdout).
124
+ const gateArgs = typeof args['profile'] === 'string'
125
+ ? { ...args, profile: cappedEcho(args['profile']) }
126
+ : args;
127
+ printResult(io, degradedEnvelope(op, 'PEAKS_WEB_DISABLED=1', 3, gateArgs), asJson);
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+ // A caller-supplied `--profile` is validated HERE, before anything is sent,
132
+ // and the daemon runs the SAME resolver again on the payload it receives (a
133
+ // value off the wire is not trusted). `resolveProfileName` folds to lower
134
+ // case, so the canonical name is what travels, and the fold is reported
135
+ // rather than silent — the contract `login` honours.
136
+ let profile;
137
+ if (typeof args['profile'] === 'string') {
138
+ const typed = args['profile'];
139
+ try {
140
+ profile = resolveProfileName(typed);
141
+ }
142
+ catch (error) {
143
+ printResult(io, fail(command, 'WEB_PROFILE_NAME_INVALID', profileRefusal(error), {}, PROFILE_NEXT_ACTIONS), asJson);
144
+ process.exitCode = 1;
145
+ return;
146
+ }
147
+ if (profile !== typed) {
148
+ foldWarnings = [
149
+ `--profile ${JSON.stringify(cappedEcho(typed))} resolved to the profile "${profile}"`
150
+ ];
151
+ }
152
+ }
153
+ const opArgs = profile === undefined ? args : { ...args, profile };
154
+ const projectRoot = resolveCanonicalProjectRoot(process.cwd());
155
+ const sessionId = getCurrentSessionId(projectRoot);
156
+ if (sessionId === null) {
157
+ printResult(io, withFold(fail(command, 'NO_SESSION', 'No peaks session is bound to this project root', {}, [
158
+ 'Bind a session first (the LLM runs `peaks workspace init` on your behalf)'
159
+ ]), foldWarnings), asJson);
160
+ process.exitCode = 1;
161
+ return;
162
+ }
163
+ const info = await ensureDaemon(projectRoot, sessionId);
164
+ const response = await new WebDaemonClient(info).call(op, { ...opArgs, dispatchId: dispatchId(), projectRoot, sessionId }, OP_TIMEOUT_MS);
165
+ if (!response.ok || response.data === null) {
166
+ const code = safeDaemonCode(response.code);
167
+ // The daemon no longer downloads (R3), so "the browser is not installed"
168
+ // arrives as a refusal. It is AC5's tier-3 branch, not an opaque op
169
+ // failure: the caller must be handed the same envelope — MCP tool,
170
+ // install command, screenshot consequence — that the gate produces.
171
+ printResult(io, code === 'WEB_INSTALL_REQUIRED'
172
+ ? withFold(degradedEnvelope(op, `WEB_INSTALL_REQUIRED: ${response.message ?? ''}`, 3, opArgs), foldWarnings)
173
+ : withFold(fail(command, code, failureMessage(op, response.message, response.nextActions), {}, []), foldWarnings), asJson);
174
+ process.exitCode = 1;
175
+ return;
176
+ }
177
+ const wrapped = wrapPageData(op, response.data);
178
+ emit(io, ok(command, wrapped.data, [...foldWarnings, ...wrapDiagnostics(response.warnings)]), asJson, wrapped.human);
179
+ }
180
+ catch (error) {
181
+ printResult(io, withFold(fail(command, 'WEB_OP_FAILED', failureMessage(op, redactSensitiveErrorMessage(getErrorMessage(error)), []), {}, []), foldWarnings), asJson);
182
+ process.exitCode = 1;
183
+ }
184
+ }
185
+ /**
186
+ * What a caller can do after a refused `--profile` — the same sentence `login`
187
+ * gives, because it is the same mistake and the same verb fixes it.
188
+ */
189
+ const PROFILE_NEXT_ACTIONS = [
190
+ 'Re-run with a name matching [a-z0-9._-], 1-64 chars',
191
+ 'Or run `peaks web login --profile <name>` to create that profile'
192
+ ];
193
+ /**
194
+ * The resolver's own message begins with the code, and `fail()` puts the code in
195
+ * front of the message again — strip it, so human output does not read
196
+ * `WEB_PROFILE_NAME_INVALID: WEB_PROFILE_NAME_INVALID: …` (the `login` verb does
197
+ * the same).
198
+ */
199
+ function profileRefusal(error) {
200
+ return getErrorMessage(error).replace(/^WEB_PROFILE_NAME_INVALID:\s*/, '');
201
+ }
202
+ /** Prepend the fold notice to an envelope's warnings; never rewrite them away. */
203
+ function withFold(envelope, warnings) {
204
+ return warnings.length === 0 ? envelope : { ...envelope, warnings: [...warnings, ...envelope.warnings] };
205
+ }
206
+ /**
207
+ * A daemon- or page-derived diagnostic is DATA, never an instruction: it is
208
+ * byte-capped and wrapped before it can reach `message` or `warnings` (AC4 /
209
+ * R4 — the envelope must cover the diagnostic channel, not only `data`).
210
+ *
211
+ * This matters because Playwright's own error messages embed the matched
212
+ * elements' HTML, so a page with two elements matching a selector can put
213
+ * arbitrary text on a channel the CLI would otherwise print verbatim to stdout.
214
+ */
215
+ function wrapDiagnostic(raw) {
216
+ const capped = capText(text(raw), MAX_TEXT_BYTES).text;
217
+ return capped === '' ? '' : wrapUntrusted(capped);
218
+ }
219
+ /** Every daemon warning, capped and wrapped. Empty entries are dropped. */
220
+ function wrapDiagnostics(values) {
221
+ return values.map((value) => wrapDiagnostic(value)).filter((value) => value !== '');
222
+ }
223
+ /**
224
+ * Our own static sentence, then the daemon's own words as a wrapped, capped
225
+ * block. The daemon's `nextActions` are folded into that block rather than
226
+ * forwarded: `printResult` prints `nextActions` to STDOUT as `next: …`, the
227
+ * channel the notice calls instruction, and the daemon's text is not ours to
228
+ * promote there.
229
+ */
230
+ function failureMessage(op, message, nextActions) {
231
+ const detail = [text(message), ...nextActions.map((action) => text(action))]
232
+ .filter((line) => line !== '')
233
+ .join('\n');
234
+ const wrapped = wrapDiagnostic(detail);
235
+ return wrapped === '' ? `peaks web ${op} failed in the daemon` : `peaks web ${op} failed in the daemon\n${wrapped}`;
236
+ }
237
+ /**
238
+ * A daemon-supplied `code` is printed on the envelope's first line, so only a
239
+ * protocol-shaped identifier is accepted; anything else falls back to ours.
240
+ */
241
+ function safeDaemonCode(value) {
242
+ return typeof value === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(value) ? value : 'WEB_OP_FAILED';
243
+ }
244
+ /**
245
+ * Wrap the page-controlled part of each verb's payload (AC4 / §6.2) and pick
246
+ * the bytes that human mode prints verbatim. `shot` is unwrapped: its path and
247
+ * byte count are ours, not the page's.
248
+ */
249
+ function wrapPageData(op, raw) {
250
+ if (!WRAPPED_OPS.has(op)) {
251
+ return { data: raw, human: null };
252
+ }
253
+ switch (op) {
254
+ case 'open': {
255
+ const title = wrapUntrusted(text(raw['title']));
256
+ return { data: { url: wrapUntrusted(text(raw['url'])), title }, human: title };
257
+ }
258
+ case 'text': {
259
+ const value = wrapUntrusted(text(raw['text']));
260
+ return {
261
+ data: { text: value, truncated: raw['truncated'] === true, droppedBytes: count(raw['droppedBytes']) },
262
+ human: value
263
+ };
264
+ }
265
+ case 'snap': {
266
+ const snapshot = wrapUntrusted(text(raw['snapshot']));
267
+ return {
268
+ data: {
269
+ snapshot,
270
+ droppedNodes: count(raw['droppedNodes']),
271
+ depthCapped: raw['depthCapped'] === true,
272
+ nodeCapped: raw['nodeCapped'] === true,
273
+ truncated: raw['truncated'] === true,
274
+ droppedBytes: count(raw['droppedBytes'])
275
+ },
276
+ human: snapshot
277
+ };
278
+ }
279
+ case 'click': {
280
+ const result = wrapUntrusted(text(raw['result']));
281
+ return { data: { result }, human: result };
282
+ }
283
+ case 'metrics': {
284
+ // The rendered lines come from the daemon's payload, so they are capped
285
+ // here as well as at the producer: this is the boundary that reaches
286
+ // stdout, and the ceiling must hold whatever the daemon sends.
287
+ const metrics = wrapUntrusted(capText(renderMetrics(raw), MAX_TEXT_BYTES).text);
288
+ return { data: { metrics }, human: metrics };
289
+ }
290
+ default:
291
+ return { data: raw, human: null };
292
+ }
293
+ }
294
+ /**
295
+ * Print the envelope. Failures and `--json` go through `printResult`; a
296
+ * successful wrapped op in human mode prints its payload RAW, because the
297
+ * UNTRUSTED delimiters must stay on their own lines (AC4) and AC2 measures the
298
+ * byte count of exactly this stdout.
299
+ */
300
+ function emit(io, result, asJson, humanPayload) {
301
+ if (!result.ok || asJson || humanPayload === null) {
302
+ printResult(io, result, asJson);
303
+ return;
304
+ }
305
+ io.stdout(humanPayload);
306
+ for (const warning of result.warnings) {
307
+ io.stderr(`warning: ${warning}`);
308
+ }
309
+ }
310
+ /** `available: false` is reported as such — never as fabricated zeros (C4). */
311
+ function renderMetrics(raw) {
312
+ if (raw['available'] !== true) {
313
+ return `available: false\nreason: ${text(raw['reason']) || 'unavailable'}`;
314
+ }
315
+ const values = (raw['values'] ?? {});
316
+ const lines = Object.entries(values).map(([key, value]) => `${key}: ${String(value)}`);
317
+ return lines.length > 0 ? lines.join('\n') : 'available: true';
318
+ }
319
+ function dispatchId() {
320
+ return process.env['PEAKS_DISPATCH_ID'] ?? 'current';
321
+ }
322
+ function text(value) {
323
+ return typeof value === 'string' ? value : '';
324
+ }
325
+ function count(value) {
326
+ return typeof value === 'number' ? value : 0;
327
+ }