peaks-loop 4.0.16 → 4.0.17

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 (69) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README-en.md +12 -9
  3. package/README.md +15 -4
  4. package/config/eslint/.peaks-rules.cjs +39 -26
  5. package/dist/cli/commands/_register.js +2 -1
  6. package/dist/cli/commands/audit-commands.d.ts +1 -1
  7. package/dist/cli/commands/code-gate-command.d.ts +25 -0
  8. package/dist/cli/commands/code-gate-command.js +86 -0
  9. package/dist/cli/commands/code-mode-gate-commands.js +3 -4
  10. package/dist/cli/commands/code-orchestrator-can-do.js +2 -1
  11. package/dist/cli/commands/cron-commands.js +16 -4
  12. package/dist/cli/commands/dashboard-long-run.js +8 -4
  13. package/dist/cli/commands/dispatch-from-dag.d.ts +1 -1
  14. package/dist/cli/commands/hooks-commands.js +56 -3
  15. package/dist/cli/commands/qa-commands.js +1 -2
  16. package/dist/cli/commands/sediment-commands.d.ts +1 -1
  17. package/dist/cli/commands/slice-commands.js +20 -8
  18. package/dist/cli/commands/statusline-commands.d.ts +1 -0
  19. package/dist/cli/commands/statusline-commands.js +17 -3
  20. package/dist/cli/commands/tech-commands.d.ts +1 -1
  21. package/dist/services/artifacts/request-artifact-service.d.ts +2 -2
  22. package/dist/services/artifacts/request-artifact-service.js +10 -1
  23. package/dist/services/audit/enforcers/lint-reference-shape.js +18 -4
  24. package/dist/services/code/auto-compact-orchestrator.js +9 -1
  25. package/dist/services/code/batch-heartbeat-poller.js +26 -0
  26. package/dist/services/code/orchestrator-can-do.d.ts +24 -0
  27. package/dist/services/code/orchestrator-can-do.js +48 -2
  28. package/dist/services/code/post-compact-detector.d.ts +1 -1
  29. package/dist/services/compact-statusline/compact-statusline-service.js +19 -11
  30. package/dist/services/config/config-service.d.ts +1 -1
  31. package/dist/services/config/config-service.js +2 -4
  32. package/dist/services/container/container-lease.js +21 -8
  33. package/dist/services/context/spillover-store.d.ts +1 -1
  34. package/dist/services/crystallization/crystallization-types.js +20 -8
  35. package/dist/services/crystallization/evidence-brief-builder.js +22 -11
  36. package/dist/services/dispatch/dispatch-record-writer.js +231 -141
  37. package/dist/services/evolution/evolution-types.js +38 -21
  38. package/dist/services/feedback/feedback-promotion-service.js +9 -1
  39. package/dist/services/hooks/pre-tool-code-gate.d.ts +46 -0
  40. package/dist/services/hooks/pre-tool-code-gate.js +91 -0
  41. package/dist/services/ide/current-model-detector.js +1 -2
  42. package/dist/services/lint/detect-eslint.js +8 -5
  43. package/dist/services/lint/eslint-runner.d.ts +1 -2
  44. package/dist/services/lint/eslint-runner.js +86 -23
  45. package/dist/services/lint/npx-resolver.d.ts +6 -0
  46. package/dist/services/lint/npx-resolver.js +47 -0
  47. package/dist/services/loop/loop-release-types.js +32 -13
  48. package/dist/services/loop/spec-service.d.ts +7 -1
  49. package/dist/services/loop/spec-service.js +218 -182
  50. package/dist/services/rd/rd-service.js +22 -9
  51. package/dist/services/scan/archetype-service.js +21 -9
  52. package/dist/services/session/binding-store.js +1 -2
  53. package/dist/services/share/bundle-reader.d.ts +6 -9
  54. package/dist/services/share/bundle-reader.js +297 -223
  55. package/dist/services/skills/hooks-settings-service.d.ts +33 -0
  56. package/dist/services/skills/hooks-settings-service.js +27 -1
  57. package/dist/services/slice/calibration-store.js +21 -8
  58. package/dist/services/slice/slice-check-service.js +35 -14
  59. package/dist/services/slice/slice-decompose-service.js +459 -223
  60. package/dist/services/standards/project-context.js +135 -73
  61. package/dist/services/verdict/envelopes.d.ts +1 -2
  62. package/dist/services/verdict/envelopes.js +1 -2
  63. package/dist/services/verdict/verdict-aggregator.d.ts +1 -2
  64. package/dist/services/vm/vm-lease.js +19 -8
  65. package/dist/services/worktree/worktree-lease.d.ts +0 -10
  66. package/dist/services/worktree/worktree-lease.js +19 -8
  67. package/dist/shared/fs-utils.d.ts +1 -1
  68. package/package.json +7 -4
  69. package/skills/peaks-code/SKILL.md +46 -0
@@ -43,7 +43,16 @@ function listExpectedEntriesForIde(ide, _skipProgress = false) {
43
43
  if (ide === 'trae') {
44
44
  return [{ matcher: adapter.toolMatcher, sentinel: 'peaks hook handle' }];
45
45
  }
46
- return [{ matcher: adapter.toolMatcher, sentinel: 'peaks gate enforce' }];
46
+ // Slice 2026-08-06-codegate-vendor-neutral: Claude Code install
47
+ // also emits the Edit|Write|MultiEdit code-gate entry. The summary
48
+ // mirrors the install shape, NOT a hardcoded expected list. The
49
+ // hook source of truth is `src/services/hooks/pre-tool-code-gate.sh`
50
+ // (vendor-neutral); the runtime adapter is `peaks code-gate --json`
51
+ // registered as a PreToolUse entry.
52
+ return [
53
+ { matcher: adapter.toolMatcher, sentinel: 'peaks gate enforce' },
54
+ { matcher: 'Edit|Write|MultiEdit', sentinel: 'peaks code-gate' }
55
+ ];
47
56
  }
