peaks-loop 4.0.6 → 4.0.7

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 (30) hide show
  1. package/CHANGELOG.md +290 -0
  2. package/dist/cli/commands/code-job-shape-commands.js +11 -8
  3. package/dist/cli/commands/code-mode-gate-commands.js +43 -1
  4. package/dist/cli/commands/code-runtime-commands.js +11 -6
  5. package/dist/cli/commands/core/standards-command.js +50 -5
  6. package/dist/cli/commands/dispatch-commands.js +39 -3
  7. package/dist/cli/commands/hooks-commands.js +26 -9
  8. package/dist/cli/commands/sub-agent-commands.js +2 -0
  9. package/dist/cli/commands/sub-agent-exec-command.d.ts +25 -0
  10. package/dist/cli/commands/sub-agent-exec-command.js +132 -0
  11. package/dist/cli/commands/sub-agent-shared.d.ts +11 -0
  12. package/dist/services/audit/backing-detector.d.ts +19 -2
  13. package/dist/services/audit/backing-detector.js +66 -7
  14. package/dist/services/audit/enforcers/lint-audit-regression.js +10 -1
  15. package/dist/services/context/build-dispatch-system-prompt.js +18 -0
  16. package/dist/services/dispatch/test-tool-detection.d.ts +14 -0
  17. package/dist/services/dispatch/test-tool-detection.js +22 -0
  18. package/dist/services/scan/archetype-service.js +52 -2
  19. package/dist/services/session/index.d.ts +1 -1
  20. package/dist/services/session/index.js +1 -1
  21. package/dist/services/session/session-manager.d.ts +29 -0
  22. package/dist/services/session/session-manager.js +33 -0
  23. package/dist/services/skills/hooks-settings-service.d.ts +41 -0
  24. package/dist/services/skills/hooks-settings-service.js +61 -5
  25. package/dist/services/slice/slice-check-service.js +59 -0
  26. package/dist/services/standards/find-skills-integration.d.ts +59 -0
  27. package/dist/services/standards/find-skills-integration.js +225 -0
  28. package/dist/services/standards/project-standards-service.js +44 -0
  29. package/package.json +4 -4
  30. package/skills/peaks-code/SKILL.md +43 -5
