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
@@ -3,8 +3,8 @@ import { existsSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { isDirectory, listDirectories } from '../../shared/fs.js';
5
5
  import { checkPrerequisites, DEFAULT_REQUEST_TYPE, isRequestType, VALID_REQUEST_TYPES } from './artifact-prerequisites.js';
6
- import { ensureSession } from '../session/session-manager.js';
7
- import { getCurrentChangeId, getChangeArtifactRoot } from '../../shared/change-id.js';
6
+ import { ensureSession, getSessionIdCanonical } from '../session/session-manager.js';
7
+ import { getCurrentChangeId } from '../../shared/change-id.js';
8
8
  import { getNextNumber, buildNumberedFilename } from '../../shared/incrementing-number.js';
9
9
  import { lintRequestArtifact } from './artifact-lint-service.js';
10
10
  import { checkTypeSanity } from '../scan/type-sanity-service.js';
@@ -313,25 +313,48 @@ export async function createRequestArtifact(options) {
313
313
  const clock = options.clock ?? defaultClock;
314
314
  const timestamp = clock();
315
315
  // Use provided session ID or get/create current session. The session
316
- // id is kept as the in-memory binding (so the artifact body can record
317
- // which session wrote it), but the artifact file is now written
318
- // under the change-id dir, NOT the session dir.
316
+ // id is the binding for the artifact file's location.
319
317
  //
320
- // The change-id is the file's durable scope. As of slice
321
- // 2026-06-05-change-id-as-unit-of-work, the requestId IS the
322
- // change-id (per the legacy `001-<change-id>.md` filename convention);
323
- // a request that lives in a session dir under a different change-id
324
- // is no longer the model. We honor a `current-change` binding if
325
- // one is set, and otherwise fall back to the requestId itself.
318
+ // Slice 006 collapses the per-change-id top-level dirs. The artifact
319
+ // file is now written under the SESSION dir
320
+ // (`.peaks/_runtime/<sid>/<role>/requests/`) instead of the
321
+ // change-id dir. The 2-tier fallback (canonical session legacy
322
+ // session) replaces the F3 3-tier fallback (per-change-id
323
+ // canonical session legacy session). The change-id is preserved
324
+ // in the artifact body's frontmatter (under `- change-id:`) for
325
+ // human navigation; it is no longer a filesystem path key.
326
326
  const sessionId = options.sessionId ?? await ensureSession(options.projectRoot);
327
327
  const boundChangeId = getCurrentChangeId(options.projectRoot);
328
- // Resolution order for the change-id (file path key):
329
- // 1. Explicit `options.changeId` (CLI `--session-id` pre-1.3.0 set this).
328
+ // Resolution order for the change-id (file body metadata):
329
+ // 1. Explicit `options.changeId` (CLI `--change-id`).
330
330
  // 2. `current-change` binding (live developer working context).
331
331
  // 3. The requestId itself (every request is its own scope by default).
332
332
  const changeId = options.changeId ?? boundChangeId ?? options.requestId;
333
- // Build numbered path under the change-id dir
334
- const requestsDir = getChangeArtifactRoot(options.projectRoot, changeId) + '/' + options.role + '/requests';
333
+ // Slice 008 (F21 fix): fail fast when the resolved session id
334
+ // looks like a real session id (matches the date+session prefix)
335
+ // but does NOT correspond to an actual session dir under
336
+ // `.peaks/_runtime/`. Pre-F21 a sub-agent with a typo or stale
337
+ // binding (e.g. `2025-01-01-session-deadbe`) silently planned
338
+ // to write to a non-existent path. The check is intentionally
339
+ // scoped to "looks like a real session id" — a sid like
340
+ // `test-session` or `s` (no date prefix) is allowed through so
341
+ // the existing F3 / slice-007 back-compat flows (e.g. the
342
+ // `peaks request init --session-id <arbitrary-scope>` tests)
343
+ // can still create the dir on demand via the writer's
344
+ // `mkdir(..., { recursive: true })`.
345
+ const LOOKS_LIKE_SESSION_ID = /^\d{4}-\d{2}-\d{2}-session-/;
346
+ if (LOOKS_LIKE_SESSION_ID.test(sessionId)) {
347
+ const sessionDir = join(options.projectRoot, '.peaks', '_runtime', sessionId);
348
+ if (!(await isDirectory(sessionDir))) {
349
+ const canonicalSid = getSessionIdCanonical(options.projectRoot);
350
+ const hint = canonicalSid !== null
351
+ ? `Use --session-id ${canonicalSid} or run 'peaks workspace init' to create a new session.`
352
+ : `Run 'peaks workspace init' to create a new session.`;
353
+ throw new Error(`session id '${sessionId}' does not exist in _runtime/. Current canonical binding is '${canonicalSid ?? '<none>'}'. ${hint}`);
354
+ }
355
+ }
356
+ // Build numbered path under the session dir (canonical post-F3 home).
357
+ const requestsDir = join(options.projectRoot, '.peaks', '_runtime', sessionId, options.role, 'requests');
335
358
  // Check if a file with this requestId already exists (regardless of number prefix)
336
359
  if (await isDirectory(requestsDir)) {
337
360
  const existingFiles = await listMarkdownFiles(requestsDir);
@@ -356,11 +379,11 @@ export async function createRequestArtifact(options) {
356
379
  await mkdir(dirname(path), { recursive: true });
357
380
  await writeFile(path, content, 'utf8');
358
381
  // Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked.
359
- // As of slice 2026-06-05-change-id-as-unit-of-work, the marker lives under
360
- // the change-id dir (not the session dir), so the gate's prereq scan
361
- // (which reads from `.peaks/<change-id>/<role>/...`) finds it.
382
+ // Slice 006: the marker lives under the SESSION dir (canonical post-F3
383
+ // home), not the change-id dir. The gate's prereq scan finds it at
384
+ // `.peaks/_runtime/<sid>/qa/.initiated`.
362
385
  if (options.role === 'qa') {
363
- const qaDir = getChangeArtifactRoot(options.projectRoot, changeId) + '/qa';
386
+ const qaDir = join(options.projectRoot, '.peaks', '_runtime', sessionId, 'qa');
364
387
  const initiatedPath = join(qaDir, '.initiated');
365
388
  if (!existsSync(initiatedPath)) {
366
389
  await mkdir(qaDir, { recursive: true });
@@ -448,45 +471,45 @@ export async function listRequestArtifacts(options) {
448
471
  if (!(await isDirectory(peaksRoot))) {
449
472
  return [];
450
473
  }
451
- // As of slice 2026-06-05-change-id-as-unit-of-work, artifact files live
452
- // in `.peaks/<change-id>/<role>/requests/`. The top-level `.peaks/<dir>/`
453
- // entries we scan here are change-id dirs (new layout) AND legacy
454
- // session-id dirs (pre-1.3.0 layout). Both have the same
455
- // `<role>/requests/<file>.md` shape, so we read them uniformly.
474
+ // Slice 006 collapsed the per-change-id top-level dirs. The 2-tier
475
+ // resolution model is:
476
+ // 1. `.peaks/_runtime/<sid>/<role>/requests/` (post-F3 canonical
477
+ // session home; slice 006's primary home for request artifacts).
478
+ // 2. `.peaks/<sid>/<role>/requests/` (pre-F3 legacy home; back-compat
479
+ // for users who have not yet migrated).
456
480
  //
457
- // Additionally, shipped slices are archived under
458
- // `.peaks/retrospective/<change-id>/<role>/requests/` and dogfood
459
- // evidence lives under `.peaks/_dogfood/<change-id>/<role>/requests/`.
460
- // When `sessionId` is NOT pinned, we scan ALL three umbrella dirs
461
- // (`<top>`, `retrospective/`, `_dogfood/`) so a `peaks request show
462
- // <rid>` resolves shipped slices too which is what the slice check's
463
- // gate-verify-pipeline stage needs to find evidence for the retrospective
464
- // slice being verified.
465
- //
466
- // Skip well-known non-artifact dirs: `_runtime/` holds ephemeral state
467
- // (no `requests/` subdirs anyway, but skip explicitly to avoid noise).
468
- const allDirs = await listDirectories(peaksRoot);
469
- const candidateDirs = allDirs.filter((dir) => dir !== '_runtime');
470
- // Expand scopes to include the nested umbrellas that host change-id dirs
471
- // (retrospective/, _dogfood/). For each, list its sub-dirs and treat
472
- // them as additional scopes. This makes the lookup span the entire
473
- // .peaks tree.
474
- const expandedScopes = [];
481
+ // When `sessionId` is pinned, the function scans that one session's
482
+ // two tiers (canonical + legacy). When `sessionId` is NOT pinned,
483
+ // the function scans every session dir under `.peaks/_runtime/`
484
+ // (canonical) AND every legacy session dir under `.peaks/`
485
+ // (top-level). Per-change-id dirs (the old `.peaks/<changeId>/<role>/`
486
+ // layout) are NOT scannedslice 008 will migrate the 5
487
+ // already-shipped slices' artifacts to the new layout; new request
488
+ // artifacts are written to the session dir directly.
489
+ const scopes = [];
475
490
  if (options.sessionId !== undefined) {
476
- expandedScopes.push(options.sessionId);
491
+ scopes.push(join('_runtime', options.sessionId));
492
+ scopes.push(options.sessionId);
477
493
  }
478
494
  else {
479
- for (const dir of candidateDirs) {
480
- expandedScopes.push(dir);
481
- if (dir === 'retrospective' || dir === '_dogfood') {
482
- const nested = await listDirectories(join(peaksRoot, dir));
483
- for (const n of nested) {
484
- expandedScopes.push(join(dir, n));
485
- }
495
+ const runtimeRoot = join(peaksRoot, '_runtime');
496
+ if (await isDirectory(runtimeRoot)) {
497
+ for (const sid of await listDirectories(runtimeRoot)) {
498
+ scopes.push(join('_runtime', sid));
486
499
  }
487
500
  }
501
+ // Legacy top-level session dirs: scan every non-`._peaks` top-level
502
+ // dir as a potential legacy scope. Slice 006 dropped per-change-id
503
+ // dirs, so any top-level dir name under `.peaks/` that is NOT
504
+ // `_runtime` (and not a well-known umbrella like retrospective,
505
+ // _dogfood, memory, etc. — those have no `<role>/requests/` tree)
506
+ // is treated as a candidate legacy session dir.
507
+ for (const dir of await listDirectories(peaksRoot)) {
508
+ if (dir === '_runtime')
509
+ continue;
510
+ scopes.push(dir);
511
+ }
488
512
  }
489
- const scopes = expandedScopes;
490
513
  const roles = options.role !== undefined ? [options.role] : Array.from(VALID_ROLES);
491
514
  const summaries = [];
492
515
  for (const scope of scopes) {
@@ -525,45 +548,56 @@ export async function showRequestArtifact(options) {
525
548
  // As of slice 2026-06-05-change-id-as-unit-of-work, the dir key is the
526
549
  // change-id (not the session-id). When the caller pins `sessionId` we
527
550
  // use it as the scope anyway (legacy callers, and tests that pass
528
- // `STABLE_SESSION` as a stand-in). The directory layout is identical
529
- // for both old session dirs and new change-id dirs, so a single
530
- // read path works for both.
551
+ // `STABLE_SESSION` as a stand-in).
552
+ //
553
+ // As of slice 2026-06-06-session-layout-canonicalize (F3), the
554
+ // canonical home for session dirs is `.peaks/_runtime/<sid>/`.
555
+ // The pre-F3 layout `.peaks/<sid>/` is preserved as a one-minor
556
+ // back-compat fallback (the new path wins when both exist). We
557
+ // resolve the dir to use UP FRONT (not lazily after a miss) so the
558
+ // prerequisite gate's "request artifact present" check observes
559
+ // the same path the rest of the canonical layout uses.
531
560
  if (options.sessionId !== undefined) {
532
- const dir = join(options.projectRoot, '.peaks', options.sessionId, options.role, 'requests');
561
+ const canonicalDir = join(options.projectRoot, '.peaks', '_runtime', options.sessionId, options.role, 'requests');
562
+ const legacyDir = join(options.projectRoot, '.peaks', options.sessionId, options.role, 'requests');
563
+ // Try the canonical (post-F3) path first; fall back to the legacy
564
+ // path only if the canonical path is absent. The legacy path is
565
+ // expected to be empty after a `peaks workspace migrate --to-runtime`
566
+ // run; this fallback exists for users who have not yet migrated.
567
+ const dir = (await isDirectory(canonicalDir)) ? canonicalDir : legacyDir;
568
+ const scope = dir === canonicalDir
569
+ ? join('_runtime', options.sessionId)
570
+ : options.sessionId;
533
571
  const found = await findFileInDir(dir);
534
572
  if (found === null) {
535
573
  return null;
536
574
  }
537
- return await readRequestArtifact(options.projectRoot, options.sessionId, options.role, found);
575
+ return await readRequestArtifact(options.projectRoot, scope, options.role, found);
538
576
  }
539
577
  const peaksRoot = join(options.projectRoot, '.peaks');
540
578
  if (!(await isDirectory(peaksRoot))) {
541
579
  return null;
542
580
  }
543
- // Scan all top-level dirs in `.peaks/` AND nested change-id dirs
544
- // under `retrospective/` and `_dogfood/`. The expanded scope list
545
- // lets us find request artifacts that live one or two levels deep
546
- // (shipped slices, dogfood evidence). Without this expansion,
547
- // verify-pipeline can't find the RD/QA request files for any
548
- // retrospective slice.
549
- const allDirs = await listDirectories(peaksRoot);
550
- const scopes = [];
551
- for (const dir of allDirs) {
552
- if (dir === '_runtime')
553
- continue;
554
- scopes.push(dir);
555
- if (dir === 'retrospective' || dir === '_dogfood') {
556
- const nested = await listDirectories(join(peaksRoot, dir));
557
- for (const n of nested) {
558
- scopes.push(join(dir, n));
581
+ // Slice 006: scan only session-scoped dirs (canonical + legacy)
582
+ // for the artifact. The per-change-id top-level dirs are no longer
583
+ // scanned they are frozen until slice 008 migrates them.
584
+ const runtimeRoot = join(peaksRoot, '_runtime');
585
+ if (await isDirectory(runtimeRoot)) {
586
+ for (const sid of await listDirectories(runtimeRoot)) {
587
+ const dir = join(runtimeRoot, sid, options.role, 'requests');
588
+ const found = await findFileInDir(dir);
589
+ if (found !== null) {
590
+ return await readRequestArtifact(options.projectRoot, join('_runtime', sid), options.role, found);
559
591
  }
560
592
  }
561
593
  }
562
- for (const scope of scopes) {
563
- const dir = join(peaksRoot, scope, options.role, 'requests');
564
- const found = await findFileInDir(dir);
594
+ for (const dir of await listDirectories(peaksRoot)) {
595
+ if (dir === '_runtime')
596
+ continue;
597
+ const target = join(peaksRoot, dir, options.role, 'requests');
598
+ const found = await findFileInDir(target);
565
599
  if (found !== null) {
566
- return await readRequestArtifact(options.projectRoot, scope, options.role, found);
600
+ return await readRequestArtifact(options.projectRoot, dir, options.role, found);
567
601
  }
568
602
  }
569
603
  return null;
@@ -719,6 +753,12 @@ export async function transitionRequestArtifact(options) {
719
753
  const prerequisiteResult = await checkPrerequisites({
720
754
  projectRoot: options.projectRoot,
721
755
  changeId: existing.changeId,
756
+ // F3 repair cycle 1: pass the session binding so the gate can fall
757
+ // back to `.peaks/_runtime/<sid>/<role>/` (and the legacy
758
+ // `.peaks/<sid>/<role>/`) for prerequisite artifacts that still
759
+ // live under the session dir rather than the change-id dir. This
760
+ // mirrors the F1/F2 back-compat pattern.
761
+ sessionId: existing.sessionId,
722
762
  role: options.role,
723
763
  newState: options.newState,
724
764
  requestId: options.requestId,
@@ -61,7 +61,7 @@ export type PeaksConfig = {
61
61
  /**
62
62
  * Sub-agent progress surfacing knobs. The `peaks progress watch`
63
63
  * CLI (intended to be run in a separate terminal tab while the
64
- * LLM is working) reads `.peaks/<sid>/system/subagent-progress.json`
64
+ * LLM is working) reads `.peaks/_sub_agents/<sid>/subagent-progress.json`
65
65
  * and renders elapsed / spinner / sub-step in real time. The
66
66
  * `enabled` flag is a kill-switch for users who find the watch
67
67
  * distracting; the `heartbeatIntervalMs` lets power users tune
@@ -0,0 +1,72 @@
1
+ export type ArtifactStatus = 'created' | 'finalized' | 'partial' | 'failed';
2
+ export interface ArtifactMeta {
3
+ readonly path: string;
4
+ readonly size: number;
5
+ readonly sha256: string;
6
+ readonly status: ArtifactStatus;
7
+ /** Mandatory literal `false`. The type system rejects `true`. */
8
+ readonly contentInlined: false;
9
+ /** 1-2 sentence description, ≤ 200 chars. Allowed in main context. */
10
+ readonly summary: string | null;
11
+ /** ISO8601. */
12
+ readonly writtenAt: string;
13
+ /** Request id this artifact belongs to. */
14
+ readonly rid: string;
15
+ /** Sub-agent role string. */
16
+ readonly role: string;
17
+ /** Sequence number when same role is dispatched multiple times. */
18
+ readonly idx: number;
19
+ }
20
+ export interface ContextImpact {
21
+ readonly promptSize: number;
22
+ readonly artifactSizes: readonly number[];
23
+ readonly batchTotalSize: number;
24
+ /** `high` if `batchTotalSize > 4MB` OR any `artifactSize > 1MB`. */
25
+ readonly contextWarning: 'normal' | 'high' | 'critical';
26
+ }
27
+ /**
28
+ * Compute the sha256 hex digest of a file. Throws if the file does not
29
+ * exist or is not readable. Caller is expected to handle ENOENT as
30
+ * `code: 'ARTIFACT_NOT_FOUND'` and treat 0-byte files as `status: 'failed'`.
31
+ */
32
+ export declare function computeSha256(filePath: string): string;
33
+ /**
34
+ * Build an `ArtifactMeta` from on-disk file. Computes size + sha256.
35
+ *
36
+ * `status` semantics:
37
+ * - `'created'` — file exists, non-empty, sha256 succeeded
38
+ * - `'failed'` — file is 0 bytes (R-2 / G7.4.e: do not silently succeed)
39
+ * - `'partial'` — caller-provided (e.g. sub-agent reports unfinished work)
40
+ * - `'finalized'` — caller-provided (e.g. sub-agent reports complete)
41
+ */
42
+ export declare function buildArtifactMeta(opts: {
43
+ path: string;
44
+ rid: string;
45
+ role: string;
46
+ idx: number;
47
+ summary: string | null;
48
+ status?: ArtifactStatus;
49
+ /** Override sha256 / size (e.g. when caller already computed them). */
50
+ precomputed?: {
51
+ size: number;
52
+ sha256: string;
53
+ };
54
+ /** Override the writtenAt timestamp. */
55
+ writtenAt?: string;
56
+ }): ArtifactMeta;
57
+ /**
58
+ * Build a `ContextImpact` from a prompt size + artifact sizes.
59
+ * Computes `contextWarning` per the G7.3 rule:
60
+ * - `'critical'` if any artifact > ARTIFACT_MAX_SIZE_BYTES
61
+ * - `'high'` if total > BATCH_TOTAL_HIGH_BYTES (4MB)
62
+ * - `'normal'` otherwise
63
+ */
64
+ export declare function buildContextImpact(opts: {
65
+ promptSize: number;
66
+ artifactSizes: readonly number[];
67
+ }): ContextImpact;
68
+ export declare const ARTIFACT_LIMITS: {
69
+ readonly ARTIFACT_MAX_SIZE_BYTES: number;
70
+ readonly BATCH_TOTAL_HIGH_BYTES: number;
71
+ readonly ARTIFACT_SUMMARY_MAX_CHARS: 200;
72
+ };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * G7 — sub-agent context minimal-occupation (RL-17..RL-22, AC-38..AC-43).
3
+ *
4
+ * `ArtifactMeta` is what the dispatch record stores per sub-agent artifact
5
+ * instead of the full content. The `contentInlined: false` literal is the
6
+ * API contract: the type system rejects `true`, so main LLM context can
7
+ * never accidentally be flooded with inlined artifact bodies.
8
+ *
9
+ * The artifact's content lives on disk at `path`; the meta is ~200 chars
10
+ * (path + size + sha256 + status + summary), so 3 sub-agents × ~200 chars
11
+ * = ~600 chars net context increase per batch instead of 3MB+.
12
+ *
13
+ * Path convention (G7.4.c):
14
+ * `.peaks/_sub_agents/<sid>/artifacts/<rid>-<role>-<idx>.<ext>`
15
+ *
16
+ * See: `.peaks/memory/sub-agent-context-minimal-occupation.md` for the
17
+ * full G7 rule.
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { readFileSync, statSync } from 'node:fs';
21
+ const ARTIFACT_MAX_SIZE_BYTES = 1024 * 1024; // 1MB
22
+ const BATCH_TOTAL_HIGH_BYTES = 4 * 1024 * 1024; // 4MB
23
+ /**
24
+ * Compute the sha256 hex digest of a file. Throws if the file does not
25
+ * exist or is not readable. Caller is expected to handle ENOENT as
26
+ * `code: 'ARTIFACT_NOT_FOUND'` and treat 0-byte files as `status: 'failed'`.
27
+ */
28
+ export function computeSha256(filePath) {
29
+ const content = readFileSync(filePath);
30
+ return createHash('sha256').update(content).digest('hex');
31
+ }
32
+ /**
33
+ * Build an `ArtifactMeta` from on-disk file. Computes size + sha256.
34
+ *
35
+ * `status` semantics:
36
+ * - `'created'` — file exists, non-empty, sha256 succeeded
37
+ * - `'failed'` — file is 0 bytes (R-2 / G7.4.e: do not silently succeed)
38
+ * - `'partial'` — caller-provided (e.g. sub-agent reports unfinished work)
39
+ * - `'finalized'` — caller-provided (e.g. sub-agent reports complete)
40
+ */
41
+ export function buildArtifactMeta(opts) {
42
+ let size;
43
+ let sha256;
44
+ let status = opts.status ?? 'created';
45
+ if (opts.precomputed) {
46
+ size = opts.precomputed.size;
47
+ sha256 = opts.precomputed.sha256;
48
+ }
49
+ else {
50
+ const stat = statSync(opts.path);
51
+ size = stat.size;
52
+ if (size === 0) {
53
+ // 0-byte artifact: cannot compute meaningful sha256; mark as failed.
54
+ sha256 = '0'.repeat(64);
55
+ status = 'failed';
56
+ }
57
+ else {
58
+ sha256 = computeSha256(opts.path);
59
+ }
60
+ }
61
+ const summary = opts.summary;
62
+ if (summary !== null && summary.length > 200) {
63
+ throw new Error(`ArtifactMeta summary must be ≤ 200 chars (got ${summary.length})`);
64
+ }
65
+ return {
66
+ path: opts.path,
67
+ size,
68
+ sha256,
69
+ status,
70
+ contentInlined: false,
71
+ summary,
72
+ writtenAt: opts.writtenAt ?? new Date().toISOString(),
73
+ rid: opts.rid,
74
+ role: opts.role,
75
+ idx: opts.idx
76
+ };
77
+ }
78
+ /**
79
+ * Build a `ContextImpact` from a prompt size + artifact sizes.
80
+ * Computes `contextWarning` per the G7.3 rule:
81
+ * - `'critical'` if any artifact > ARTIFACT_MAX_SIZE_BYTES
82
+ * - `'high'` if total > BATCH_TOTAL_HIGH_BYTES (4MB)
83
+ * - `'normal'` otherwise
84
+ */
85
+ export function buildContextImpact(opts) {
86
+ const batchTotalSize = opts.promptSize + opts.artifactSizes.reduce((a, b) => a + b, 0);
87
+ let contextWarning = 'normal';
88
+ if (opts.artifactSizes.some((s) => s > ARTIFACT_MAX_SIZE_BYTES)) {
89
+ contextWarning = 'critical';
90
+ }
91
+ else if (batchTotalSize > BATCH_TOTAL_HIGH_BYTES) {
92
+ contextWarning = 'high';
93
+ }
94
+ return {
95
+ promptSize: opts.promptSize,
96
+ artifactSizes: opts.artifactSizes,
97
+ batchTotalSize,
98
+ contextWarning
99
+ };
100
+ }
101
+ export const ARTIFACT_LIMITS = {
102
+ ARTIFACT_MAX_SIZE_BYTES,
103
+ BATCH_TOTAL_HIGH_BYTES,
104
+ ARTIFACT_SUMMARY_MAX_CHARS: 200
105
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * G9 — forced compression gate (RL-27..RL-32).
3
+ *
4
+ * The CLI 兜底 layer. Validates prompt size against the threshold
5
+ * table in `threshold.ts` and returns a decision. The PreToolUse hook
6
+ * layer (`peaks sub-agent-dispatch-guard`) re-runs the same logic
7
+ * without `--force` (RL-30 strict).
8
+ *
9
+ * Decision codes:
10
+ * - `OK` — under 50%
11
+ * - `CONTEXT_SOFT_WARN` — 50-75%, suggest --use-headroom
12
+ * - `CONTEXT_NEAR_LIMIT` — 75-80%, mandatory --use-headroom suggestion
13
+ * - `PROMPT_TOO_LARGE` — 80-90%, hard reject (allow = false)
14
+ * - `PROMPT_EMERGENCY` — ≥ 90%, hard reject + emergency
15
+ * - `FORCED_OVER_THRESHOLD` — user passed --force at CLI; allow = true
16
+ *
17
+ * See: `.peaks/memory/sub-agent-headroom-forced-compression-gate.md`.
18
+ */
19
+ import { tierToCode, type ThresholdEvaluation } from './threshold.js';
20
+ export type ContextGuardCode = 'OK' | 'CONTEXT_SOFT_WARN' | 'CONTEXT_NEAR_LIMIT' | 'PROMPT_TOO_LARGE' | 'PROMPT_EMERGENCY' | 'FORCED_OVER_THRESHOLD';
21
+ export interface ContextGuardDecision {
22
+ readonly allow: boolean;
23
+ readonly code: ContextGuardCode;
24
+ readonly warnings: readonly string[];
25
+ readonly suggest: string | null;
26
+ readonly evaluation: ThresholdEvaluation;
27
+ /** ISO8601 timestamp when --force override was applied. null otherwise. */
28
+ readonly forcedAt: string | null;
29
+ }
30
+ export interface ContextGuardOptions {
31
+ /** Pass `true` to allow override at the ≥ 80% tier. CLI-only; hook layer MUST NOT set this. */
32
+ readonly force?: boolean;
33
+ /** Override the default 256K context capacity (e.g. for tests). */
34
+ readonly capacityBytes?: number;
35
+ }
36
+ /**
37
+ * Evaluate a prompt size against the G9.3 threshold table.
38
+ *
39
+ * The `force` option is the **only** path that lets a ≥ 80% prompt
40
+ * through. The hook layer (PreToolUse) MUST NOT accept this option;
41
+ * it is enforced by the `peaks sub-agent-dispatch-guard` atom's
42
+ * command-line parser, which does not declare a `--force` flag.
43
+ */
44
+ export declare function evaluatePromptSize(promptSize: number, opts?: ContextGuardOptions): ContextGuardDecision;
45
+ /**
46
+ * Re-export of `tierToCode` for callers that want a stable mapping
47
+ * without depending on the threshold module directly.
48
+ */
49
+ export { tierToCode };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * G9 — forced compression gate (RL-27..RL-32).
3
+ *
4
+ * The CLI 兜底 layer. Validates prompt size against the threshold
5
+ * table in `threshold.ts` and returns a decision. The PreToolUse hook
6
+ * layer (`peaks sub-agent-dispatch-guard`) re-runs the same logic
7
+ * without `--force` (RL-30 strict).
8
+ *
9
+ * Decision codes:
10
+ * - `OK` — under 50%
11
+ * - `CONTEXT_SOFT_WARN` — 50-75%, suggest --use-headroom
12
+ * - `CONTEXT_NEAR_LIMIT` — 75-80%, mandatory --use-headroom suggestion
13
+ * - `PROMPT_TOO_LARGE` — 80-90%, hard reject (allow = false)
14
+ * - `PROMPT_EMERGENCY` — ≥ 90%, hard reject + emergency
15
+ * - `FORCED_OVER_THRESHOLD` — user passed --force at CLI; allow = true
16
+ *
17
+ * See: `.peaks/memory/sub-agent-headroom-forced-compression-gate.md`.
18
+ */
19
+ import { CONTEXT_CAPACITY_DEFAULT_BYTES, evaluateThresholdTier, tierToCode } from './threshold.js';
20
+ const NEAR_LIMIT_SUGGEST = 'Consider --use-headroom to compress prompt.';
21
+ const SOFT_WARN_SUGGEST = 'Use --use-headroom to compress prompt proactively.';
22
+ const HARD_REJECT_SUGGEST = 'Trim prompt to < 80% of context capacity. Pass --force at CLI to override (NOT allowed at hook layer).';
23
+ const EMERGENCY_SUGGEST = 'Prompt exceeds 90% of context. Trim aggressively or split into multiple dispatches.';
24
+ /**
25
+ * Evaluate a prompt size against the G9.3 threshold table.
26
+ *
27
+ * The `force` option is the **only** path that lets a ≥ 80% prompt
28
+ * through. The hook layer (PreToolUse) MUST NOT accept this option;
29
+ * it is enforced by the `peaks sub-agent-dispatch-guard` atom's
30
+ * command-line parser, which does not declare a `--force` flag.
31
+ */
32
+ export function evaluatePromptSize(promptSize, opts = {}) {
33
+ const capacity = opts.capacityBytes ?? CONTEXT_CAPACITY_DEFAULT_BYTES;
34
+ const evaluation = evaluateThresholdTier(promptSize, capacity);
35
+ const tier = evaluation.tier;
36
+ let allow;
37
+ let code;
38
+ let suggest;
39
+ let forcedAt = null;
40
+ switch (tier) {
41
+ case 'ok':
42
+ allow = true;
43
+ code = 'OK';
44
+ suggest = null;
45
+ break;
46
+ case 'soft-warn':
47
+ allow = true;
48
+ code = 'CONTEXT_SOFT_WARN';
49
+ suggest = SOFT_WARN_SUGGEST;
50
+ break;
51
+ case 'near-limit':
52
+ allow = true;
53
+ code = 'CONTEXT_NEAR_LIMIT';
54
+ suggest = NEAR_LIMIT_SUGGEST;
55
+ break;
56
+ case 'hard-reject':
57
+ allow = false;
58
+ code = 'PROMPT_TOO_LARGE';
59
+ suggest = HARD_REJECT_SUGGEST;
60
+ break;
61
+ case 'emergency':
62
+ allow = false;
63
+ code = 'PROMPT_EMERGENCY';
64
+ suggest = EMERGENCY_SUGGEST;
65
+ break;
66
+ }
67
+ // --force override (CLI only; hook layer never reaches this branch)
68
+ if (!allow && opts.force === true) {
69
+ allow = true;
70
+ code = 'FORCED_OVER_THRESHOLD';
71
+ suggest = 'Override applied at CLI. Hook layer will still reject.';
72
+ forcedAt = new Date().toISOString();
73
+ }
74
+ const warnings = [...evaluation.warnings];
75
+ if (forcedAt !== null && !warnings.includes('FORCED_OVER_THRESHOLD')) {
76
+ warnings.push('FORCED_OVER_THRESHOLD');
77
+ }
78
+ return {
79
+ allow,
80
+ code,
81
+ warnings,
82
+ suggest,
83
+ evaluation,
84
+ forcedAt
85
+ };
86
+ }
87
+ /**
88
+ * Re-export of `tierToCode` for callers that want a stable mapping
89
+ * without depending on the threshold module directly.
90
+ */
91
+ export { tierToCode };
@@ -0,0 +1,27 @@
1
+ /** Build the canonical artifact path for a given session/rid/role/idx/ext. */
2
+ export declare function artifactPath(projectRoot: string, sid: string, rid: string, role: string, idx: number, ext?: string): string;
3
+ /** Build the canonical shared channel path. */
4
+ export declare function sharedChannelPath(projectRoot: string, sid: string, rid: string, batchId: string): string;
5
+ /**
6
+ * Assert that `artifactPath` lives under
7
+ * `projectRoot/.peaks/_sub_agents/<sid>/artifacts/`. Rejects
8
+ * symlink/junction escapes and `..` segments.
9
+ *
10
+ * Throws an Error with `.code = 'INVALID_ARTIFACT_PATH'` on rejection.
11
+ */
12
+ export declare function assertSafeArtifactPath(artifactPathInput: string, projectRoot: string): string;
13
+ /**
14
+ * Assert that `channelPath` lives under
15
+ * `projectRoot/.peaks/_sub_agents/<sid>/shared/`. Same R-2 logic as
16
+ * `assertSafeArtifactPath` but with a different canonical subdir.
17
+ *
18
+ * Throws an Error with `.code = 'INVALID_SHARED_CHANNEL_PATH'` on rejection.
19
+ */
20
+ export declare function assertSafeSharedChannelPath(channelPathInput: string, projectRoot: string): string;
21
+ /**
22
+ * Soft-warn check on the artifact file name pattern. Returns null if
23
+ * the name matches `<rid>-<role>-<idx>.<ext>`, otherwise returns a
24
+ * warning string. Does NOT reject (the path is still in the canonical
25
+ * dir; the warning is for human/audit readability per G7.4.c).
26
+ */
27
+ export declare function checkArtifactNameConvention(artifactPathInput: string): string | null;