48
57
  /**
49
58
  * Slice 2026-07-24-peaks-code-bridge-002-rootcause (G6b / G10): copy the
@@ -98,6 +107,26 @@ function copyBridgeHookIfPresent(userHome) {
98
107
  copyFileSync(source, target);
99
108
  return { copied: true, source, target };
100
109
  }
110
+ /**
111
+ * Slice 2026-08-06-codegate-vendor-neutral: copy the code-gate hook
112
+ * source (`src/services/hooks/pre-tool-code-gate.sh`) into the user-
113
+ * global peaks-code hooks dir, mirroring the bridge-hook copy. The
114
+ * hook itself is vendor-neutral (no `claude` / `anthropic` strings in
115
+ * the script) — this installer is the vendor adapter that decides
116
+ * where the file lands. The CLI command `peaks code-gate` is the
117
+ * runtime entry; the shell script is the canonical artifact distributed
118
+ * alongside the bridge hook.
119
+ */
120
+ function copyCodeGateHookIfPresent(userHome) {
121
+ const source = resolve(__dirname, '..', '..', 'services', 'hooks', 'pre-tool-code-gate.sh');
122
+ const target = resolve(userHome, '.claude', 'skills', 'peaks-code', 'hooks', 'pre-tool-code-gate.sh');
123
+ if (!existsSync(source)) {
124
+ return { copied: false, source, target };
125
+ }
126
+ mkdirSync(dirname(target), { recursive: true });
127
+ copyFileSync(source, target);
128
+ return { copied: true, source, target };
129
+ }
101
130
  export function registerHooksCommands(program, io) {
102
131
  const hooks = program
103
132
  .command('hooks')
@@ -121,6 +150,14 @@ export function registerHooksCommands(program, io) {
121
150
  const bridgeCopy = scope === 'global'
122
151
  ? copyBridgeHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '')
123
152
  : { copied: false, source: '', target: '' };
153
+ // Slice 2026-08-06-codegate-vendor-neutral: also copy the
154
+ // code-gate hook script. The runtime gate lives at
155
+ // `peaks code-gate --json` (registered as a PreToolUse entry
156
+ // in the install output); the script is the build artifact
157
+ // distributed alongside the bridge hook.
158
+ const codeGateCopy = scope === 'global'
159
+ ? copyCodeGateHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '')
160
+ : { copied: false, source: '', target: '' };
124
161
  printResult(io, ok('hooks.install', {
125
162
  ...plan,
126
163
  ide,
@@ -129,6 +166,7 @@ export function registerHooksCommands(program, io) {
129
166
  skipProgress,
130
167
  entries: dryRunEntries,
131
168
  bridgeHookCopy: bridgeCopy,
169
+ codeGateHookCopy: codeGateCopy,
132
170
  // Slice 2026-07-29-worktree-layer3-deny: surface the
133
171
  // Layer 3 deny entries the install WOULD write. The
134
172
  // dry-run branch never actually invokes
@@ -141,7 +179,10 @@ export function registerHooksCommands(program, io) {
141
179
  `would write ${listSuperpowersDenyEntries().length} permissions.deny entries (Layer 3 worktree governance)`,
142
180
  bridgeCopy.copied
143
181
  ? `would copy bridge hook from ${bridgeCopy.source} to ${bridgeCopy.target}`
144
- : 'would not copy bridge hook (source missing or non-global scope)'
182
+ : 'would not copy bridge hook (source missing or non-global scope)',
183
+ codeGateCopy.copied
184
+ ? `would copy code-gate hook from ${codeGateCopy.source} to ${codeGateCopy.target}`
185
+ : 'would not copy code-gate hook (source missing or non-global scope)'
145
186
  ]), options.json);
146
187
  return;
147
188
  }
@@ -161,6 +202,14 @@ export function registerHooksCommands(program, io) {
161
202
  const bridgeCopy = scope === 'global'
162
203
  ? copyBridgeHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '')
163
204
  : { copied: false, source: '', target: '' };
205
+ // Slice 2026-08-06-codegate-vendor-neutral: also copy the
206
+ // code-gate hook script. The runtime gate lives at
207
+ // `peaks code-gate --json` (registered as a PreToolUse entry
208
+ // in the install output); the script is the build artifact
209
+ // distributed alongside the bridge hook.
210
+ const codeGateCopy = scope === 'global'
211
+ ? copyCodeGateHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '')
212
+ : { copied: false, source: '', target: '' };
164
213
  const nextActions = result.applied
