peaks-cli 1.3.1 → 1.3.3

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 (111) hide show
  1. package/README.md +6 -2
  2. package/bin/peaks.js +0 -0
  3. package/dist/src/cli/commands/core-artifact-commands.js +49 -11
  4. package/dist/src/cli/commands/gate-commands.js +28 -19
  5. package/dist/src/cli/commands/hook-handle.d.ts +17 -0
  6. package/dist/src/cli/commands/hook-handle.js +111 -0
  7. package/dist/src/cli/commands/hooks-commands.js +72 -21
  8. package/dist/src/cli/commands/progress-commands.js +9 -2
  9. package/dist/src/cli/commands/progress-start-spawn.js +30 -4
  10. package/dist/src/cli/commands/slice-commands.js +4 -2
  11. package/dist/src/cli/commands/statusline-commands.js +75 -17
  12. package/dist/src/cli/commands/sub-agent-commands.d.ts +5 -0
  13. package/dist/src/cli/commands/sub-agent-commands.js +488 -0
  14. package/dist/src/cli/commands/sub-agent-dispatch-guard.d.ts +55 -0
  15. package/dist/src/cli/commands/sub-agent-dispatch-guard.js +57 -0
  16. package/dist/src/cli/commands/workspace-commands.js +70 -14
  17. package/dist/src/cli/program.js +9 -0
  18. package/dist/src/hooks/pre-tool-use-sub-agent.d.ts +28 -0
  19. package/dist/src/hooks/pre-tool-use-sub-agent.js +105 -0
  20. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +12 -0
  21. package/dist/src/services/artifacts/artifact-prerequisites.js +39 -8
  22. package/dist/src/services/artifacts/request-artifact-service.js +116 -76
  23. package/dist/src/services/config/config-types.d.ts +1 -1
  24. package/dist/src/services/context/artifact-meta.d.ts +72 -0
  25. package/dist/src/services/context/artifact-meta.js +105 -0
  26. package/dist/src/services/context/context-guard.d.ts +49 -0
  27. package/dist/src/services/context/context-guard.js +91 -0
  28. package/dist/src/services/context/dispatch-context-guard.d.ts +27 -0
  29. package/dist/src/services/context/dispatch-context-guard.js +192 -0
  30. package/dist/src/services/context/headroom-client.d.ts +34 -0
  31. package/dist/src/services/context/headroom-client.js +117 -0
  32. package/dist/src/services/context/shared-channel.d.ts +92 -0
  33. package/dist/src/services/context/shared-channel.js +285 -0
  34. package/dist/src/services/context/threshold.d.ts +35 -0
  35. package/dist/src/services/context/threshold.js +76 -0
  36. package/dist/src/services/dispatch/batch-counter.d.ts +27 -0
  37. package/dist/src/services/dispatch/batch-counter.js +85 -0
  38. package/dist/src/services/dispatch/dispatch-record-writer.d.ts +93 -0
  39. package/dist/src/services/dispatch/dispatch-record-writer.js +261 -0
  40. package/dist/src/services/dispatch/heartbeat-truncator.d.ts +26 -0
  41. package/dist/src/services/dispatch/heartbeat-truncator.js +13 -0
  42. package/dist/src/services/dispatch/leak-detector.d.ts +11 -0
  43. package/dist/src/services/dispatch/leak-detector.js +72 -0
  44. package/dist/src/services/dispatch/sub-agent-dispatcher.d.ts +127 -0
  45. package/dist/src/services/dispatch/sub-agent-dispatcher.js +98 -0
  46. package/dist/src/services/doctor/doctor-service.d.ts +62 -0
  47. package/dist/src/services/doctor/doctor-service.js +276 -1
  48. package/dist/src/services/ide/adapters/claude-code-adapter.d.ts +18 -0
  49. package/dist/src/services/ide/adapters/claude-code-adapter.js +53 -0
  50. package/dist/src/services/ide/adapters/trae-adapter.d.ts +34 -0
  51. package/dist/src/services/ide/adapters/trae-adapter.js +70 -0
  52. package/dist/src/services/ide/hook-protocol.d.ts +44 -0
  53. package/dist/src/services/ide/hook-protocol.js +71 -0
  54. package/dist/src/services/ide/hook-translator.d.ts +72 -0
  55. package/dist/src/services/ide/hook-translator.js +128 -0
  56. package/dist/src/services/ide/ide-detector.d.ts +10 -0
  57. package/dist/src/services/ide/ide-detector.js +19 -0
  58. package/dist/src/services/ide/ide-registry.d.ts +14 -0
  59. package/dist/src/services/ide/ide-registry.js +45 -0
  60. package/dist/src/services/ide/ide-types.d.ts +120 -0
  61. package/dist/src/services/ide/ide-types.js +2 -0
  62. package/dist/src/services/ide/shared/atomic-json.d.ts +15 -0
  63. package/dist/src/services/ide/shared/atomic-json.js +58 -0
  64. package/dist/src/services/ide/shared/safe-path.d.ts +11 -0
  65. package/dist/src/services/ide/shared/safe-path.js +29 -0
  66. package/dist/src/services/progress/progress-service.d.ts +1 -1
  67. package/dist/src/services/progress/progress-service.js +18 -14
  68. package/dist/src/services/security/safe-settings-path.d.ts +12 -0
  69. package/dist/src/services/security/safe-settings-path.js +104 -0
  70. package/dist/src/services/session/session-manager.d.ts +22 -1
  71. package/dist/src/services/session/session-manager.js +137 -28
  72. package/dist/src/services/signal/cancel-handler.d.ts +14 -0
  73. package/dist/src/services/signal/cancel-handler.js +76 -0
  74. package/dist/src/services/skill/resume-detector.d.ts +54 -0
  75. package/dist/src/services/skill/resume-detector.js +334 -0
  76. package/dist/src/services/skill/skill-scheduler.d.ts +40 -0
  77. package/dist/src/services/skill/skill-scheduler.js +53 -0
  78. package/dist/src/services/skills/hooks-settings-service.d.ts +47 -29
  79. package/dist/src/services/skills/hooks-settings-service.js +190 -144
  80. package/dist/src/services/skills/statusline-settings-service.d.ts +33 -6
  81. package/dist/src/services/skills/statusline-settings-service.js +31 -34
  82. package/dist/src/services/slice/slice-archive-service.d.ts +20 -0
  83. package/dist/src/services/slice/slice-archive-service.js +111 -0
  84. package/dist/src/services/slice/slice-check-service.js +20 -1
  85. package/dist/src/services/slice/slice-check-types.d.ts +9 -0
  86. package/dist/src/services/solo/batch-heartbeat-poller.d.ts +51 -0
  87. package/dist/src/services/solo/batch-heartbeat-poller.js +88 -0
  88. package/dist/src/services/solo/status-line-renderer.d.ts +34 -0
  89. package/dist/src/services/solo/status-line-renderer.js +55 -0
  90. package/dist/src/services/workspace/migrate-service.js +124 -2
  91. package/dist/src/services/workspace/migrate-types.d.ts +50 -7
  92. package/dist/src/services/workspace/reconcile-service.d.ts +69 -0
  93. package/dist/src/services/workspace/reconcile-service.js +267 -48
  94. package/dist/src/services/workspace/reconcile-types.d.ts +37 -0
  95. package/dist/src/services/workspace/workspace-service.js +29 -62
  96. package/dist/src/shared/version.d.ts +1 -1
  97. package/dist/src/shared/version.js +1 -1
  98. package/package.json +2 -1
  99. package/schemas/doctor-report.schema.json +2 -2
  100. package/skills/peaks-ide/SKILL.md +159 -0
  101. package/skills/peaks-qa/SKILL.md +58 -1
  102. package/skills/peaks-qa/references/qa-fanout-contract.md +150 -0
  103. package/skills/peaks-rd/SKILL.md +52 -9
  104. package/skills/peaks-solo/SKILL.md +83 -20
  105. package/skills/peaks-solo/references/context-governance.md +144 -0
  106. package/skills/peaks-solo/references/headroom-integration.md +107 -0
  107. package/skills/peaks-solo/references/runbook.md +3 -3
  108. package/skills/peaks-solo/references/sub-agent-dispatch.md +218 -0
  109. package/skills/peaks-solo/references/swarm-dispatch-contract.md +3 -37
  110. package/skills/peaks-txt/SKILL.md +19 -0
  111. package/skills/peaks-ui/SKILL.md +28 -1
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `peaks sub-agent-dispatch-guard` — G9.5 / RL-30 strict hook-only atom.
3
+ *
4
+ * This is the **second-layer** gate (PreToolUse hook) for the G9 forced
5
+ * compression threshold. It re-validates the prompt size against the
6
+ * threshold table in `src/services/context/threshold.ts` and returns
7
+ * `{allow: true/false, reason, suggest}` JSON to the LLM platform.
8
+ *
9
+ * **NO `--force` flag is exposed at this layer** (RL-30 strict). The
10
+ * hook is the strictest layer in the G9 chain. If the CLI is bypassed
11
+ * (e.g. a user manually invokes the dispatch CLI with `--force` to
12
+ * override the 80% threshold), the hook catches it and returns
13
+ * `{allow: false}` regardless.
14
+ *
15
+ * This atom is **hidden from `peaks --help`** per dev-preference
16
+ * "skill-first / CLI-auxiliary" + PB-2 byte-stable. It is registered
17
+ * via the LLM platform's PreToolUse hook chain (e.g. Claude Code's
18
+ * `settings.json` `PreToolUse` array) and is not a user-facing command.
19
+ *
20
+ * The `peaks hooks install` command reads `IdeAdapter.promptSizeAware`
21
+ * to decide whether to register this hook for a given IDE.
22
+ */
23
+ import { Command } from 'commander';
24
+ import { type ContextGuardDecision } from '../../services/context/context-guard.js';
25
+ export declare const HOOK_GUARD_RESULT_TYPE: "peaks-hook-guard/v1";
26
+ export interface HookGuardResult {
27
+ readonly schema: typeof HOOK_GUARD_RESULT_TYPE;
28
+ readonly allow: boolean;
29
+ readonly code: ContextGuardDecision['code'];
30
+ readonly reason: string;
31
+ readonly suggest: string | null;
32
+ readonly tier: ContextGuardDecision['evaluation']['tier'];
33
+ readonly ratio: number;
34
+ readonly bytesUsed: number;
35
+ readonly capacityBytes: number;
36
+ readonly warnings: readonly string[];
37
+ }
38
+ /**
39
+ * Build the hook-guard result for a given prompt size. Pure function;
40
+ * no IO. The CLI atom (registered below) calls this and prints JSON.
41
+ *
42
+ * Even if the caller passes `force = true` in the input (it shouldn't —
43
+ * the hook CLI doesn't expose that flag), this function ignores it
44
+ * and treats the prompt as if no override were available. This is the
45
+ * RL-30 strict semantics.
46
+ */
47
+ export declare function evaluateHookGuard(promptSize: number): HookGuardResult;
48
+ /**
49
+ * Register the `peaks sub-agent-dispatch-guard` command. Intentionally
50
+ * NOT registered in the main `peaks --help` quickstart (dev-preference
51
+ * PB-2 byte-stable). The caller (the `peaks hooks install` flow) calls
52
+ * this directly via the imported function; the CLI registration in
53
+ * `src/cli/index.ts` uses a hidden command (no `description`, no help).
54
+ */
55
+ export declare function registerSubAgentDispatchGuard(program: Command): void;
@@ -0,0 +1,57 @@
1
+ import { evaluatePromptSize } from '../../services/context/context-guard.js';
2
+ export const HOOK_GUARD_RESULT_TYPE = 'peaks-hook-guard/v1';
3
+ /**
4
+ * Build the hook-guard result for a given prompt size. Pure function;
5
+ * no IO. The CLI atom (registered below) calls this and prints JSON.
6
+ *
7
+ * Even if the caller passes `force = true` in the input (it shouldn't —
8
+ * the hook CLI doesn't expose that flag), this function ignores it
9
+ * and treats the prompt as if no override were available. This is the
10
+ * RL-30 strict semantics.
11
+ */
12
+ export function evaluateHookGuard(promptSize) {
13
+ // Intentionally pass `force: false` always. The hook layer is strict.
14
+ const decision = evaluatePromptSize(promptSize, { force: false });
15
+ return {
16
+ schema: HOOK_GUARD_RESULT_TYPE,
17
+ allow: decision.allow,
18
+ code: decision.code,
19
+ reason: decision.allow
20
+ ? `prompt size ${promptSize} bytes within threshold (tier=${decision.evaluation.tier})`
21
+ : `prompt size ${promptSize} bytes exceeds threshold (tier=${decision.evaluation.tier}, ratio=${decision.evaluation.ratio.toFixed(3)})`,
22
+ suggest: decision.suggest,
23
+ tier: decision.evaluation.tier,
24
+ ratio: decision.evaluation.ratio,
25
+ bytesUsed: decision.evaluation.bytesUsed,
26
+ capacityBytes: decision.evaluation.capacityBytes,
27
+ warnings: decision.warnings
28
+ };
29
+ }
30
+ /**
31
+ * Register the `peaks sub-agent-dispatch-guard` command. Intentionally
32
+ * NOT registered in the main `peaks --help` quickstart (dev-preference
33
+ * PB-2 byte-stable). The caller (the `peaks hooks install` flow) calls
34
+ * this directly via the imported function; the CLI registration in
35
+ * `src/cli/index.ts` uses a hidden command (no `description`, no help).
36
+ */
37
+ export function registerSubAgentDispatchGuard(program) {
38
+ program
39
+ .command('sub-agent-dispatch-guard')
40
+ .description('INTERNAL: PreToolUse hook guard (G9.5 / RL-30 strict)')
41
+ .requiredOption('--prompt <text>', 'the prompt to validate (size in bytes is what gets checked)')
42
+ .option('--prompt-length <bytes>', 'DOGFOOD ONLY: synthesize a prompt of this size (overrides --prompt content for size only)')
43
+ .action((options) => {
44
+ let prompt = options.prompt;
45
+ if (typeof options.promptLength === 'string' && options.promptLength.length > 0) {
46
+ const len = Number.parseInt(options.promptLength, 10);
47
+ if (Number.isInteger(len) && len > 0) {
48
+ prompt = 'x'.repeat(len);
49
+ }
50
+ }
51
+ const promptSize = Buffer.byteLength(prompt, 'utf8');
52
+ const result = evaluateHookGuard(promptSize);
53
+ // Always exit 0 — the LLM platform reads `allow` from JSON.
54
+ // The decision is encoded in `allow` / `code`, not the exit code.
55
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
56
+ });
57
+ }
@@ -89,7 +89,7 @@ export function registerWorkspaceCommands(program, io) {
89
89
  const workspace = program.command('workspace').description('Manage the Peaks per-session artifact workspace (.peaks/<session-id>/)');
90
90
  addJsonOption(workspace
91
91
  .command('init')
92
- .description('Create the .peaks/<session-id>/ directory structure (prd, ui, rd, qa, sc, txt, system) and bind the session as the project current one. Pass --session-id to use a specific id, or omit it to auto-generate one (and adopt an existing binding if present). On the first call for a project, also handles the one-time "install peaks hooks" decision (sticky-marker stored in .peaks/.peaks-init-hooks-decision.json).')
92
+ .description('Create the .peaks/_runtime/<session-id>/ directory with ONLY the session.json metadata file (slice 006: role subdirs prd/ui/rd/qa/sc/txt and the system/ subdir are created lazily by writers, not pre-created at init). When --change-id is given, also creates the .peaks/<change-id>/ dir. Pass --session-id to use a specific id, or omit it to auto-generate one (and adopt an existing binding if present). On the first call for a project, also handles the one-time "install peaks hooks" decision (sticky-marker stored in .peaks/.peaks-init-hooks-decision.json).')
93
93
  .requiredOption('--project <path>', 'target project root')
94
94
  .option('--session-id <id>', 'optional session id in YYYY-MM-DD-<kebab-slug> format. When omitted, the CLI is the single source of truth: an existing binding is reused, otherwise a fresh id is auto-generated.')
95
95
  .option('--allow-session-rebind', 'overwrite an existing session binding when the requested session id differs from the project current one', false)
@@ -199,14 +199,24 @@ export function registerWorkspaceCommands(program, io) {
199
199
  });
200
200
  addJsonOption(workspace
201
201
  .command('reconcile')
202
- .description('Scan .peaks/2026-MM-DD-session-*/ directories and re-point .peaks/_runtime/session.json ' +
203
- 'to the canonical session (4-tier heuristic: active-skill binding -> latest session.json mtime -> ' +
204
- 'latest any-file mtime -> dir-name sort). Also migrates any legacy .peaks/.session.json / ' +
205
- '.peaks/.active-skill.json / .peaks/sop-state/ into .peaks/_runtime/ (idempotent; no-op on a ' +
206
- 'tree that is already on the new layout). By default the command is a dry-run: it reports empty / abandoned ' +
207
- `session dirs older than ${DEFAULT_RECONCILE_AGE_DAYS} days as deletion candidates but does not delete them. ` +
208
- 'Pass --apply to actually remove the listed candidate dirs (destructive). ' +
209
- 'Override the age threshold with --older-than <days>.')
202
+ .description('Scan .peaks/2026-MM-DD-session-*/ directories and consolidate the runtime state. ' +
203
+ 'By default (no --apply) the command performs four actions:\n' +
204
+ ' 1. Migrates legacy runtime files into .peaks/_runtime/: ' +
205
+ '.peaks/.session.json -> .peaks/_runtime/session.json, ' +
206
+ '.peaks/.active-skill.json -> .peaks/_runtime/active-skill.json, ' +
207
+ '.peaks/sop-state/ -> .peaks/_runtime/sop-state/ ' +
208
+ '(idempotent; no-op if already on the new layout).\n' +
209
+ ' 2. Re-points .peaks/_runtime/session.json to the canonical session ' +
210
+ 'using a 4-tier heuristic: active-skill binding -> latest session.json mtime -> ' +
211
+ 'latest any-file mtime -> dir-name sort.\n' +
212
+ ' 3. (slice 006) Syncs the single change/<sid>/ live marker under ' +
213
+ '.peaks/_runtime/change/. The marker is an empty directory; every other ' +
214
+ 'entry under change/ is removed. Also cleans up the F3-introduced ' +
215
+ '.peaks/_runtime/<sid>/system/ subdir (no-op if already absent).\n' +
216
+ ' 4. REPORTS (but does not delete) session dirs older than --older-than <days> ' +
217
+ `(default ${DEFAULT_RECONCILE_AGE_DAYS}) as deletion candidates; this is the only step that is dry-run by default.\n` +
218
+ 'Pass --apply to additionally REMOVE the listed candidate dirs (destructive). ' +
219
+ 'Migration (1), repoint (2), and marker sync (3) always run regardless of --apply.')
210
220
  .requiredOption('--project <path>', 'target project root')
211
221
  .option('--apply', 'actually delete the deletion candidates (destructive); without it, dry-run only', false)
212
222
  .option('--older-than <days>', `age threshold in days for deletion candidates (default: ${DEFAULT_RECONCILE_AGE_DAYS})`, (value) => Number.parseFloat(value))).action((options) => {
@@ -242,6 +252,21 @@ export function registerWorkspaceCommands(program, io) {
242
252
  if (!apply && result.wouldDelete.length > 0) {
243
253
  nextActions.push(`Re-run with --apply to delete ${result.wouldDelete.length} candidate dir(s).`);
244
254
  }
255
+ if (result.changeMarker.created !== null) {
256
+ nextActions.push(`Synced change/<${result.changeMarker.created}>/ live marker.`);
257
+ }
258
+ else if (result.canonicalSessionId !== null) {
259
+ nextActions.push(`change/<${result.canonicalSessionId}>/ live marker already in place.`);
260
+ }
261
+ if (result.changeMarker.removed.length > 0) {
262
+ nextActions.push(`Removed ${result.changeMarker.removed.length} stale change/<oldSid>/ marker(s).`);
263
+ }
264
+ if (result.systemCleaned.length > 0) {
265
+ nextActions.push(`Removed ${result.systemCleaned.length} F3 system/ subdir(s).`);
266
+ }
267
+ if (result.subAgentStateMigrated > 0) {
268
+ nextActions.push(`Migrated ${result.subAgentStateMigrated} legacy sub-agent state file(s) into .peaks/_sub_agents/.`);
269
+ }
245
270
  printResult(io, ok('workspace.reconcile', result, warnings, nextActions), options.json);
246
271
  if (result.errors.length > 0) {
247
272
  process.exitCode = 1;
@@ -262,18 +287,24 @@ export function registerWorkspaceCommands(program, io) {
262
287
  'response. By default the command is a dry-run: it reports the planned moves + conflicts ' +
263
288
  'and the session dirs that WOULD be deleted. Pass --apply to actually `git mv` the files ' +
264
289
  'and `rm -rf` the emptied session dirs. Idempotent: re-running on an already-migrated tree ' +
265
- 'is a no-op (all files report conflicts with identical content).')
290
+ 'is a no-op (all files report conflicts with identical content).' +
291
+ '\n\nSlice 003 (--to-runtime): moves every top-level `.peaks/<sid>/` to `.peaks/_runtime/<sid>/` ' +
292
+ 'for projects still on the pre-runtime-layer layout. Idempotent: re-running on a tree ' +
293
+ 'that is already canonical is a no-op. F15 carve-out: top-level `rd/project-scan.md` is ' +
294
+ 'never overwritten when the runtime copy already exists with different content.')
266
295
  .requiredOption('--project <path>', 'target project root')
267
- .option('--apply', 'actually `git mv` the files and delete the emptied session dirs (destructive); without it, dry-run only', false)).action(async (options) => {
296
+ .option('--apply', 'actually `git mv` the files and delete the emptied session dirs (destructive); without it, dry-run only', false)
297
+ .option('--to-runtime', 'slice 003: also consolidate every top-level .peaks/<sid>/ dir into .peaks/_runtime/<sid>/. Idempotent; conflicts are logged but never overwrite.', false)).action(async (options) => {
268
298
  try {
269
299
  const projectRoot = resolveCanonicalProjectRoot(options.project);
270
300
  const apply = options.apply === true;
271
- const result = await migrateWorkspace({ projectRoot, apply });
301
+ const toRuntime = options.toRuntime === true;
302
+ const result = await migrateWorkspace({ projectRoot, apply, toRuntime });
272
303
  const warnings = [];
273
- if (result.sessions.length === 0) {
304
+ if (result.sessions.length === 0 && (result.toRuntimePlans?.length ?? 0) === 0) {
274
305
  warnings.push('No legacy session directories found under .peaks/. Nothing to migrate.');
275
306
  }
276
- else if (result.wouldMove.length === 0) {
307
+ else if (result.wouldMove.length === 0 && (result.toRuntimePlans?.length ?? 0) === 0) {
277
308
  warnings.push('Legacy session dirs found but no reviewable content to migrate (all files were cross-cutting or transient).');
278
309
  }
279
310
  const nextActions = [];
@@ -283,6 +314,31 @@ export function registerWorkspaceCommands(program, io) {
283
314
  if (result.conflicts.length > 0) {
284
315
  nextActions.push(`${result.conflicts.length} file(s) already exist at the target path; review before --apply (or re-run after a partial migrate).`);
285
316
  }
317
+ if (toRuntime) {
318
+ const plans = result.toRuntimePlans ?? [];
319
+ if (apply) {
320
+ if ((result.toRuntimeMoved?.length ?? 0) > 0) {
321
+ nextActions.push(`Moved ${result.toRuntimeMoved?.length} top-level session dir(s) to .peaks/_runtime/ (slice 003 --to-runtime).`);
322
+ }
323
+ if ((result.toRuntimeConflicts?.length ?? 0) > 0) {
324
+ nextActions.push(`${result.toRuntimeConflicts?.length} --to-runtime conflict(s) — see response. ${plans.filter((p) => p.action === 'f15-conflict-project-scan').length} are F15 carve-outs (deferred to a separate slice).`);
325
+ }
326
+ }
327
+ else {
328
+ const wouldMoveCount = plans.filter((p) => p.action === 'moved').length;
329
+ const wouldSkipCount = plans.filter((p) => p.action === 'skipped-already-canonical').length;
330
+ if (wouldMoveCount > 0) {
331
+ nextActions.push(`Re-run with --apply to move ${wouldMoveCount} top-level session dir(s) to .peaks/_runtime/; ${wouldSkipCount} already canonical.`);
332
+ }
333
+ else if (wouldSkipCount > 0) {
334
+ nextActions.push(`All ${wouldSkipCount} top-level session dir(s) are already canonical — no moves needed.`);
335
+ }
336
+ const f15Count = plans.filter((p) => p.action === 'f15-conflict-project-scan').length;
337
+ if (f15Count > 0) {
338
+ nextActions.push(`${f15Count} F15 carve-out conflict(s) (rd/project-scan.md differs from runtime copy) — see response.`);
339
+ }
340
+ }
341
+ }
286
342
  if (apply) {
287
343
  if (result.moved.length > 0) {
288
344
  nextActions.push(`Migrated ${result.moved.length} file(s) into .peaks/retrospective/.`);
@@ -17,7 +17,10 @@ import { registerScanCommands } from './commands/scan-commands.js';
17
17
  import { registerShadcnCommands } from './commands/shadcn-commands.js';
18
18
  import { registerSliceCommands } from './commands/slice-commands.js';
19
19
  import { registerSopCommands } from './commands/sop-commands.js';
20
+ import { registerSubAgentCommands } from './commands/sub-agent-commands.js';
21
+ import { registerSubAgentDispatchGuard } from './commands/sub-agent-dispatch-guard.js';
20
22
  import { registerGateCommands } from './commands/gate-commands.js';
23
+ import { registerHookHandleCommand } from './commands/hook-handle.js';
21
24
  import { registerHooksCommands } from './commands/hooks-commands.js';
22
25
  import { registerStatusLineCommands } from './commands/statusline-commands.js';
23
26
  import { registerUnderstandCommands } from './commands/understand-commands.js';
@@ -92,7 +95,13 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
92
95
  registerShadcnCommands(program, io);
93
96
  registerSliceCommands(program, io);
94
97
  registerSopCommands(program, io);
98
+ registerSubAgentCommands(program, io);
99
+ // Slice #010 G9.5: register the hook-only internal atom. Hidden from
100
+ // `peaks --help` (no description text); used by `peaks hooks install`
101
+ // to wire the PreToolUse hook chain.
102
+ registerSubAgentDispatchGuard(program);
95
103
  registerGateCommands(program, io);
104
+ registerHookHandleCommand(program, io);
96
105
  registerHooksCommands(program, io);
97
106
  registerStatusLineCommands(program, io);
98
107
  registerUnderstandCommands(program, io);
@@ -0,0 +1,28 @@
1
+ import { type HookGuardResult } from '../cli/commands/sub-agent-dispatch-guard.js';
2
+ /**
3
+ * Read the prompt size from the LLM platform's hook stdin. Different
4
+ * LLMs send different payload shapes; we accept the most common:
5
+ * - Claude Code: `{"tool_name": "Bash", "tool_input": {"command": "..."}}`
6
+ * - Trae: `{"tool_name": "terminal", "tool_input": {"command": "..."}}`
7
+ *
8
+ * The hook reads the `command` (or `prompt`) field and computes the
9
+ * byte length. If neither field is present, returns 0 (always passes).
10
+ */
11
+ export declare function readPromptSizeFromHookStdin(stdin: unknown): number;
12
+ /**
13
+ * Execute the hook guard via spawnSync. Returns the parsed result or
14
+ * a fallback (allow: true) on subprocess failure.
15
+ *
16
+ * Prefer the in-process `evaluateHookGuard` (no subprocess) when the
17
+ * hook is called from a TypeScript context. Use `runHookGuardSubprocess`
18
+ * only when the hook needs to be invoked from a non-TypeScript caller
19
+ * (e.g. a shell script that wraps the peaks CLI).
20
+ */
21
+ export declare function runHookGuardSubprocess(prompt: string): HookGuardResult;
22
+ /**
23
+ * Main entry point for the hook. Reads the LLM platform's stdin,
24
+ * computes the prompt size, and returns the guard result. Used by
25
+ * the LLM platform's hook JSON to decide whether to allow the tool
26
+ * call.
27
+ */
28
+ export declare function runHookGuard(stdin: unknown): HookGuardResult;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * G9.5 PreToolUse hook execution body.
3
+ *
4
+ * Wraps `peaks sub-agent-dispatch-guard` for LLM platform integration.
5
+ * The hook reads the prompt size from the LLM platform's hook stdin
6
+ * (Claude Code / Trae / etc.), invokes the guard CLI, and returns
7
+ * `{allow: true/false, reason, suggest}` JSON to the LLM platform.
8
+ *
9
+ * The hook is registered via `peaks hooks install` in the LLM
10
+ * platform's `settings.json` `PreToolUse` array. Only IDEs with
11
+ * `IdeAdapter.promptSizeAware: true` get the hook installed.
12
+ *
13
+ * The hook layer is the **strictest** layer in the G9 chain (RL-30).
14
+ * The CLI 兜底 layer (`peaks sub-agent dispatch --force`) can override
15
+ * the 80% threshold; the hook layer CANNOT.
16
+ */
17
+ import { spawnSync } from 'node:child_process';
18
+ import { evaluateHookGuard } from '../cli/commands/sub-agent-dispatch-guard.js';
19
+ /**
20
+ * Read the prompt size from the LLM platform's hook stdin. Different
21
+ * LLMs send different payload shapes; we accept the most common:
22
+ * - Claude Code: `{"tool_name": "Bash", "tool_input": {"command": "..."}}`
23
+ * - Trae: `{"tool_name": "terminal", "tool_input": {"command": "..."}}`
24
+ *
25
+ * The hook reads the `command` (or `prompt`) field and computes the
26
+ * byte length. If neither field is present, returns 0 (always passes).
27
+ */
28
+ export function readPromptSizeFromHookStdin(stdin) {
29
+ if (stdin === null || typeof stdin !== 'object') {
30
+ return 0;
31
+ }
32
+ const obj = stdin;
33
+ const toolInput = obj.tool_input;
34
+ if (toolInput === null || typeof toolInput !== 'object') {
35
+ return 0;
36
+ }
37
+ const ti = toolInput;
38
+ const candidates = ['command', 'prompt', 'text', 'input'];
39
+ for (const key of candidates) {
40
+ const v = ti[key];
41
+ if (typeof v === 'string') {
42
+ return Buffer.byteLength(v, 'utf8');
43
+ }
44
+ }
45
+ return 0;
46
+ }
47
+ /**
48
+ * Execute the hook guard via spawnSync. Returns the parsed result or
49
+ * a fallback (allow: true) on subprocess failure.
50
+ *
51
+ * Prefer the in-process `evaluateHookGuard` (no subprocess) when the
52
+ * hook is called from a TypeScript context. Use `runHookGuardSubprocess`
53
+ * only when the hook needs to be invoked from a non-TypeScript caller
54
+ * (e.g. a shell script that wraps the peaks CLI).
55
+ */
56
+ export function runHookGuardSubprocess(prompt) {
57
+ const result = spawnSync('node', [
58
+ process.argv[1] ?? 'peaks',
59
+ 'sub-agent-dispatch-guard',
60
+ '--prompt', prompt,
61
+ '--json'
62
+ ], { encoding: 'utf8' });
63
+ if (result.status !== 0) {
64
+ // Fallback: allow (don't block the dispatch on a guard subprocess failure).
65
+ return {
66
+ schema: 'peaks-hook-guard/v1',
67
+ allow: true,
68
+ code: 'OK',
69
+ reason: `guard subprocess failed (status ${result.status}); falling through`,
70
+ suggest: null,
71
+ tier: 'ok',
72
+ ratio: 0,
73
+ bytesUsed: 0,
74
+ capacityBytes: 0,
75
+ warnings: ['HOOK_GUARD_SUBPROCESS_FAILED']
76
+ };
77
+ }
78
+ try {
79
+ return JSON.parse(result.stdout);
80
+ }
81
+ catch {
82
+ return {
83
+ schema: 'peaks-hook-guard/v1',
84
+ allow: true,
85
+ code: 'OK',
86
+ reason: 'guard subprocess produced unparseable JSON; falling through',
87
+ suggest: null,
88
+ tier: 'ok',
89
+ ratio: 0,
90
+ bytesUsed: 0,
91
+ capacityBytes: 0,
92
+ warnings: ['HOOK_GUARD_SUBPROCESS_INVALID_JSON']
93
+ };
94
+ }
95
+ }
96
+ /**
97
+ * Main entry point for the hook. Reads the LLM platform's stdin,
98
+ * computes the prompt size, and returns the guard result. Used by
99
+ * the LLM platform's hook JSON to decide whether to allow the tool
100
+ * call.
101
+ */
102
+ export function runHookGuard(stdin) {
103
+ const promptSize = readPromptSizeFromHookStdin(stdin);
104
+ return evaluateHookGuard(promptSize);
105
+ }
@@ -37,6 +37,18 @@ export type CheckPrerequisitesOptions = {
37
37
  * on-disk path now agree on the same top-level dir.
38
38
  */
39
39
  changeId: string;
40
+ /**
41
+ * Session binding (the developer's local session that wrote the
42
+ * request artifact). Read from the file body's `- session:` line.
43
+ * Optional, but when present the gate falls back to
44
+ * `.peaks/_runtime/<sid>/<role>/` and then `.peaks/<sid>/<role>/`
45
+ * for prerequisite artifacts that don't exist at the per-change-id
46
+ * path. This mirrors the F1/F2 back-compat pattern (read new path
47
+ * first, then legacy) and keeps the gate working for users whose
48
+ * QA / tech-doc / initiated artifacts still live under the session
49
+ * dir rather than under the change-id dir.
50
+ */
51
+ sessionId?: string;
40
52
  role: RequestArtifactRole;
41
53
  newState: RequestArtifactState;
42
54
  requestId: string;
@@ -162,17 +162,25 @@ export async function checkPrerequisites(options) {
162
162
  if (requirements.length === 0) {
163
163
  return { ok: true, missing: [] };
164
164
  }
165
- // As of slice 2026-06-05-change-id-as-unit-of-work, the prerequisite
166
- // gate resolves paths under `.peaks/<changeId>/<role>/...` where the
167
- // changeId is the file's durable scope (the top-level dir the file
168
- // lives in), NOT the body's `- session:` line. The body and the path
169
- // can now disagree (e.g. a request written in one session but read
170
- // across sessions), and the gate follows the on-disk location.
171
- const changeRoot = join(options.projectRoot, '.peaks', options.changeId);
165
+ // Slice 006 simplifies the resolution to a 2-tier fallback. The
166
+ // per-change-id scope (`.peaks/<changeId>/<role>/`) is gone — new
167
+ // artifacts go to the session dir directly. The 2 tiers are:
168
+ // 1. `.peaks/_runtime/<sid>/<role>/...` (post-F3 canonical
169
+ // session home; primary).
170
+ // 2. `.peaks/<sid>/<role>/...` (pre-F3 legacy session home;
171
+ // back-compat).
172
+ // The changeId is preserved in the artifact body's frontmatter for
173
+ // human navigation; it is no longer a filesystem path key.
174
+ const canonicalSessionRoot = options.sessionId !== undefined
175
+ ? join(options.projectRoot, '.peaks', '_runtime', options.sessionId)
176
+ : null;
177
+ const legacySessionRoot = options.sessionId !== undefined
178
+ ? join(options.projectRoot, '.peaks', options.sessionId)
179
+ : null;
172
180
  const missing = [];
173
181
  for (const prerequisite of requirements) {
174
182
  const relative = resolvePrerequisitePath(prerequisite, options.requestId);
175
- const absolute = await resolvePrerequisiteAbsolutePath(changeRoot, prerequisite, options.requestId);
183
+ const absolute = await resolvePrerequisiteAbsolutePathWithFallback(canonicalSessionRoot, legacySessionRoot, prerequisite, options.requestId);
176
184
  if (absolute === null) {
177
185
  missing.push({ path: relative, description: prerequisite.description });
178
186
  continue;
@@ -202,3 +210,26 @@ export async function checkPrerequisites(options) {
202
210
  }
203
211
  return { ok: missing.length === 0, missing };
204
212
  }
213
+ /**
214
+ * Resolve a prerequisite to an on-disk path, with a 2-tier fallback
215
+ * (slice 006 — the per-change-id tier was dropped because per-change-id
216
+ * dirs are no longer created):
217
+ * 1. `<canonicalSessionRoot>/<relative>` (post-F3 canonical session
218
+ * home, when `canonicalSessionRoot` is provided).
219
+ * 2. `<legacySessionRoot>/<relative>` (pre-F3 legacy session home,
220
+ * when `legacySessionRoot` is provided).
221
+ * Tolerates the numbered filename prefix that `request init` writes
222
+ * (e.g. `001-<rid>.md`) at every tier. Returns the matched absolute
223
+ * path, or null when nothing matches.
224
+ */
225
+ async function resolvePrerequisiteAbsolutePathWithFallback(canonicalSessionRoot, legacySessionRoot, prerequisite, requestId) {
226
+ const roots = [canonicalSessionRoot, legacySessionRoot];
227
+ for (const root of roots) {
228
+ if (root === null)
229
+ continue;
230
+ const found = await resolvePrerequisiteAbsolutePath(root, prerequisite, requestId);
231
+ if (found !== null)
232
+ return found;
233
+ }
234
+ return null;
235
+ }