@@ -38,12 +38,23 @@ function resolveIdeForCommand(options, projectRoot) {
38
38
  * the install shape so the JSON envelope doesn't claim a hook the
39
39
  * service did not write.
40
40
  */
41
- function listExpectedEntriesForIde(ide, _skipProgress = false) {
41
+ function listExpectedEntriesForIde(ide, options = {}) {
42
42
  const adapter = getAdapter(ide);
43
+ const out = [];
43
44
  if (ide === 'trae') {
44
- return [{ matcher: adapter.toolMatcher, sentinel: 'peaks hook handle' }];
45
+ out.push({ matcher: adapter.toolMatcher, sentinel: 'peaks hook handle' });
45
46
  }
46
- return [{ matcher: adapter.toolMatcher, sentinel: 'peaks gate enforce' }];
47
+ else {
48
+ out.push({ matcher: adapter.toolMatcher, sentinel: 'peaks gate enforce' });
49
+ }
50
+ // Slice 4.0.7-PR-meta-5: opt-in meta-1 / meta-3 hook entries.
51
+ if (options.withEditEnforcement === true) {
52
+ out.push({ matcher: 'Edit|Write|MultiEdit|NotebookEdit', sentinel: 'peaks edit enforcement' });
53
+ }
54
+ if (options.withScopeCounter === true) {
55
+ out.push({ matcher: 'Edit|Write|MultiEdit|NotebookEdit', sentinel: 'peaks scope counter' });
56
+ }
57
+ return out;
47
58
  }
48
59
  /**
49
60
  * Slice 2026-07-24-peaks-code-bridge-002-rootcause (G6b / G10): copy the
@@ -104,20 +115,24 @@ export function registerHooksCommands(program, io) {
104
115
  .description("Manage the Peaks-managed hook entry in the adapter's settings.json (default: .claude/settings.json for Claude, .trae/settings.json for Trae). Slice #014: the only installed entry is the gate-enforce hook (SOP gate). The legacy progress-start hook (auto-spawn sub-agent progress terminal) is no longer installed — sub-agent progress is now surfaced via the dispatch + heartbeat flow (`peaks sub-agent dispatch` / `peaks sub-agent heartbeat`). The IDE is auto-detected from env / cwd; override with --ide <id>.");
105
116
  addJsonOption(hooks
106
117
  .command('install')
107
- .description(`Install the peaks-managed gate-enforce hook entry into the adapter's settings.json. Slice #014: only the gate-enforce entry is installed; the legacy progress-start entry is no longer installed. Idempotent: re-runs are no-ops. Project scope by default.`)
118
+ .description(`Install the peaks-managed gate-enforce hook entry into the adapter's settings.json. Slice #014: only the gate-enforce entry is installed by default; the legacy progress-start entry is no longer installed. Idempotent: re-runs are no-ops. Project scope by default. Slice 4.0.7-PR-meta-5: --with-edit-enforcement and --with-scope-counter opt-in flags add the corresponding PreToolUse hook entries (off by default to preserve the existing install behavior).`)
108
119
  .option('--global', 'install into the user-level ~/.claude/settings.json instead of the project')
109
120
  .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
110
121
  .option('--ide <id>', "target adapter id (claude-code | trae); default: auto-detect from env/cwd")
111
122
  .option('--dry-run', 'show what would change without writing')
112
- .option('--no-progress', 'skip the progress-start PreToolUse hook entry; install ONLY the gate-enforce entry')).action((options) => {
123
+ .option('--no-progress', 'skip the progress-start PreToolUse hook entry; install ONLY the gate-enforce entry')
124
+ .option('--with-edit-enforcement', 'Slice 4.0.7-PR-meta-5: opt-in. Add the Edit-enforcement PreToolUse hook entry (pre-tool-edit-enforcement.sh). Default false to preserve the existing install behavior. Once installed, the hook blocks src/** edits without an RD artifact on disk.')
125
+ .option('--with-scope-counter', 'Slice 4.0.7-PR-meta-5: opt-in. Add the scope-counter PreToolUse hook entry (pre-tool-scope-counter.sh). Default false. The hook counts cumulative application-source edits and emits a fail-LOUD reflection reminder at thresholds 5, 10, 20.')).action((options) => {
113
126
  const scope = resolveScope(options);
114
127
  const projectRoot = resolveProjectRoot(scope, options.project);
115
128
  const ide = resolveIdeForCommand(options, projectRoot);
116
129
  const skipProgress = options.progress === false;
130
+ const withEditEnforcement = options.withEditEnforcement === true;
131
+ const withScopeCounter = options.withScopeCounter === true;
117
132
  try {
118
133
  if (options.dryRun === true) {
119
134
  const plan = planHookInstall(scope, projectRoot, { ide, skipProgress });
120
- const dryRunEntries = listExpectedEntriesForIde(ide, skipProgress);
135
+ const dryRunEntries = listExpectedEntriesForIde(ide, { skipProgress, withEditEnforcement, withScopeCounter });
121
136
  const bridgeCopy = scope === 'global'
122
137
  ? copyBridgeHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '')
123
138
  : { copied: false, source: '', target: '' };
@@ -145,14 +160,16 @@ export function registerHooksCommands(program, io) {
145
160
  ]), options.json);
146
161
  return;
147
162
  }
148
- const result = applyHookInstall(scope, projectRoot, { ide, skipProgress });
163
+ const result = applyHookInstall(scope, projectRoot, { ide, skipProgress, withEditEnforcement, withScopeCounter });
149
164
  // Slice #3: build the per-IDE entries summary from the actual installed
150
165
  // entries, not the slice #1 PEAKS_HOOK_ENTRIES constant (which is the
151
166
  // claude-code default). The user's JSON envelope must reflect the IDE
152
167
  // they targeted. Slice #014: the install only emits the gate-enforce
153
168
  // entry; the summary mirrors the install shape, NOT a hardcoded
154
- // expected list.
155
- const installedEntries = listExpectedEntriesForIde(ide, skipProgress);
169
+ // expected list. Slice 4.0.7-PR-meta-5: the summary now also
170
+ // includes the opt-in Edit-enforcement + scope-counter entries
171
+ // when the user passed --with-edit-enforcement / --with-scope-counter.
172
+ const installedEntries = listExpectedEntriesForIde(ide, { skipProgress, withEditEnforcement, withScopeCounter });
156
173
  // Slice 2026-07-24-peaks-code-bridge-002-rootcause (G6b / G10): when
157
174
  // the install targets global scope, also copy the superpowers-bridge
158
175
  // hook script from src/services/hooks/ to the user-global hooks
@@ -1,4 +1,5 @@
1
1
  import { registerDispatchCommand } from './dispatch-commands.js';
2
+ import { registerSubAgentExecCommand } from './sub-agent-exec-command.js';
2
3
  import { registerHeartbeatCommand } from './heartbeat-commands.js';
3
4
  import { registerShareCommand, registerSharedReadCommand, registerAwaitCommand, registerFinalizeCommand } from './share-commands.js';
4
5
  // Re-export `validateRole` for backward compat — the integration test
@@ -12,6 +13,7 @@ export function registerSubAgentCommands(program, io) {
12
13
  'These commands are the primitives that peaks-code / peaks-rd / ' +
13
14
  'peaks-qa SKILL.md compose. Users do not invoke this directly.');
14
15
  registerDispatchCommand(subAgent, io);
16
+ registerSubAgentExecCommand(subAgent, io); // 4.0.7-PR-meta-6
15
17
  registerHeartbeatCommand(subAgent, io);
16
18
  registerShareCommand(subAgent, io);
17
19
  registerSharedReadCommand(subAgent, io);
@@ -0,0 +1,25 @@
1
+ import type { Command } from 'commander';
2
+ import { type ProgramIO } from '../cli-helpers.js';
3
+ /**
4
+ * Shape of a dispatch record on disk (slice 2026-06-07 +
5
+ * dispatch-record metadata). We only read the fields `exec` needs
6
+ * to re-emit the toolCall envelope; everything else (heartbeat,
7
+ * artifacts, lease, isolation) is preserved by the dispatch record
8
+ * itself and surfaced in the re-emitted envelope.
9
+ */
10
+ interface DispatchRecordToolCall {
11
+ name: string;
12
+ args: Record<string, unknown>;
13
+ }
14
+ interface DispatchRecord {
15
+ toolCall?: DispatchRecordToolCall;
16
+ promptSize?: number;
17
+ originalPromptSize?: number;
18
+ batchId?: string;
19
+ rid?: string;
20
+ role?: string;
21
+ sessionId?: string;
22
+ }
23
+ export declare function registerSubAgentExecCommand(parent: Command, io: ProgramIO): void;
24
+ export declare function readDispatchRecordForExec(dispatchRecordPath: string): DispatchRecord;
25
+ export {};
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `peaks sub-agent exec` — slice 4.0.7-PR-meta-6.
3
+ *
4
+ * Companion to `peaks sub-agent dispatch` (slice 2026-06-07). The
5
+ * dispatch CLI is dry-run by design (per skill-first / CLI-auxiliary
6
+ * red line PB-4): it returns a `toolCall` envelope that the LLM
7
+ * extracts args from and re-executes via the Agent tool. The 4.0.7
8
+ * ice-cola dogfood pass surfaced the friction: the LLM often
9
+ * skipped the dispatch altogether instead of paying the parse
10
+ * cost, and routed `Edit` calls directly. PR-meta-4 lowered the
11
+ * friction by adding `--emit-bash-script`, which prints a
12
+ * ready-to-exec bash script alongside the JSON envelope. PR-meta-6
13
+ * ships the actual surface the script invokes: this `exec`
14
+ * sub-command reads the dispatch record from disk (the same
15
+ * record the dispatch CLI wrote) and re-emits the canonical
16
+ * toolCall envelope so the LLM does not have to hand-parse the
17
+ * JSON to retry a dispatch.
18
+ *
19
+ * Architecture (PR-meta-6):
20
+ * 1. The user (or the LLM via Bash tool) runs the script
21
+ * emitted by `peaks sub-agent dispatch --emit-bash-script`.
22
+ * 2. The script invokes `peaks sub-agent exec --session-id ...
23
+ * --request-id ... --role ... --batch-id ... --dispatch-record ...
24
+ * --project ...`.
25
+ * 3. `exec` reads the dispatch record from disk, re-emits the
26
+ * same toolCall envelope, and prints a single next-action
27
+ * line: "Run this Task tool call to dispatch."
28
+ *
29
+ * The dry-run architecture is preserved: the LLM still has the
30
+ * final say on whether to actually spawn the Task tool. The
31
+ * script + exec pair is a transparent retry path, not a side-step.
32
+ */
33
+ import { existsSync, readFileSync } from 'node:fs';
34
+ import { dirname } from 'node:path';
35
+ import { addJsonOption, printResult } from '../cli-helpers.js';
36
+ import { fail, ok } from 'peaks-loop-shared/result';
37
+ function failExec(command, message, options, hint) {
38
+ printResult(undefined, // io is plumbed below
39
+ fail(command, 'SUB_AGENT_EXEC_FAILED', message, null, [hint]), options.json);
40
+ // The above `printResult` expects io — the real call site passes
41
+ // io explicitly. This helper is only used for type-level
42
+ // documentation; the actual printing is inline in the action.
43
+ throw new Error(message);
44
+ }
45
+ export function registerSubAgentExecCommand(parent, io) {
46
+ addJsonOption(parent
47
+ .command('exec')
48
+ .description('Slice 4.0.7-PR-meta-6. Re-emit a stored dispatch envelope. Companion to `peaks sub-agent dispatch --emit-bash-script`. Reads the dispatch record from `--dispatch-record` and prints the canonical toolCall envelope so the LLM does not have to hand-parse the JSON to retry. Dry-run: the LLM still owns the actual Agent tool invocation.')
49
+ .requiredOption('--session-id <sid>', 'session id the dispatch was created in')
50
+ .requiredOption('--request-id <rid>', 'request id the dispatch was created for')
51
+ .requiredOption('--role <role>', 'sub-agent role (rd | qa | ui | sc | txt | qa-business | qa-business-api)')
52
+ .requiredOption('--batch-id <uuid>', 'batch id the dispatch was created in')
53
+ .requiredOption('--dispatch-record <path>', 'path to the dispatch record JSON (the same file the dispatch CLI wrote under .peaks/_sub_agents/<sid>/)')
54
+ .requiredOption('--project <path>', 'target project root (echoed back in the envelope; not used to read files)')).action((options) => {
55
+ if (options.dispatchRecord === undefined || !existsSync(options.dispatchRecord)) {
56
+ printResult(io, fail('sub-agent.exec', 'DISPATCH_RECORD_MISSING', `Dispatch record not found at ${options.dispatchRecord ?? '(unset)'}`, null, [
57
+ 'Re-run `peaks sub-agent dispatch <role> --prompt <text> --request-id <rid> --json` to create the record.',
58
+ 'Then pass the emitted `dispatchRecordPath` to `peaks sub-agent exec --dispatch-record <path>`.'
59
+ ]), options.json);
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ let record;
64
+ try {
65
+ record = JSON.parse(readFileSync(options.dispatchRecord, 'utf8'));
66
+ }
67
+ catch (err) {
68
+ printResult(io, fail('sub-agent.exec', 'DISPATCH_RECORD_INVALID', `Failed to parse dispatch record: ${err instanceof Error ? err.message : String(err)}`, { dispatchRecord: options.dispatchRecord }, [
69
+ 'The dispatch record may be truncated. Re-run `peaks sub-agent dispatch` to create a fresh record.'
70
+ ]), options.json);
71
+ process.exitCode = 1;
72
+ return;
73
+ }
74
+ if (record.toolCall === undefined) {
75
+ printResult(io, fail('sub-agent.exec', 'DISPATCH_RECORD_NO_TOOL_CALL', 'Dispatch record has no `toolCall` field. The record may be from a pre-toolCall era; re-run `peaks sub-agent dispatch` to create a fresh record.', { dispatchRecord: options.dispatchRecord }, [
76
+ 'Re-run `peaks sub-agent dispatch` to create a fresh record with a `toolCall` envelope.'
77
+ ]), options.json);
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ // Sanity check: the record's sessionId / rid / batchId / role
82
+ // should match the CLI flags (if the caller passed them). This
83
+ // is defensive: a mismatch usually means the operator is
84
+ // running the wrong script. We surface a warning but still
85
+ // re-emit the envelope (do not block — the dispatch record
86
+ // is the canonical source of truth).
87
+ const warnings = [];
88
+ if (options.sessionId !== undefined && record.sessionId !== undefined && record.sessionId !== options.sessionId) {
89
+ warnings.push(`session-id mismatch: record=${record.sessionId} vs --session-id=${options.sessionId}; using the record's value as the canonical source.`);
90
+ }
91
+ if (options.requestId !== undefined && record.rid !== undefined && record.rid !== options.requestId) {
92
+ warnings.push(`request-id mismatch: record=${record.rid} vs --request-id=${options.requestId}; using the record's value.`);
93
+ }
94
+ if (options.batchId !== undefined && record.batchId !== undefined && record.batchId !== options.batchId) {
95
+ warnings.push(`batch-id mismatch: record=${record.batchId} vs --batch-id=${options.batchId}; using the record's value.`);
96
+ }
97
+ if (options.role !== undefined && record.role !== undefined && record.role !== options.role) {
98
+ warnings.push(`role mismatch: record=${record.role} vs --role=${options.role}; using the record's value.`);
99
+ }
100
+ const nextActions = [
101
+ 'This is a dry-run re-emit. Run the Task tool with the printed `toolCall.args` to actually dispatch the sub-agent.',
102
+ `After dispatching, the sub-agent should call \`peaks sub-agent heartbeat --record ${options.dispatchRecord}\` periodically.`
103
+ ];
104
+ // Re-emit the same envelope shape as `peaks sub-agent dispatch`
105
+ // so consumers (LLM or downstream tooling) can treat both CLIs
106
+ // as the same surface. Slice 4.0.7-PR-meta-6: the envelope
107
+ // version is bumped to 2.4.0 to mark the new `viaExec` field
108
+ // (true here; false in dispatch CLI output).
109
+ printResult(io, ok('sub-agent.exec', {
110
+ envelopeVersion: '2.4.0',
111
+ viaExec: true,
112
+ sourceDispatchRecord: options.dispatchRecord,
113
+ role: record.role ?? options.role,
114
+ sessionId: record.sessionId ?? options.sessionId,
115
+ rid: record.rid ?? options.requestId,
116
+ batchId: record.batchId ?? options.batchId,
117
+ promptSize: record.promptSize ?? 0,
118
+ originalPromptSize: record.originalPromptSize ?? 0,
119
+ toolCall: record.toolCall
120
+ }, warnings, nextActions), options.json);
121
+ });
122
+ }
123
+ // Helper export so tests can drive the underlying logic without
124
+ // invoking the action handler. The action handler is the
125
+ // integration path; the helper is the unit path.
126
+ export function readDispatchRecordForExec(dispatchRecordPath) {
127
+ if (!existsSync(dispatchRecordPath)) {
128
+ throw new Error(`DISPATCH_RECORD_MISSING: ${dispatchRecordPath}`);
129
+ }
130
+ return JSON.parse(readFileSync(dispatchRecordPath, 'utf8'));
131
+ }
132
+ void dirname;
@@ -40,6 +40,17 @@ export type DispatchOptions = {
40
40
  * worktree without needing a separate `peaks worktree auth grant`.
41
41
  */
42
42
  isolation?: string;
43
+ /**
44
+ * Slice 4.0.7-PR-meta-4: when true, the dispatch CLI also emits a
45
+ * ready-to-exec bash script to stdout (in addition to the JSON
46
+ * envelope) so the LLM can run the script via the Bash tool
47
+ * instead of having to parse toolCall args and re-execute them
48
+ * via the Agent tool. Lowers the dispatch friction documented
49
+ * in `.peaks/memory/2026-07-28-sub-agent-visibility-issue.md`.
50
+ * The dry-run architecture is preserved: the script invokes
51
+ * the canonical `peaks sub-agent exec` surface, not a side-step.
52
+ */
53
+ emitBashScript?: boolean;
43
54
  json?: boolean;
44
55
  };
45
56
  export type HeartbeatOptions = {
@@ -5,20 +5,37 @@
5
5
  * handle the post-classification nuance: heuristics for the "partial" tier
6
6
  * (a gate exists but the LLM can bypass it) and verification that the
7
7
  * enforcer file actually exists on disk.
8
+ *
9
+ * Slice 4.0.7-dogfood-PR-1 (ice-cola surface probe 2026-08-02): the
10
+ * `enforcerRef` paths in the catalog are written relative to the
11
+ * peaks-loop source root (every `enforcerRef` is `src/services/audit/...`
12
+ * and the catalog itself is at `src/services/audit/red-line-catalog.ts`).
13
+ * Resolving them against the audited `--project` root produces false
14
+ * orphans on every downstream project. Fix: resolve against the
15
+ * peaks-loop install root by default, with an explicit
16
+ * `peaksLoopRoot` override for tests.
8
17
  */
9
18
  import type { RedLineEntry } from './types.js';
10
19
  export interface BackingResult {
11
20
  readonly entry: RedLineEntry;
12
21
  readonly enforcerExists: boolean;
13
22
  }
23
+ export declare function resolvePeaksLoopRoot(): string;
24
+ /** Test-only: clear the cached peaks-loop root. */
25
+ export declare function _resetPeaksLoopRootCache(): void;
14
26
  /**
15
27
  * Re-classify a single RedLineEntry. Returns a new entry with the
16
28
  * `backing` field updated and `enforcerRef` possibly nulled if the
17
29
  * referenced file does not exist on disk.
30
+ *
31
+ * `projectRoot` is the audited project (kept for callers that still
32
+ * pass it for context, but no longer used to resolve `enforcerRef`).
33
+ * `peaksLoopRoot` is the peaks-loop source root; defaults to
34
+ * `resolvePeaksLoopRoot()`.
18
35
  */
19
- export declare function classifyBacking(entry: RedLineEntry, projectRoot: string): BackingResult;
36
+ export declare function classifyBacking(entry: RedLineEntry, projectRoot: string, peaksLoopRoot?: string): BackingResult;
20
37
  export interface BackingBatchResult {
21
38
  readonly entries: readonly RedLineEntry[];
22
39
  readonly warnings: readonly string[];
23
40
  }
24
- export declare function classifyBackingBatch(entries: readonly RedLineEntry[], projectRoot: string): BackingBatchResult;
41
+ export declare function classifyBackingBatch(entries: readonly RedLineEntry[], projectRoot: string, peaksLoopRoot?: string): BackingBatchResult;
@@ -5,9 +5,19 @@
5
5
  * handle the post-classification nuance: heuristics for the "partial" tier
6
6
  * (a gate exists but the LLM can bypass it) and verification that the
7
7
  * enforcer file actually exists on disk.
8
+ *
9
+ * Slice 4.0.7-dogfood-PR-1 (ice-cola surface probe 2026-08-02): the
10
+ * `enforcerRef` paths in the catalog are written relative to the
11
+ * peaks-loop source root (every `enforcerRef` is `src/services/audit/...`
12
+ * and the catalog itself is at `src/services/audit/red-line-catalog.ts`).
13
+ * Resolving them against the audited `--project` root produces false
14
+ * orphans on every downstream project. Fix: resolve against the
15
+ * peaks-loop install root by default, with an explicit
16
+ * `peaksLoopRoot` override for tests.
8
17
  */
9
- import { existsSync } from 'node:fs';
10
- import { resolve } from 'node:path';
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { dirname, resolve } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
11
21
  const PARTIAL_PHRASES = [
12
22
  'if llm cooperates',
13
23
  'llm-cooperation',
@@ -21,33 +31,82 @@ function detectPartial(context) {
21
31
  const lower = context.toLowerCase();
22
32
  return PARTIAL_PHRASES.some((phrase) => lower.includes(phrase));
23
33
  }
34
+ /**
35
+ * Resolve the peaks-loop source root (where the catalog itself lives).
36
+ * Walks upward from this very file until a `package.json` with
37
+ * `name === 'peaks-loop'` is found. Cached after the first call.
38
+ * Exported so tests can reset the cache between cases.
39
+ */
40
+ let cachedPeaksLoopRoot = null;
41
+ export function resolvePeaksLoopRoot() {
42
+ if (cachedPeaksLoopRoot !== null)
43
+ return cachedPeaksLoopRoot;
44
+ // This file lives at <root>/src/services/audit/backing-detector.ts.
45
+ // Walk up to find peaks-loop's package.json.
46
+ const here = dirname(fileURLToPath(import.meta.url));
47
+ let dir = here;
48
+ for (let i = 0; i < 8; i++) {
49
+ const candidate = resolve(dir, 'package.json');
50
+ if (existsSync(candidate)) {
51
+ try {
52
+ const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
53
+ if (pkg.name === 'peaks-loop') {
54
+ cachedPeaksLoopRoot = dir;
55
+ return dir;
56
+ }
57
+ }
58
+ catch {
59
+ // malformed package.json; keep walking up
60
+ }
61
+ }
62
+ const parent = dirname(dir);
63
+ if (parent === dir)
64
+ break;
65
+ dir = parent;
66
+ }
67
+ // Fallback: assume 4 levels up from this file (catalog + enforcers
68
+ // share the same depth). Keeps the unit test that constructs a fake
69
+ // projectRoot working when no real package.json is in the walk.
70
+ cachedPeaksLoopRoot = resolve(here, '..', '..', '..', '..');
71
+ return cachedPeaksLoopRoot;
72
+ }
73
+ /** Test-only: clear the cached peaks-loop root. */
74
+ export function _resetPeaksLoopRootCache() {
75
+ cachedPeaksLoopRoot = null;
76
+ }
24
77
  /**
25
78
  * Re-classify a single RedLineEntry. Returns a new entry with the
26
79
  * `backing` field updated and `enforcerRef` possibly nulled if the
27
80
  * referenced file does not exist on disk.
81
+ *
82
+ * `projectRoot` is the audited project (kept for callers that still
83
+ * pass it for context, but no longer used to resolve `enforcerRef`).
84
+ * `peaksLoopRoot` is the peaks-loop source root; defaults to
85
+ * `resolvePeaksLoopRoot()`.
28
86
  */
29
- export function classifyBacking(entry, projectRoot) {
87
+ export function classifyBacking(entry, projectRoot, peaksLoopRoot = resolvePeaksLoopRoot()) {
88
+ void projectRoot; // intentionally unused; preserved for API stability
30
89
  if (detectPartial(entry.source.context)) {
31
90
  return {
32
91
  entry: { ...entry, backing: 'partial' },
33
- enforcerExists: entry.enforcerRef !== null && existsSync(resolve(projectRoot, entry.enforcerRef)),
92
+ enforcerExists: entry.enforcerRef !== null && existsSync(resolve(peaksLoopRoot, entry.enforcerRef)),
34
93
  };
35
94
  }
36
95
  if (entry.enforcerRef === null) {
37
96
  return { entry, enforcerExists: false };
38
97
  }
39
- const enforcerPath = resolve(projectRoot, entry.enforcerRef);
98
+ const enforcerPath = resolve(peaksLoopRoot, entry.enforcerRef);
40
99
  const exists = existsSync(enforcerPath);
41
100
  return {
42
101
  entry: { ...entry, backing: exists ? 'cli-backed' : 'prose-only' },
43
102
  enforcerExists: exists,
44
103
  };
45
104
  }
46
- export function classifyBackingBatch(entries, projectRoot) {
105
+ export function classifyBackingBatch(entries, projectRoot, peaksLoopRoot = resolvePeaksLoopRoot()) {
47
106
  const updated = [];
48
107
  const warnings = [];
49
108
  for (const entry of entries) {
50
- const { entry: reclassified, enforcerExists } = classifyBacking(entry, projectRoot);
109
+ const { entry: reclassified, enforcerExists } = classifyBacking(entry, projectRoot, peaksLoopRoot);
51
110
  updated.push(reclassified);
52
111
  if (reclassified.backing === 'cli-backed' && !enforcerExists) {
53
112
  // Defensive: should not happen because classifyBacking downgrades to
@@ -15,6 +15,7 @@
15
15
  import { existsSync, readFileSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
17
  import { RED_LINE_CATALOG } from '../red-line-catalog.js';
18
+ import { resolvePeaksLoopRoot } from '../backing-detector.js';
18
19
  export const CATALOG_STABILITY_GROWTH_CAP = 0.20;
19
20
  export const CATALOG_STABILITY_WINDOW_DAYS = 90;
20
21
  export const RUNTIME_BUDGET_MS = 2000;
@@ -41,11 +42,19 @@ export function lintCatalogStability(input) {
41
42
  return [syntheticHit('rl-audit-catalog-stability-001', 'catalog size has not grown > 20% in the last 90 days', `(growth ${(growth * 100).toFixed(1)}% over 90 days; currentSize=${input.currentSize}, priorSize=${input.sizeNinetyDaysAgo})`)];
42
43
  }
43
44
  export function lintNoOrphanEnforcer(projectRoot) {
45
+ // Slice 4.0.7-dogfood-PR-1: catalog enforcerRef paths are written
46
+ // relative to the peaks-loop source root (catalog + enforcers both
47
+ // live at <root>/src/services/audit/...). Resolving against the
48
+ // audited --project root produces false orphans on every downstream
49
+ // project. Use resolvePeaksLoopRoot() and keep `projectRoot` for
50
+ // test/legacy callers (the parameter is intentionally ignored here).
51
+ void projectRoot;
52
+ const peaksLoopRoot = resolvePeaksLoopRoot();
44
53
  const hits = [];
45
54
  for (const entry of RED_LINE_CATALOG) {
46
55
  if (!entry.enforcerRef)
47
56
  continue;
48
- const absPath = join(projectRoot, entry.enforcerRef);
57
+ const absPath = join(peaksLoopRoot, entry.enforcerRef);
49
58
  if (!existsSync(absPath)) {
50
59
  hits.push(syntheticHit('rl-audit-no-orphan-enforcer-001', 'every enforcerRef points to a real file', `(enforcerRef "${entry.enforcerRef}" for ${entry.id} does not exist on disk)`));
51
60
  }
@@ -123,6 +123,24 @@ function renderContextBlock(probe) {
123
123
  if (probe !== null && probe !== undefined) {
124
124
  const usedPct = (probe.ratio * 100).toFixed(1);
125
125
  const freePct = ((1 - probe.ratio) * 100).toFixed(1);
126
+ // Slice 4.0.7-dogfood-PR-11 (ice-cola surface probe 2026-08-02):
127
+ // when the source is conservative-fallback, the ratio is
128
+ // hard-coded to 0 (no signal available). The pre-rid prompt
129
+ // rendered "0.0% used (100.0% free)" without flagging that
130
+ // this is a fallback, so the sub-agent thought it had 100%
131
+ // free and never re-checked. The fix short-circuits to the
132
+ // "no probe available" message when the source is not one of
133
+ // the trusted values emitted by peaks-loop's own readers.
134
+ const KNOWN_SOURCES = new Set(['token-counted', 'transcript-estimate', 'statusline']);
135
+ if (!KNOWN_SOURCES.has(probe.source)) {
136
+ return `## Context window (no probe available)
137
+
138
+ The orchestrator captured a probe with source: \`${probe.source}\` (ratio ${usedPct}%) but the dispatch layer does not trust this source. The pre-rid dispatch prompt rendered this as "0.0% used (100.0% free)" without flagging the fallback, which caused sub-agents to think they had 100% free and never re-check. **Treat the displayed ratio as unverified.**
139
+
140
+ If you need to evaluate context pressure, run \`peaks code context-now --project <root>\` and trust its \`ratio\` field. Do not estimate from message length.
141
+
142
+ `;
143
+ }
126
144
  const action = probe.ratio >= 0.95
127
145
  ? 'RED-LINE — call `peaks compact auto --execute` immediately.'
128
146
  : probe.ratio >= 0.85
@@ -34,6 +34,20 @@ export declare const TEST_TOOL_DETECTION_BLOCK = "## Test Tool Detection (mandat
34
34
  * (e.g. projectRoot for context) without churning every call site.
35
35
  */
36
36
  export declare function formatTestToolDetection(): string;
37
+ /**
38
+ * Slice 4.0.7-dogfood-PR-9: optional monorepo block prepended when
39
+ * `<projectRoot>/pnpm-workspace.yaml` exists. The pre-rid static
40
+ * template did not mention monorepos at all, so sub-agents on a
41
+ * monorepo project (e.g. ice-cola, 4 packages) ran `pnpm test` which
42
+ * fanned out to every sub-package — a 36-minute wall clock for what
43
+ * was a single-slice change. The block tells the sub-agent to scope
44
+ * to a single sub-package via `pnpm -r --filter @scope/name test`
45
+ * and to detect the layout via `peaks test --json` first.
46
+ *
47
+ * The block is empty when the project is not a monorepo, so the
48
+ * single-package case is byte-identical to the pre-rid prompt.
49
+ */
50
+ export declare const MONOREPO_TEST_TOOL_BLOCK = "## Monorepo Test Tool Detection (only when pnpm-workspace.yaml exists)\n\nWhen `<projectRoot>/pnpm-workspace.yaml` is present, the project is a pnpm monorepo. The root `package.json#scripts.test` is typically `pnpm -r test` (recursive across all packages) and `pnpm test` from the root will fan out to every sub-package. **Do not run that.** Scope to a single sub-package:\n\n- detect the active sub-package by looking for `packages/<name>/package.json` (or via `pnpm -r --filter ${YOUR_PACKAGE_NAME} test`)\n- run `pnpm --filter @<scope>/<name> test <pattern>` for one sub-package only\n- if a top-level test script is genuinely needed across multiple packages, prefix with `PEAKS_FULL_TEST=1` AND justify in the final report\n\nA repo without `pnpm-workspace.yaml` has no monorepo block; the pre-rid static template applies unchanged.";
37
51
  /**
38
52
  * Slice 2026-07-29-dispatch-stall-governance / S5 (AC-4.1 / AC-4.2) —
39
53
  * pure scope classifier. Returns a typed result so callers (the
@@ -54,6 +54,28 @@ If unsure which framework the consumer project uses, run \`peaks test --json\` f
54
54
  export function formatTestToolDetection() {
55
55
  return TEST_TOOL_DETECTION_BLOCK;
56
56
  }
57
+ /**
58
+ * Slice 4.0.7-dogfood-PR-9: optional monorepo block prepended when
59
+ * `<projectRoot>/pnpm-workspace.yaml` exists. The pre-rid static
60
+ * template did not mention monorepos at all, so sub-agents on a
61
+ * monorepo project (e.g. ice-cola, 4 packages) ran `pnpm test` which
62
+ * fanned out to every sub-package — a 36-minute wall clock for what
63
+ * was a single-slice change. The block tells the sub-agent to scope
64
+ * to a single sub-package via `pnpm -r --filter @scope/name test`
65
+ * and to detect the layout via `peaks test --json` first.
66
+ *
67
+ * The block is empty when the project is not a monorepo, so the
68
+ * single-package case is byte-identical to the pre-rid prompt.
69
+ */
70
+ export const MONOREPO_TEST_TOOL_BLOCK = `## Monorepo Test Tool Detection (only when pnpm-workspace.yaml exists)
71
+
72
+ When \`<projectRoot>/pnpm-workspace.yaml\` is present, the project is a pnpm monorepo. The root \`package.json#scripts.test\` is typically \`pnpm -r test\` (recursive across all packages) and \`pnpm test\` from the root will fan out to every sub-package. **Do not run that.** Scope to a single sub-package:
73
+
74
+ - detect the active sub-package by looking for \`packages/<name>/package.json\` (or via \`pnpm -r --filter \${YOUR_PACKAGE_NAME} test\`)
75
+ - run \`pnpm --filter @<scope>/<name> test <pattern>\` for one sub-package only
76
+ - if a top-level test script is genuinely needed across multiple packages, prefix with \`PEAKS_FULL_TEST=1\` AND justify in the final report
77
+
78
+ A repo without \`pnpm-workspace.yaml\` has no monorepo block; the pre-rid static template applies unchanged.`;
57
79
  export function classifyTestCommand(command, env = process.env) {
58
80
  if (typeof command !== 'string') {
59
81
  return {
@@ -44,6 +44,46 @@ async function readPackageJsonDeps(projectRoot) {
44
44
  return { exists: true, deps: {} };
45
45
  }
46
46
  }
47
+ /**
48
+ * Slice 4.0.7-dogfood-PR-8: walk packages/[name]/package.json one
49
+ * level deep and merge each sub-package's deps into a single map
50
+ * (later sub-packages win on key collision; this is the conservative
51
+ * "first detected framework" behavior). Returns empty when the
52
+ * project is not a monorepo or has no packages dir. Deliberately
53
+ * does NOT recurse into nested workspaces.
54
+ */
55
+ async function readMonorepoSubPackageDeps(projectRoot) {
56
+ const packagesDir = join(projectRoot, 'packages');
57
+ if (!(await pathExists(packagesDir)))
58
+ return {};
59
+ let entries;
60
+ try {
61
+ entries = await readdir(packagesDir);
62
+ }
63
+ catch {
64
+ return {};
65
+ }
66
+ const merged = {};
67
+ for (const entry of entries) {
68
+ if (entry === '.' || entry === '..')
69
+ continue;
70
+ const subPkgPath = join(packagesDir, entry, 'package.json');
71
+ if (!(await pathExists(subPkgPath)))
72
+ continue;
73
+ try {
74
+ const raw = await readText(subPkgPath);
75
+ const parsed = JSON.parse(raw);
76
+ Object.assign(merged, parsed.dependencies ?? {});
77
+ Object.assign(merged, parsed.devDependencies ?? {});
78
+ Object.assign(merged, parsed.peerDependencies ?? {});
79
+ Object.assign(merged, parsed.optionalDependencies ?? {});
80
+ }
81
+ catch {
82
+ // skip malformed sub-package; keep walking
83
+ }
84
+ }
85
+ return merged;
86
+ }
47
87
  async function detectBackendFrameworks(deps) {
48
88
  return BACKEND_DEP_NAMES.filter((name) => name !== 'next' && Object.prototype.hasOwnProperty.call(deps, name));
49
89
  }
@@ -226,8 +266,18 @@ function decideFrontendOnly(report) {
226
266
  export async function scanArchetype(options) {
227
267
  const { projectRoot } = options;
228
268
  const { exists: hasPackageJson, deps } = await readPackageJsonDeps(projectRoot);
229
- const backendFrameworks = await detectBackendFrameworks(deps);
230
- const hasNext = Object.prototype.hasOwnProperty.call(deps, 'next');
269
+ // Slice 4.0.7-dogfood-PR-8 (ice-cola surface probe 2026-08-02): a
270
+ // monorepo root often carries only a thin workspace manifest (no
271
+ // framework deps). Ice-cola root has peaks-loop only, and the
272
+ // @nestjs/core dep lives in packages[server].package.json. Pre-rid
273
+ // this reported `hasBackendFramework: false` for a NestJS monorepo
274
+ // because the dep scan only looked at the root. The fix adds a
275
+ // 1-level-deep walk into the packages dir and merges each
276
+ // sub-package's deps into the dedup result.
277
+ const monorepoSubDeps = await readMonorepoSubPackageDeps(projectRoot);
278
+ const mergedDeps = { ...deps, ...monorepoSubDeps };
279
+ const backendFrameworks = await detectBackendFrameworks(mergedDeps);
280
+ const hasNext = Object.prototype.hasOwnProperty.call(mergedDeps, 'next');
231
281
  const hasNextApiRoutes = await detectNextApiRoutes(projectRoot, hasNext);
232
282
  const backendDirsPresent = await detectBackendDirs(projectRoot);
233
283
  const swaggerPaths = await detectSwagger(projectRoot);
@@ -1,4 +1,4 @@
1
- export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding, type SessionInfo, type SessionMeta } from './session-manager.js';
1
+ export { ensureSession, getSessionId, getSessionIdCanonical, resolveActiveSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding, type SessionInfo, type SessionMeta } from './session-manager.js';
2
2
  export { getSessionDir } from './getSessionDir.js';
3
3
  export { resolveCallerId, type ResolveCallerIdOptions } from './resolve-caller-id.js';
4
4
  export { getCallerBindingFile, getActiveSkillFileForCaller, synthesiseLegacyCallerId, getCallerBinding, setCallerBinding, listCallerBindings } from './caller-binding-service.js';
@@ -1,4 +1,4 @@
1
- export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding } from './session-manager.js';
1
+ export { ensureSession, getSessionId, getSessionIdCanonical, resolveActiveSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding } from './session-manager.js';
2
2
  export { getSessionDir } from './getSessionDir.js';
3
3
  // Slice 020 — caller-keyed session binding. The new canonical path.
4
4
  export { resolveCallerId } from './resolve-caller-id.js';