165
214
  ? [
166
215
  'Restart the IDE (or reload the workspace) so the hook entries take effect',
@@ -170,7 +219,10 @@ export function registerHooksCommands(program, io) {
170
219
  `Layer 3 deny: wrote ${listSuperpowersDenyEntries().length} permissions.deny entries (worktree governance)`,
171
220
  bridgeCopy.copied
172
221
  ? `Copied bridge hook: ${bridgeCopy.target}`
173
- : 'Bridge hook not copied (source missing or non-global scope)'
222
+ : 'Bridge hook not copied (source missing or non-global scope)',
223
+ codeGateCopy.copied
224
+ ? `Copied code-gate hook: ${codeGateCopy.target}`
225
+ : 'Code-gate hook not copied (source missing or non-global scope)'
174
226
  ]
175
227
  : (bridgeCopy.copied
176
228
  ? [`Bridge hook copied: ${bridgeCopy.target}`]
@@ -189,6 +241,7 @@ export function registerHooksCommands(program, io) {
189
241
  skipProgress,
190
242
  entries: installedEntries.map((e) => ({ matcher: e.matcher, sentinel: e.sentinel })),
191
243
  bridgeHookCopy: bridgeCopy,
244
+ codeGateHookCopy: codeGateCopy,
192
245
  permissionsDenyApplied: result.applied,
193
246
  permissionsDenyEntries: denyEntries
194
247
  }, [], nextActions), options.json);
@@ -25,8 +25,7 @@
25
25
  * with reason `--no-browser`. This is a slice-level opt-out for
26
26
  * backend-only or already-covered-by-unit-tests work.
27
27
  */
28
- import { join } from 'node:path';
29
- import { resolve } from 'node:path';
28
+ import { join, resolve } from 'node:path';
30
29
  import { addJsonOption, printResult } from '../cli-helpers.js';
31
30
  import { fail, ok } from 'peaks-loop-shared/result';
32
31
  import { archiveScreenshots } from '../../services/qa/screenshot-archive-service.js';
@@ -1,5 +1,5 @@
1
1
  import type { Command } from "commander";
2
- import type { ProgramIO } from "../cli-helpers.js";
2
+ import { type ProgramIO } from "../cli-helpers.js";
3
3
  export interface CliResult {
4
4
  ok: boolean;
5
5
  error?: string;
@@ -9,6 +9,17 @@ import { decompose as multiPassDecompose } from '../../services/slice/multi-pass
9
9
  import { readResult as readDecompositionResult, writeResult as writeSchemaResult } from '../../services/slice/schema-router.js';
10
10
  import { fail, ok } from 'peaks-loop-shared/result';
11
11
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
12
+ /**
13
+ * PRD-002b slice 2 — extract CLI listing-pagination + table-format
14
+ * constants. Values are bytewise-identical to the original literals.
15
+ */
16
+ const SLICE_LIST_DEFAULT_LIMIT = 50;
17
+ const SLICE_LIST_MAX_LIMIT = 500;
18
+ const SLICE_LIST_RID_COL_WIDTH = 34;
19
+ const SLICE_LIST_MTIME_COL_WIDTH = 22;
20
+ const SLICE_LIST_SIZE_COL_WIDTH = 8;
21
+ const SLICE_LIST_PICKED_COL_WIDTH = 6;
22
+ const SLICE_LIST_SEPARATOR_WIDTH = 76;
12
23
  export function registerSliceCommands(program, io) {
13
24
  const slice = program.command('slice').description('Slice lifecycle: check (boundary), decompose (PRD -> 6-stage algorithm), ' +
14
25
  'pick (fzf multi-select), plan (apply via peaks request init). ' +
@@ -67,12 +78,12 @@ export function registerSliceCommands(program, io) {
67
78
  '(mtime > 30d). Use --stale-only to filter, --rid <substring> to narrow, ' +
68
79
  '--limit <n> to cap. To remove stale entries, use `peaks slice cleanup` (separate slice).')
69
80
  .option('--project <path>', 'target project root', '.')
70
- .option('--limit <n>', 'cap result count (default 50, max 500)', (v) => parseInt(v, 10), 50)
81
+ .option('--limit <n>', 'cap result count (default 50, max 500)', (v) => parseInt(v, 10), SLICE_LIST_DEFAULT_LIMIT)
71
82
  .option('--stale-only', 'only include rids older than the stale threshold', false)
72
83
  .option('--rid <substring>', 'case-insensitive substring filter on rid', '')).action((options) => {
73
84
  try {
74
85
  const projectRoot = resolveCanonicalProjectRoot(options.project);
75
- const limit = Math.max(1, Math.min(500, options.limit ?? 50));
86
+ const limit = Math.max(1, Math.min(SLICE_LIST_MAX_LIMIT, options.limit ?? SLICE_LIST_DEFAULT_LIMIT));
76
87
  const all = listDecompositions(projectRoot);
77
88
  let filtered = all;
78
89
  const ridSub = (options.rid ?? '').toLowerCase();
@@ -97,13 +108,13 @@ export function registerSliceCommands(program, io) {
97
108
  }
98
109
  // Plaintext mode: header + table
99
110
  const lines = [];
100
- lines.push('RID'.padEnd(34) + 'MTIME'.padEnd(22) + 'SIZE'.padEnd(8) + 'PICKED'.padEnd(6) + 'STALE');
101
- lines.push('-'.repeat(76));
111
+ lines.push('RID'.padEnd(SLICE_LIST_RID_COL_WIDTH) + 'MTIME'.padEnd(SLICE_LIST_MTIME_COL_WIDTH) + 'SIZE'.padEnd(SLICE_LIST_SIZE_COL_WIDTH) + 'PICKED'.padEnd(SLICE_LIST_PICKED_COL_WIDTH) + 'STALE');
112
+ lines.push('-'.repeat(SLICE_LIST_SEPARATOR_WIDTH));
102
113
  for (const r of page) {
103
- lines.push(r.rid.padEnd(34) +
104
- r.mtime.padEnd(22) +
105
- String(r.sizeBytes).padEnd(8) +
106
- (r.pickedPath ? 'yes' : 'no').padEnd(6) +
114
+ lines.push(r.rid.padEnd(SLICE_LIST_RID_COL_WIDTH) +
115
+ r.mtime.padEnd(SLICE_LIST_MTIME_COL_WIDTH) +
116
+ String(r.sizeBytes).padEnd(SLICE_LIST_SIZE_COL_WIDTH) +
117
+ (r.pickedPath ? 'yes' : 'no').padEnd(SLICE_LIST_PICKED_COL_WIDTH) +
107
118
  (r.isStale ? 'yes' : 'no'));
108
119
  }
109
120
  if (truncated) {
@@ -365,6 +376,7 @@ function writeDecompositionFile(rid, result, projectRoot) {
365
376
  * recorded in .peaks/memory/ for follow-up). If that slice ships with a
366
377
  * different default, both must update here.
367
378
  */
379
+ // eslint-disable-next-line no-magic-numbers -- canonical 30-day-in-ms math
368
380
  const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
369
381
  /**
370
382
  * Enumerate every slice decomposition artifact under
@@ -3,6 +3,7 @@ import { type ProgramIO } from '../cli-helpers.js';
3
3
  type RenderOptions = {
4
4
  project?: string;
5
5
  json?: boolean;
6
+ now?: number | string;
6
7
  };
7
8
  /**
8
9
  * Default-statusline render body. Reused by both the top-level default
@@ -74,7 +74,14 @@ export async function runDefaultStatuslineRender(options, io) {
74
74
  const seeded = options.project
75
75
  ? { ...(stdin ?? {}), workspace: { current_dir: options.project } }
76
76
  : stdin;
77
- const model = buildStatusLineModel(seeded, Date.now());
77
+ // Slice 2026-08-07-statusline-flake: accept `--now <epoch-ms>` to pin
78
+ // `Date.now()` for deterministic lifecycle-window checks under full-
79
+ // suite concurrency. The integration test passes a test-time `now`
80
+ // pinned at the moment `seedLifecycle` was called so the completed-
81
+ // expiry window stays in-range regardless of how long the spawned
82
+ // subprocess sat descheduled. Production callers omit the flag.
83
+ const now = options.now !== undefined ? Number(options.now) : Date.now();
84
+ const model = buildStatusLineModel(seeded, now);
78
85
  const capability = resolveStatusLineCapability({
79
86
  env: process.env,
80
87
  isTTY: Boolean(process.stdout.isTTY),
@@ -98,7 +105,8 @@ export function registerStatusLineCommands(program, io) {
98
105
  const statusline = addJsonOption(program
99
106
  .command('statusline')
100
107
  .description('Render the Peaks skill status line for the current session, or manage the adapter-driven statusLine entry. Run with no subcommand to render; with a subcommand (install | uninstall | status) to manage.')
101
- .option('--project <path>', 'project root path (used to label the status line when stdin is absent; applies to the default render path)'));
108
+ .option('--project <path>', 'project root path (used to label the status line when stdin is absent; applies to the default render path)')
109
+ .option('--now <ms>', 'override current time (epoch ms) — for testing / deterministic rendering of the lifecycle expiry window'));
102
110
  // Default behavior: when the user types `peaks statusline` with no
103
111
  // subcommand, render the status line. Without this default action,
104
112
  // commander falls back to printing usage (Bug-02, ice-cola surface check
@@ -225,6 +233,7 @@ export function registerStatusLineCommands(program, io) {
225
233
  '--session-id to target a non-canonical session explicitly.')
226
234
  .option('--project <path>', 'project root (auto-detected from cwd when omitted)')
227
235
  .option('--session-id <sid>', 'override the active session id (defaults to the canonical binding; supported for QA / internal tooling)')
236
+ .option('--now <ms>', 'override current time (epoch ms) — for testing / deterministic rendering of the lifecycle expiry window')
228
237
  .action((options, command) => {
229
238
  // Re-resolve options via `command.optsWithGlobals()` (Commander 12.x
230
239
  // compatibility): when the parent `statusline` command has its own
@@ -243,10 +252,15 @@ export function registerStatusLineCommands(program, io) {
243
252
  ? merged.project
244
253
  : (findProjectRoot(process.cwd()) ?? process.cwd());
245
254
  const sid = merged.sessionId ?? getSessionIdCanonical(projectRoot) ?? null;
255
+ // Slice 2026-08-07-statusline-flake: honor `--now` (epoch ms)
256
+ // when present so deterministic lifecycle-window checks stay
257
+ // in-range under full-suite concurrency. See `--now` doc on
258
+ // the parent `statusline` command.
259
+ const now = merged.now !== undefined ? Number(merged.now) : Date.now();
246
260
  const state = decideCompactStatusline({
247
261
  projectRoot,
248
262
  sessionId: sid,
249
- now: Date.now(),
263
+ now,
250
264
  });
251
265
  const label = renderCompactStatusline(state);
252
266
  // For non-JSON mode we print the LABEL DIRECTLY to stdout
@@ -18,7 +18,7 @@
18
18
  * (B2 contract preservation rule from the rid-009 closure record).
19
19
  */
20
20
  import type { Command } from 'commander';
21
- import type { ProgramIO } from '../cli-helpers.js';
21
+ import { type ProgramIO } from '../cli-helpers.js';
22
22
  import type { WorkspaceConfig } from '../../services/config/config-types.js';
23
23
  import { type TechChangeStatus, type WorkspaceConfig as _WorkspaceConfig } from '../../services/tech/tech-change-id-service.js';
24
24
  export type { WorkspaceConfig };
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_REQUEST_TYPE, isRequestType, VALID_REQUEST_TYPES, type PrerequisiteCheckResult, type RequestType } from './artifact-prerequisites.js';
2
2
  export { VALID_REQUEST_TYPES, DEFAULT_REQUEST_TYPE, isRequestType, type RequestType };
3
- import type { RequestArtifactRole } from './artifact-templates.js';
3
+ import { type RequestArtifactRole } from './artifact-templates.js';
4
4
  export type { RequestArtifactRole } from './artifact-templates.js';
5
5
  export { formatHandoffPath, formatCommitBoundaryPath, formatSkillUsageLessonsPath } from './artifact-templates.js';
6
6
  export type CreateRequestArtifactOptions = {
@@ -110,7 +110,7 @@ export type TransitionRequestArtifactResult = RequestArtifactSummary & {
110
110
  content: string;
111
111
  bypassedPrerequisites?: PrerequisiteCheckResult;
112
112
  };
113
- import type { RequestArtifactState } from './request-artifact-state-helpers.js';
113
+ import { type RequestArtifactState } from './request-artifact-state-helpers.js';
114
114
  export { allowedStatesForRole, FileSizeViolationError, LintGateError, PrerequisitesNotSatisfiedError, TypeSanityViolationError, updateStatusBlock } from './request-artifact-state-helpers.js';
115
115
  export type { RequestArtifactState } from './request-artifact-state-helpers.js';
116
116
  export declare function transitionRequestArtifact(options: TransitionRequestArtifactOptions): Promise<TransitionRequestArtifactResult | null>;
@@ -16,6 +16,15 @@ import { requireUserConfirmation } from '../mode/mode-enforcement.js';
16
16
  import { scanFileSize } from '../scan/file-size-scan.js';
17
17
  import { emitObservabilityEvent } from '../observability/observability-service.js';
18
18
  export { VALID_REQUEST_TYPES, DEFAULT_REQUEST_TYPE, isRequestType };
19
+ // Re-export the pure-function template surface so external callers (CLI,
20
+ // tests) keep importing from this module unchanged. The render templates,
21
+ // handoff-path helpers, and `RequestArtifactRole` type all live in the
22
+ // sibling `artifact-templates.ts` module — see slice
23
+ // 2026-06-24-handoff-path-canonicalization for the rationale.
24
+ //
25
+ // `import type` is required so the type is in scope as a bare identifier
26
+ // within this file (the value re-export alone does not bring types into
27
+ // the local namespace under `isolatedModules`).
19
28
  import { renderTemplate } from './artifact-templates.js';
20
29
  export { formatHandoffPath, formatCommitBoundaryPath, formatSkillUsageLessonsPath } from './artifact-templates.js';
21
30
  const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -324,7 +333,7 @@ async function readRequestArtifact(projectRoot, scope, role, found) {
324
333
  // the sibling `request-artifact-state-helpers.ts` module — see
325
334
  // v2.18.3 file-split for the rationale. Function signatures and
326
335
  // behaviour are unchanged (verbatim move).
327
- import { ALLOWED_STATES_PER_ROLE, FileSizeViolationError, LintGateError, PrerequisitesNotSatisfiedError, TypeSanityViolationError, updateStatusBlock } from './request-artifact-state-helpers.js';
336
+ import { ALLOWED_STATES_PER_ROLE, FileSizeViolationError, LintGateError, PrerequisitesNotSatisfiedError, TypeSanityViolationError, updateStatusBlock, } from './request-artifact-state-helpers.js';
328
337
  export { allowedStatesForRole, FileSizeViolationError, LintGateError, PrerequisitesNotSatisfiedError, TypeSanityViolationError, updateStatusBlock } from './request-artifact-state-helpers.js';
329
338
  export async function transitionRequestArtifact(options) {
330
339
  if (!VALID_ROLES.has(options.role)) {
@@ -35,6 +35,20 @@ const ABSOLUTE_PATH_WINDOWS = /[A-Z]:\\[\w\\]/;
35
35
  const ABSOLUTE_PATH_UNIX = /\/usr\/(?:local|bin|opt)\b/;
36
36
  const MAGIC_NUMBER = /\b(\d{3,})\b/;
37
37
  const LOAD_STRATEGY_PATTERN = /loadStrategy:\s*(always|on-demand)/i;
38
+ /**
39
+ * PRD-002b slice 2 — extract cross-cutting thresholds used by
40
+ * multiple enforcers (file-size cap, magic-number digit floor).
41
+ * Per-rule spec literals (≤800 lines, ≤12 H2 headings, ≤10 magic
42
+ * digits, top-N aggregation cap) are kept inline because they ARE
43
+ * the rule spec; extracting them would change the rule semantics to
44
+ * depend on a constant with no semantic gain. Names here describe
45
+ * the role only.
46
+ */
47
+ const KB_PER_FILE = 1024;
48
+ const LOAD_STRATEGY_FILE_SIZE_KB = 5;
49
+ const OVERVIEW_TOP_SEARCH_LINES = 30;
50
+ const OVERVIEW_MIN_LINE_COUNT = 200;
51
+ const LOAD_STRATEGY_PROBE_LINES = 20;
38
52
  function findLine(lines, pattern) {
39
53
  for (let i = 0; i < lines.length; i += 1) {
40
54
  if (pattern.test(lines[i] ?? ''))
@@ -129,9 +143,9 @@ export function lintH2CountLe12(ref) {
129
143
  return [hit('rl-ref-h2-count-le-12-001', 'at most 12 `## <heading>` per reference', ref.path, 1, `(h2 count ${matches.length} > 12)`)];
130
144
  }
131
145
  export function lintOverviewNearTop(ref) {
132
- if (ref.lines.length <= 200)
146
+ if (ref.lines.length <= OVERVIEW_MIN_LINE_COUNT)
133
147
  return [];
134
- if (findLine(ref.lines.slice(0, 30), OVERVIEW_HEADING) !== -1)
148
+ if (findLine(ref.lines.slice(0, OVERVIEW_TOP_SEARCH_LINES), OVERVIEW_HEADING) !== -1)
135
149
  return [];
136
150
  return [hit('rl-ref-overview-section-near-top-001', 'long references (>200 lines) must have `## Overview` within the first 30 lines', ref.path, 1, '(missing `## Overview` near top of long reference)')];
137
151
  }
@@ -149,7 +163,7 @@ export function lintLoadStrategyAlwaysCacheable(ref) {
149
163
  // Heuristic: a loadStrategy: always reference must not have a
150
164
  // top-level shell command. This is a soft check — we look at
151
165
  // the first 10 non-frontmatter lines for I/O patterns.
152
- const lines = ref.lines.slice(0, 20);
166
+ const lines = ref.lines.slice(0, LOAD_STRATEGY_PROBE_LINES);
153
167
  for (const line of lines) {
154
168
  if (/^\s*(npm|pnpm|yarn|npx|git|curl|wget|docker)\s+/.test(line)) {
155
169
  return [hit('rl-ref-loadstrategy-always-cacheable-001', 'loadStrategy: always references must not run I/O at top of file', ref.path, 1, `(I/O pattern at top of file: ${line.trim()})`)];
@@ -249,7 +263,7 @@ export function lintSkillCitesEveryReference(ref, skill) {
249
263
  }
250
264
  export function lintLoadStrategyMatchesSize(ref) {
251
265
  const sizeBytes = Buffer.byteLength(ref.body, 'utf8');
252
- if (sizeBytes <= 5 * 1024)
266
+ if (sizeBytes <= LOAD_STRATEGY_FILE_SIZE_KB * KB_PER_FILE)
253
267
  return [];
254
268
  // >5KB file should declare loadStrategy: on-demand (always
255
269
  // is a context-budget bug).
@@ -36,6 +36,14 @@ import { describeMode, thresholdFor } from './auto-compact-modes.js';
36
36
  import { read24hState } from '../24h-mode/store.js';
37
37
  import { readCompactLifecycle, writeCompactLifecycle } from '../compact-statusline/compact-lifecycle-store.js';
38
38
  const PRE_COMPACT_REASON = 'pre-compact-auto';
39
+ /**
40
+ * PRD-002b slice 2 — extract the few cross-cutting magic numbers
41
+ * that actually appear at runtime call sites in this orchestrator.
42
+ * Threshold ratios (0.85 / 0.95) live in `auto-compact-types.ts`
43
+ * and are NOT extracted here because they ARE the contract; comment
44
+ * + spec prose references must keep the literal value visible.
45
+ */
46
+ const COLLAPSED_ERROR_MAX_CHARS = 160;
39
47
  /**
40
48
  * Map a context ratio to a `CompactTrigger` action. Pure; the side
41
49
  * effects (checkpoint + IDE dispatch) live in `runAutoCompact`. Two
@@ -409,7 +417,7 @@ function summarizeLifecycleError(error) {
409
417
  raw = 'unknown error';
410
418
  const firstLine = raw.split('\n')[0] ?? '';
411
419
  const collapsed = firstLine.replace(/\s+/g, ' ').trim();
412
- return collapsed.length === 0 ? 'unknown error' : (collapsed.length > 160 ? collapsed.slice(0, 160) : collapsed);
420
+ return collapsed.length === 0 ? 'unknown error' : (collapsed.length > COLLAPSED_ERROR_MAX_CHARS ? collapsed.slice(0, COLLAPSED_ERROR_MAX_CHARS) : collapsed);
413
421
  }
414
422
  /**
415
423
  * Slice 2026-08-01-compact-lifecycle (Task 5, Step 4): close out an
@@ -1,3 +1,29 @@
1
+ /**
2
+ * G6 — in-process batch heartbeat poller.
3
+ *
4
+ * The Dispatcher (peaks-code main loop) starts one poller per batch.
5
+ * The poller ticks every 10 s, reads `heartbeats[]` + `lastBeatAt` from
6
+ * each record in the batch, and emits:
7
+ *
8
+ * - a `dispatcherStatus` envelope to the renderer's `onStatus` callback
9
+ * (which formats the single-line status per G6.5)
10
+ * - a `stale` envelope to `onStale` if a record crosses the 5-min
11
+ * threshold (G6.2 / AC-35)
12
+ *
13
+ * The poller does **not**:
14
+ * - cancel, kill, or send SIGTERM to a sub-agent (RL-15)
15
+ * - modify `outcome` (only the aggregate `status` is flipped to 'stale')
16
+ * - block the LLM call (it is fire-and-forget; the parent batch-sync
17
+ * wait is governed by the LLM platform, not by this poller)
18
+ *
19
+ * The poller stops when:
20
+ * - the parent calls `stop()` (batch finished)
21
+ * - all records are terminal (`done` / `failed` / `cancelled` / `no-execution`)
22
+ *
23
+ * Pure JS event loop — no native deps, no IPC. Designed to be replaced
24
+ * by a real OS-level watcher in a future slice without changing the
25
+ * callback contract.
26
+ */
1
27
  import { readRecords } from '../dispatch/dispatch-record-writer.js';
2
28
  import { renderStatusLine, summarize, viewSubAgent } from './status-line-renderer.js';
3
29
  export const DEFAULT_POLL_INTERVAL_MS = 10_000;
@@ -38,6 +38,19 @@ export declare const ORCHESTRATOR_REDLINE_RATIO = 0.95;
38
38
  export declare const ORCHESTRATOR_PRECOMPACT_RATIO = 0.85;
39
39
  /** Source-code keywords that signal "do NOT Edit/Write directly". */
40
40
  export declare const SOURCE_CODE_KEYWORDS: readonly string[];
41
+ /**
42
+ * Slice 2026-08-06-codegate-vendor-neutral — hard-blocked path families.
43
+ * When ANY of these substrings appears in the slice-spec, the orchestrator
44
+ * MUST NOT Edit/Write directly; the probe returns `canDoInSession: false`
45
+ * with `blockers: ["requires-sub-agent-dispatch"]` to force
46
+ * `peaks sub-agent dispatch rd`. The hook (`pre-tool-code-gate.sh`)
47
+ * enforces the same deny at the PreToolUse layer.
48
+ *
49
+ * The allow-list (.peaks/**, .peaks/_runtime/**, skill files, docs/**)
50
+ * is checked by the hook, NOT by this probe — the probe is content-side
51
+ * (slice-spec text) and the hook is file-side (resolved path on Edit/Write).
52
+ */
53
+ export declare const HARD_BLOCKED_PATH_FAMILIES: readonly string[];
41
54
  /** Decision-marker keywords that signal "needs user AskUserQuestion". */
42
55
  export declare const DECISION_KEYWORDS: readonly string[];
43
56
  export interface ContextProbe {
@@ -67,6 +80,8 @@ export interface OrchestratorCanDoResult {
67
80
  readonly subAgentAvailable: boolean;
68
81
  /** Diagnostic — which of the 4 boundary questions fired. */
69
82
  readonly q1SourceCodeTouched: boolean;
83
+ /** Slice 2026-08-06-codegate-vendor-neutral: did the slice-spec mention a hard-blocked path family (src/, tests/unit/, ...)? When true the probe refuses direct execution. */
84
+ readonly q1HardBlockedPath: boolean;
70
85
  readonly q2SubAgentAvailable: boolean;
71
86
  readonly q3RequiresUserDecision: boolean;
72
87
  readonly q4ContextRatio: number;
@@ -80,6 +95,14 @@ export declare class OrchestratorCanDoError extends Error {
80
95
  * the slice-spec string. Case-insensitive substring match.
81
96
  */
82
97
  export declare function detectSourceCodeTouched(sliceSpec: string): boolean;
98
+ /**
99
+ * Slice 2026-08-06-codegate-vendor-neutral — does the slice-spec mention
100
+ * any hard-blocked path family? Pure substring match. When true, the
101
+ * probe returns `canDoInSession: false` with `requires-sub-agent-dispatch`.
102
+ * This is the LLM-side complement to the `pre-tool-code-gate.sh` hook
103
+ * (which checks the actual Edit/Write/MultiEdit target path).
104
+ */
105
+ export declare function detectHardBlockedPath(sliceSpec: string): boolean;
83
106
  /**
84
107
  * Q3: does the slice require user decisions? Pure keyword scan
85
108
  * over the slice-spec string.
@@ -103,6 +126,7 @@ export declare function probeContextRatio(projectRoot: string, peaksBin?: string
103
126
  */
104
127
  export declare function buildOrchestratorCanDoResult(input: OrchestratorCanDoInput, signals: {
105
128
  q1SourceCodeTouched: boolean;
129
+ q1HardBlockedPath: boolean;
106
130
  q2SubAgentAvailable: boolean;
107
131
  q3RequiresUserDecision: boolean;
108
132
  q4ContextRatio: number;
@@ -55,6 +55,26 @@ export const SOURCE_CODE_KEYWORDS = [
55
55
  '.go',
56
56
  '.rs',
57
57
  ];
58
+ /**
59
+ * Slice 2026-08-06-codegate-vendor-neutral — hard-blocked path families.
60
+ * When ANY of these substrings appears in the slice-spec, the orchestrator
61
+ * MUST NOT Edit/Write directly; the probe returns `canDoInSession: false`
62
+ * with `blockers: ["requires-sub-agent-dispatch"]` to force
63
+ * `peaks sub-agent dispatch rd`. The hook (`pre-tool-code-gate.sh`)
64
+ * enforces the same deny at the PreToolUse layer.
65
+ *
66
+ * The allow-list (.peaks/**, .peaks/_runtime/**, skill files, docs/**)
67
+ * is checked by the hook, NOT by this probe — the probe is content-side
68
+ * (slice-spec text) and the hook is file-side (resolved path on Edit/Write).
69
+ */
70
+ export const HARD_BLOCKED_PATH_FAMILIES = [
71
+ 'src/',
72
+ 'tests/unit/',
73
+ 'tests/integration/',
74
+ 'config/',
75
+ 'bin/',
76
+ 'scripts/',
77
+ ];
58
78
  /** Decision-marker keywords that signal "needs user AskUserQuestion". */
59
79
  export const DECISION_KEYWORDS = [
60
80
  'design',
@@ -82,6 +102,17 @@ export function detectSourceCodeTouched(sliceSpec) {
82
102
  const lower = sliceSpec.toLowerCase();
83
103
  return SOURCE_CODE_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()));
84
104
  }
105
+ /**
106
+ * Slice 2026-08-06-codegate-vendor-neutral — does the slice-spec mention
107
+ * any hard-blocked path family? Pure substring match. When true, the
108
+ * probe returns `canDoInSession: false` with `requires-sub-agent-dispatch`.
109
+ * This is the LLM-side complement to the `pre-tool-code-gate.sh` hook
110
+ * (which checks the actual Edit/Write/MultiEdit target path).
111
+ */
112
+ export function detectHardBlockedPath(sliceSpec) {
113
+ const lower = sliceSpec.toLowerCase();
114
+ return HARD_BLOCKED_PATH_FAMILIES.some((fam) => lower.includes(fam.toLowerCase()));
115
+ }
85
116
  /**
86
117
  * Q3: does the slice require user decisions? Pure keyword scan
87
118
  * over the slice-spec string.
@@ -135,6 +166,17 @@ export function buildOrchestratorCanDoResult(input, signals) {
135
166
  const blockers = [];
136
167
  const warnings = [];
137
168
  const suggestions = [];
169
+ // Slice 2026-08-06-codegate-vendor-neutral — Q1 HARD BLOCKER. When
170
+ // the slice-spec mentions any hard-blocked path family
171
+ // (src/, tests/unit/, tests/integration/, config/, bin/, scripts/),
172
+ // the orchestrator MUST refuse direct execution and force sub-agent
173
+ // dispatch. This is the LLM-side complement to the
174
+ // `pre-tool-code-gate.sh` PreToolUse hook.
175
+ if (signals.q1HardBlockedPath) {
176
+ blockers.push('requires-sub-agent-dispatch: slice-spec mentions a hard-blocked path family ' +
177
+ '(src/, tests/unit/, tests/integration/, config/, bin/, scripts/); ' +
178
+ 'orchestrator MUST NOT Edit/Write these directly. Use peaks sub-agent dispatch rd.');
179
+ }
138
180
  // Q2 — sub-agent availability is a hard precondition.
139
181
  if (!signals.q2SubAgentAvailable) {
140
182
  blockers.push('sub-agent dispatch unavailable (peaks sub-agent dispatch --help failed)');
@@ -154,8 +196,9 @@ export function buildOrchestratorCanDoResult(input, signals) {
154
196
  if (signals.q3RequiresUserDecision) {
155
197
  warnings.push('slice-spec contains decision keywords; AskUserQuestion before proceeding');
156
198
  }
157
- // Q1 — source-code touched is NOT a blocker. The orchestrator
158
- // delegates via sub-agent dispatch.
199
+ // Q1 (soft) — source-code touched is a sub-agent-dispatch hint. When
200
+ // not already hard-blocked (above), surface the dispatch verb. When
201
+ // already hard-blocked the blocker line carries the same instruction.
159
202
  if (signals.q1SourceCodeTouched && signals.q2SubAgentAvailable) {
160
203
  const batchId = randomUUID();
161
204
  const rid = 'rid-' + Date.now().toString(36);
@@ -175,6 +218,7 @@ export function buildOrchestratorCanDoResult(input, signals) {
175
218
  contextRatio: signals.q4ContextRatio,
176
219
  subAgentAvailable: signals.q2SubAgentAvailable,
177
220
  q1SourceCodeTouched: signals.q1SourceCodeTouched,
221
+ q1HardBlockedPath: signals.q1HardBlockedPath,
178
222
  q2SubAgentAvailable: signals.q2SubAgentAvailable,
179
223
  q3RequiresUserDecision: signals.q3RequiresUserDecision,
180
224
  q4ContextRatio: signals.q4ContextRatio,
@@ -189,6 +233,7 @@ export async function evaluateOrchestratorCanDo(input) {
189
233
  throw new OrchestratorCanDoError('--slice-spec is required', 'MISSING_SLICE_SPEC');
190
234
  }
191
235
  const q1SourceCodeTouched = detectSourceCodeTouched(input.sliceSpec);
236
+ const q1HardBlockedPath = detectHardBlockedPath(input.sliceSpec);
192
237
  const q3RequiresUserDecision = detectRequiresUserDecision(input.sliceSpec);
193
238
  const probeSubAgent = input.probeSubAgentAvailable ?? (() => probeSubAgentAvailable(input.projectRoot));
194
239
  const probeContext = input.probeContextRatio ?? (() => probeContextRatio(input.projectRoot));
@@ -198,6 +243,7 @@ export async function evaluateOrchestratorCanDo(input) {
198
243
  ]);
199
244
  return buildOrchestratorCanDoResult(input, {
200
245
  q1SourceCodeTouched,
246
+ q1HardBlockedPath,
201
247
  q2SubAgentAvailable: subAgentAvailable,
202
248
  q3RequiresUserDecision,
203
249
  q4ContextRatio: ctxProbe.ratio,
@@ -21,7 +21,7 @@
21
21
  * IO: readdir + readFile on `.peaks/_runtime/<sid>/checkpoints/`.
22
22
  * No writes (caller logs the decision via the auto-decisions channel).
23
23
  */
24
- import type { SkillPresenceMode } from '../skills/skill-presence-service.js';
24
+ import { type SkillPresenceMode } from '../skills/skill-presence-service.js';
25
25
  import { type CodeMode } from './mode-gate.js';
26
26
  export type PostCompactResumeReason = 'post-compact-match' | 'sid-unbound' | 'runtime-dir-missing' | 'no-checkpoint-today' | 'no-mode-field' | 'stale-checkpoint' | 'multiple-checkpoints-ambiguous' | 'active-skill-mismatch' | 'checkpoint-read-failed';
27
27
  export declare const POST_COMPACT_RESUME_REASONS: readonly PostCompactResumeReason[];