peaks-cli 1.3.2 → 1.3.4

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 (115) hide show
  1. package/README.md +6 -2
  2. package/dist/src/cli/commands/core-artifact-commands.js +6 -3
  3. package/dist/src/cli/commands/gate-commands.js +28 -19
  4. package/dist/src/cli/commands/hook-handle.d.ts +17 -0
  5. package/dist/src/cli/commands/hook-handle.js +111 -0
  6. package/dist/src/cli/commands/hooks-commands.js +72 -21
  7. package/dist/src/cli/commands/progress-commands.js +9 -2
  8. package/dist/src/cli/commands/progress-start-spawn.js +30 -4
  9. package/dist/src/cli/commands/project-commands.js +8 -4
  10. package/dist/src/cli/commands/statusline-commands.js +75 -17
  11. package/dist/src/cli/commands/sub-agent-commands.d.ts +5 -0
  12. package/dist/src/cli/commands/sub-agent-commands.js +488 -0
  13. package/dist/src/cli/commands/sub-agent-dispatch-guard.d.ts +55 -0
  14. package/dist/src/cli/commands/sub-agent-dispatch-guard.js +57 -0
  15. package/dist/src/cli/commands/workflow-commands.js +2 -1
  16. package/dist/src/cli/commands/workspace-commands.js +3 -0
  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/config/config-types.d.ts +1 -1
  21. package/dist/src/services/context/artifact-meta.d.ts +72 -0
  22. package/dist/src/services/context/artifact-meta.js +105 -0
  23. package/dist/src/services/context/context-guard.d.ts +49 -0
  24. package/dist/src/services/context/context-guard.js +91 -0
  25. package/dist/src/services/context/dispatch-context-guard.d.ts +27 -0
  26. package/dist/src/services/context/dispatch-context-guard.js +192 -0
  27. package/dist/src/services/context/headroom-client.d.ts +34 -0
  28. package/dist/src/services/context/headroom-client.js +117 -0
  29. package/dist/src/services/context/shared-channel.d.ts +92 -0
  30. package/dist/src/services/context/shared-channel.js +285 -0
  31. package/dist/src/services/context/threshold.d.ts +35 -0
  32. package/dist/src/services/context/threshold.js +76 -0
  33. package/dist/src/services/dashboard/project-dashboard-service.d.ts +23 -0
  34. package/dist/src/services/dashboard/project-dashboard-service.js +21 -0
  35. package/dist/src/services/dispatch/batch-counter.d.ts +27 -0
  36. package/dist/src/services/dispatch/batch-counter.js +85 -0
  37. package/dist/src/services/dispatch/dispatch-record-writer.d.ts +93 -0
  38. package/dist/src/services/dispatch/dispatch-record-writer.js +261 -0
  39. package/dist/src/services/dispatch/heartbeat-truncator.d.ts +26 -0
  40. package/dist/src/services/dispatch/heartbeat-truncator.js +13 -0
  41. package/dist/src/services/dispatch/leak-detector.d.ts +11 -0
  42. package/dist/src/services/dispatch/leak-detector.js +72 -0
  43. package/dist/src/services/dispatch/sub-agent-dispatcher.d.ts +127 -0
  44. package/dist/src/services/dispatch/sub-agent-dispatcher.js +98 -0
  45. package/dist/src/services/ide/adapters/claude-code-adapter.d.ts +18 -0
  46. package/dist/src/services/ide/adapters/claude-code-adapter.js +80 -0
  47. package/dist/src/services/ide/adapters/trae-adapter.d.ts +42 -0
  48. package/dist/src/services/ide/adapters/trae-adapter.js +98 -0
  49. package/dist/src/services/ide/hook-protocol.d.ts +47 -0
  50. package/dist/src/services/ide/hook-protocol.js +74 -0
  51. package/dist/src/services/ide/hook-translator.d.ts +72 -0
  52. package/dist/src/services/ide/hook-translator.js +128 -0
  53. package/dist/src/services/ide/ide-detector.d.ts +10 -0
  54. package/dist/src/services/ide/ide-detector.js +19 -0
  55. package/dist/src/services/ide/ide-registry.d.ts +14 -0
  56. package/dist/src/services/ide/ide-registry.js +45 -0
  57. package/dist/src/services/ide/ide-types.d.ts +180 -0
  58. package/dist/src/services/ide/ide-types.js +2 -0
  59. package/dist/src/services/ide/resource-profile.d.ts +52 -0
  60. package/dist/src/services/ide/resource-profile.js +33 -0
  61. package/dist/src/services/ide/shared/atomic-json.d.ts +15 -0
  62. package/dist/src/services/ide/shared/atomic-json.js +58 -0
  63. package/dist/src/services/ide/shared/safe-path.d.ts +11 -0
  64. package/dist/src/services/ide/shared/safe-path.js +29 -0
  65. package/dist/src/services/memory/project-context-service.js +2 -1
  66. package/dist/src/services/memory/project-memory-service.js +4 -3
  67. package/dist/src/services/perf/perf-baseline-service.js +2 -1
  68. package/dist/src/services/progress/progress-service.d.ts +1 -1
  69. package/dist/src/services/progress/progress-service.js +18 -14
  70. package/dist/src/services/security/safe-settings-path.d.ts +12 -0
  71. package/dist/src/services/security/safe-settings-path.js +104 -0
  72. package/dist/src/services/session/getSessionDir.d.ts +1 -0
  73. package/dist/src/services/session/getSessionDir.js +27 -0
  74. package/dist/src/services/session/index.d.ts +1 -0
  75. package/dist/src/services/session/index.js +1 -0
  76. package/dist/src/services/signal/cancel-handler.d.ts +14 -0
  77. package/dist/src/services/signal/cancel-handler.js +76 -0
  78. package/dist/src/services/skill/resume-detector.d.ts +54 -0
  79. package/dist/src/services/skill/resume-detector.js +334 -0
  80. package/dist/src/services/skill/skill-scheduler.d.ts +40 -0
  81. package/dist/src/services/skill/skill-scheduler.js +53 -0
  82. package/dist/src/services/skills/hooks-settings-service.d.ts +47 -29
  83. package/dist/src/services/skills/hooks-settings-service.js +190 -144
  84. package/dist/src/services/skills/statusline-settings-service.d.ts +33 -6
  85. package/dist/src/services/skills/statusline-settings-service.js +31 -34
  86. package/dist/src/services/slice/slice-archive-service.d.ts +20 -0
  87. package/dist/src/services/slice/slice-archive-service.js +111 -0
  88. package/dist/src/services/solo/batch-heartbeat-poller.d.ts +51 -0
  89. package/dist/src/services/solo/batch-heartbeat-poller.js +88 -0
  90. package/dist/src/services/solo/status-line-renderer.d.ts +34 -0
  91. package/dist/src/services/solo/status-line-renderer.js +55 -0
  92. package/dist/src/services/standards/ide-aware-standards-service.d.ts +94 -0
  93. package/dist/src/services/standards/ide-aware-standards-service.js +89 -0
  94. package/dist/src/services/standards/project-standards-service.d.ts +1 -2
  95. package/dist/src/services/workspace/reconcile-service.d.ts +36 -0
  96. package/dist/src/services/workspace/reconcile-service.js +107 -6
  97. package/dist/src/services/workspace/reconcile-types.d.ts +12 -0
  98. package/dist/src/shared/version.d.ts +1 -1
  99. package/dist/src/shared/version.js +1 -1
  100. package/package.json +2 -1
  101. package/scripts/install-skills.mjs +112 -2
  102. package/skills/peaks-ide/SKILL.md +159 -0
  103. package/skills/peaks-ide/references/audit-log-helper.md +52 -0
  104. package/skills/peaks-qa/SKILL.md +153 -55
  105. package/skills/peaks-qa/references/qa-fanout-contract.md +150 -0
  106. package/skills/peaks-rd/SKILL.md +134 -62
  107. package/skills/peaks-solo/SKILL.md +124 -37
  108. package/skills/peaks-solo/references/browser-workflow.md +22 -20
  109. package/skills/peaks-solo/references/context-governance.md +144 -0
  110. package/skills/peaks-solo/references/headroom-integration.md +107 -0
  111. package/skills/peaks-solo/references/runbook.md +3 -3
  112. package/skills/peaks-solo/references/sub-agent-dispatch.md +261 -0
  113. package/skills/peaks-solo/references/swarm-dispatch-contract.md +3 -37
  114. package/skills/peaks-txt/SKILL.md +17 -0
  115. package/skills/peaks-ui/SKILL.md +45 -10
@@ -0,0 +1,285 @@
1
+ /**
2
+ * G8 — cross sub-agent shared channel (RL-23..RL-26, AC-47..AC-49).
3
+ *
4
+ * Dispatcher-mediated indirect signal: sub-agent A writes a shared entry,
5
+ * the dispatcher stores it in a per-batch JSON file, sub-agent B (still
6
+ * in flight) reads it. A and B never directly talk. This is the
7
+ * pseudo-swarm property 3 upgrade; it is NOT peer-to-peer messaging.
8
+ *
9
+ * Path convention (G8.3):
10
+ * `.peaks/_sub_agents/<sid>/shared/<rid>-<batchId>.json`
11
+ *
12
+ * The file is atomic-write (tmp + rename). Last-write-wins by key. Value
13
+ * size limit: ≤ 1KB soft warn, ≥ 64KB hard reject. File size cap: 1MB
14
+ * with LRU eviction.
15
+ *
16
+ * See: `.peaks/memory/sub-agent-shared-channel-cross-completion.md` for
17
+ * the full G8 rule.
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
20
+ import { dirname } from 'node:path';
21
+ import { assertSafeSharedChannelPath, sharedChannelPath } from './dispatch-context-guard.js';
22
+ export const SHARED_CHANNEL_MAX_VALUE_BYTES = 64 * 1024; // 64KB hard reject
23
+ export const SHARED_CHANNEL_SOFT_VALUE_WARN = 1024; // 1KB soft warning
24
+ export const SHARED_CHANNEL_MAX_FILE_BYTES = 1024 * 1024; // 1MB LRU cap
25
+ export const SHARED_CHANNEL_TTL_DAYS = 30; // 30-day TTL on orphan channels
26
+ /**
27
+ * Write a shared entry to the per-batch channel file. Atomic-write
28
+ * (tmp + rename). Returns the new entry + channel size + last-write-wins
29
+ * flag (true if the key already existed; the new value overwrites).
30
+ *
31
+ * RL-25 size limit: `value ≥ 64KB` is hard-rejected with `VALUE_TOO_LARGE`.
32
+ * `value > 1KB and < 64KB` is a soft warning (returned in the result).
33
+ */
34
+ export function writeSharedEntry(opts) {
35
+ if (typeof opts.key !== 'string' || opts.key.length === 0) {
36
+ return { ok: false, code: 'INVALID_BATCH_ID', message: 'key must be non-empty' };
37
+ }
38
+ if (typeof opts.from !== 'string' || opts.from.length === 0) {
39
+ return { ok: false, code: 'INVALID_BATCH_ID', message: 'from must be non-empty' };
40
+ }
41
+ if (opts.value === null || typeof opts.value !== 'object' || Array.isArray(opts.value)) {
42
+ return {
43
+ ok: false,
44
+ code: 'INVALID_BATCH_ID',
45
+ message: 'value must be a JSON object (not array, not primitive)'
46
+ };
47
+ }
48
+ const valueSize = Buffer.byteLength(JSON.stringify(opts.value), 'utf8');
49
+ if (valueSize >= SHARED_CHANNEL_MAX_VALUE_BYTES) {
50
+ return {
51
+ ok: false,
52
+ code: 'VALUE_TOO_LARGE',
53
+ message: `value size ${valueSize} bytes exceeds limit ${SHARED_CHANNEL_MAX_VALUE_BYTES} bytes (RL-25)`
54
+ };
55
+ }
56
+ const softWarning = valueSize > SHARED_CHANNEL_SOFT_VALUE_WARN;
57
+ const channelFile = sharedChannelPath(opts.projectRoot, opts.sid, opts.rid, opts.batchId);
58
+ assertSafeSharedChannelPath(channelFile, opts.projectRoot);
59
+ let channel;
60
+ try {
61
+ channel = readChannelOrEmpty(channelFile, opts.batchId);
62
+ }
63
+ catch (err) {
64
+ return {
65
+ ok: false,
66
+ code: 'WRITE_ERROR',
67
+ message: `failed to read existing channel: ${err.message}`
68
+ };
69
+ }
70
+ const lastWriteWins = Object.prototype.hasOwnProperty.call(channel.entries, opts.key);
71
+ // LRU eviction: if writing would push the file over the 1MB cap,
72
+ // evict oldest entries until the new write fits.
73
+ const entry = {
74
+ at: new Date().toISOString(),
75
+ from: opts.from,
76
+ key: opts.key,
77
+ value: Object.freeze({ ...opts.value }),
78
+ valueSize
79
+ };
80
+ const projectedChannel = {
81
+ ...channel,
82
+ updatedAt: entry.at,
83
+ entries: { ...channel.entries, [opts.key]: entry }
84
+ };
85
+ const projectedSize = JSON.stringify(projectedChannel).length;
86
+ let lruEvicted = 0;
87
+ if (projectedSize > SHARED_CHANNEL_MAX_FILE_BYTES) {
88
+ const sortedKeys = Object.keys(projectedChannel.entries).sort((a, b) => {
89
+ const ea = projectedChannel.entries[a];
90
+ const eb = projectedChannel.entries[b];
91
+ if (!ea || !eb)
92
+ return 0;
93
+ return ea.at.localeCompare(eb.at);
94
+ });
95
+ let working = projectedChannel;
96
+ while (JSON.stringify(working).length > SHARED_CHANNEL_MAX_FILE_BYTES &&
97
+ sortedKeys.length > 0) {
98
+ const oldestKey = sortedKeys.shift();
99
+ if (oldestKey === opts.key) {
100
+ // Don't evict the entry we just wrote.
101
+ break;
102
+ }
103
+ const nextEntries = {};
104
+ for (const [k, v] of Object.entries(working.entries)) {
105
+ if (k !== oldestKey) {
106
+ nextEntries[k] = v;
107
+ }
108
+ }
109
+ working = { ...working, entries: nextEntries };
110
+ lruEvicted += 1;
111
+ }
112
+ try {
113
+ writeAtomic(channelFile, working);
114
+ }
115
+ catch (err) {
116
+ return {
117
+ ok: false,
118
+ code: 'WRITE_ERROR',
119
+ message: `failed to write channel after LRU eviction: ${err.message}`
120
+ };
121
+ }
122
+ return {
123
+ ok: true,
124
+ entry,
125
+ channelSize: JSON.stringify(working).length,
126
+ lastWriteWins,
127
+ softWarning
128
+ };
129
+ }
130
+ try {
131
+ writeAtomic(channelFile, projectedChannel);
132
+ }
133
+ catch (err) {
134
+ return {
135
+ ok: false,
136
+ code: 'WRITE_ERROR',
137
+ message: `failed to write channel: ${err.message}`
138
+ };
139
+ }
140
+ // lruEvicted is 0 here; expose the count through the size of the
141
+ // returned channel for caller diagnostics.
142
+ void lruEvicted;
143
+ return {
144
+ ok: true,
145
+ entry,
146
+ channelSize: projectedSize,
147
+ lastWriteWins,
148
+ softWarning
149
+ };
150
+ }
151
+ /**
152
+ * Read the shared channel for a batch. Returns the channel with all
153
+ * matching entries. Filters: `--since` (ISO8601) and `--key` (glob
154
+ * pattern; simple `*` wildcard, no regex).
155
+ *
156
+ * If the channel file does not exist, returns an empty channel
157
+ * (this is the dispatcher's "fresh batch" view).
158
+ */
159
+ export function readSharedChannel(opts) {
160
+ const channelFile = sharedChannelPath(opts.projectRoot, opts.sid, opts.rid, opts.batchId);
161
+ assertSafeSharedChannelPath(channelFile, opts.projectRoot);
162
+ const channel = readChannelOrEmpty(channelFile, opts.batchId);
163
+ const since = opts.since;
164
+ const pattern = opts.keyPattern;
165
+ if (!since && !pattern) {
166
+ return channel;
167
+ }
168
+ const filteredEntries = {};
169
+ const matcher = pattern ? compileKeyPattern(pattern) : null;
170
+ for (const [k, v] of Object.entries(channel.entries)) {
171
+ if (since && v.at < since)
172
+ continue;
173
+ if (matcher && !matcher(k))
174
+ continue;
175
+ filteredEntries[k] = v;
176
+ }
177
+ return { ...channel, entries: filteredEntries };
178
+ }
179
+ /**
180
+ * Garbage-collect a single channel. Returns true if the file was
181
+ * deleted, false if it did not exist.
182
+ */
183
+ export function gcChannel(opts) {
184
+ const channelFile = sharedChannelPath(opts.projectRoot, opts.sid, opts.rid, opts.batchId);
185
+ if (!existsSync(channelFile)) {
186
+ return false;
187
+ }
188
+ // Best-effort delete; R-2 guard not strictly needed (we built the path)
189
+ // but kept for safety.
190
+ assertSafeSharedChannelPath(channelFile, opts.projectRoot);
191
+ try {
192
+ const { unlinkSync } = require('node:fs');
193
+ unlinkSync(channelFile);
194
+ return true;
195
+ }
196
+ catch {
197
+ return false;
198
+ }
199
+ }
200
+ /**
201
+ * Check if a channel file is older than `SHARED_CHANNEL_TTL_DAYS` days
202
+ * and should be GC'd as an orphan. Returns true if file is missing
203
+ * (already GC'd) or older than TTL.
204
+ */
205
+ export function isOrphanChannel(opts) {
206
+ const channelFile = sharedChannelPath(opts.projectRoot, opts.sid, opts.rid, opts.batchId);
207
+ if (!existsSync(channelFile)) {
208
+ return true;
209
+ }
210
+ const stat = require('node:fs');
211
+ const s = stat.statSync(channelFile);
212
+ const now = opts.now ?? new Date();
213
+ const ageMs = now.getTime() - s.mtimeMs;
214
+ const ttlMs = SHARED_CHANNEL_TTL_DAYS * 24 * 60 * 60 * 1000;
215
+ return ageMs > ttlMs;
216
+ }
217
+ // ─── internals ───────────────────────────────────────────────────────
218
+ function readChannelOrEmpty(channelFile, batchId) {
219
+ if (!existsSync(channelFile)) {
220
+ const now = new Date().toISOString();
221
+ return { batchId, createdAt: now, updatedAt: now, entries: {} };
222
+ }
223
+ let parsed;
224
+ try {
225
+ parsed = JSON.parse(readFileSync(channelFile, 'utf8'));
226
+ }
227
+ catch {
228
+ const now = new Date().toISOString();
229
+ return { batchId, createdAt: now, updatedAt: now, entries: {} };
230
+ }
231
+ if (!isObject(parsed)) {
232
+ const now = new Date().toISOString();
233
+ return { batchId, createdAt: now, updatedAt: now, entries: {} };
234
+ }
235
+ const obj = parsed;
236
+ const batchIdField = typeof obj.batchId === 'string' ? obj.batchId : batchId;
237
+ const createdAt = typeof obj.createdAt === 'string' ? obj.createdAt : new Date().toISOString();
238
+ const updatedAt = typeof obj.updatedAt === 'string' ? obj.updatedAt : createdAt;
239
+ const entriesField = isObject(obj.entries) ? obj.entries : {};
240
+ const entries = {};
241
+ for (const [k, v] of Object.entries(entriesField)) {
242
+ if (isValidEntry(v)) {
243
+ entries[k] = v;
244
+ }
245
+ }
246
+ return { batchId: batchIdField, createdAt, updatedAt, entries };
247
+ }
248
+ function isValidEntry(v) {
249
+ if (!isObject(v))
250
+ return false;
251
+ return (typeof v.at === 'string' &&
252
+ typeof v.from === 'string' &&
253
+ typeof v.key === 'string' &&
254
+ isObject(v.value) &&
255
+ typeof v.valueSize === 'number');
256
+ }
257
+ function isObject(v) {
258
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
259
+ }
260
+ function writeAtomic(path, channel) {
261
+ const dir = dirname(path);
262
+ mkdirSync(dir, { recursive: true });
263
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
264
+ writeFileSync(tmp, JSON.stringify(channel, null, 2) + '\n', 'utf8');
265
+ renameSync(tmp, path);
266
+ }
267
+ /**
268
+ * Compile a simple key pattern with `*` wildcards to a matcher. Only
269
+ * `*` is special; everything else is a literal. `*` matches zero or
270
+ * more characters. Examples:
271
+ * "rd.*" matches "rd.completed", "rd.found-blocker"
272
+ * "*.completed" matches "rd.completed", "qa.completed"
273
+ * "*" matches everything
274
+ */
275
+ export function compileKeyPattern(pattern) {
276
+ if (pattern === '*') {
277
+ return () => true;
278
+ }
279
+ const escaped = pattern
280
+ .split('*')
281
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
282
+ .join('.*');
283
+ const re = new RegExp(`^${escaped}$`);
284
+ return (key) => re.test(key);
285
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * G9.3 — forced compression gate threshold constants + tier evaluation.
3
+ *
4
+ * 256K default context capacity is a conservative proxy. The LLM's real
5
+ * capacity is its private business (R-1 / R-8 / R-10 / R-13 boundary
6
+ * inherited from slice #009); we use prompt size as the gate signal.
7
+ *
8
+ * See: `.peaks/memory/sub-agent-headroom-forced-compression-gate.md`
9
+ * for the full G9 rule (RL-27..RL-32, AC-50..AC-65).
10
+ */
11
+ export declare const CONTEXT_CAPACITY_DEFAULT_BYTES: number;
12
+ export declare const THRESHOLD_SOFT_WARN_RATIO = 0.5;
13
+ export declare const THRESHOLD_NEAR_LIMIT_RATIO = 0.75;
14
+ export declare const THRESHOLD_HARD_REJECT_RATIO = 0.8;
15
+ export declare const THRESHOLD_EMERGENCY_RATIO = 0.9;
16
+ export type ThresholdTier = 'ok' | 'soft-warn' | 'near-limit' | 'hard-reject' | 'emergency';
17
+ export interface ThresholdEvaluation {
18
+ readonly tier: ThresholdTier;
19
+ readonly ratio: number;
20
+ readonly bytesUsed: number;
21
+ readonly capacityBytes: number;
22
+ readonly warnings: readonly string[];
23
+ }
24
+ /**
25
+ * Compute the threshold tier for a given prompt size. Pure function
26
+ * (no IO, no side effects). The `capacityBytes` parameter lets callers
27
+ * override the default 256K proxy (e.g. for testing or for a future
28
+ * per-IDE capacity override).
29
+ */
30
+ export declare function evaluateThresholdTier(promptSize: number, capacityBytes?: number): ThresholdEvaluation;
31
+ /**
32
+ * Convert a threshold tier to a machine-readable code suitable for the
33
+ * CLI envelope's `code` field. Used by both the CLI and the hook layer.
34
+ */
35
+ export declare function tierToCode(tier: ThresholdTier): string;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * G9.3 — forced compression gate threshold constants + tier evaluation.
3
+ *
4
+ * 256K default context capacity is a conservative proxy. The LLM's real
5
+ * capacity is its private business (R-1 / R-8 / R-10 / R-13 boundary
6
+ * inherited from slice #009); we use prompt size as the gate signal.
7
+ *
8
+ * See: `.peaks/memory/sub-agent-headroom-forced-compression-gate.md`
9
+ * for the full G9 rule (RL-27..RL-32, AC-50..AC-65).
10
+ */
11
+ export const CONTEXT_CAPACITY_DEFAULT_BYTES = 256 * 1024; // 256K
12
+ export const THRESHOLD_SOFT_WARN_RATIO = 0.5; // 50%
13
+ export const THRESHOLD_NEAR_LIMIT_RATIO = 0.75; // 75% — user red line
14
+ export const THRESHOLD_HARD_REJECT_RATIO = 0.80; // 80%
15
+ export const THRESHOLD_EMERGENCY_RATIO = 0.90; // 90%
16
+ /**
17
+ * Compute the threshold tier for a given prompt size. Pure function
18
+ * (no IO, no side effects). The `capacityBytes` parameter lets callers
19
+ * override the default 256K proxy (e.g. for testing or for a future
20
+ * per-IDE capacity override).
21
+ */
22
+ export function evaluateThresholdTier(promptSize, capacityBytes = CONTEXT_CAPACITY_DEFAULT_BYTES) {
23
+ if (!Number.isFinite(promptSize) || promptSize < 0) {
24
+ throw new Error(`evaluateThresholdTier: promptSize must be ≥ 0 (got ${promptSize})`);
25
+ }
26
+ if (!Number.isFinite(capacityBytes) || capacityBytes <= 0) {
27
+ throw new Error(`evaluateThresholdTier: capacityBytes must be > 0 (got ${capacityBytes})`);
28
+ }
29
+ const ratio = promptSize / capacityBytes;
30
+ const warnings = [];
31
+ let tier;
32
+ if (ratio >= THRESHOLD_EMERGENCY_RATIO) {
33
+ tier = 'emergency';
34
+ warnings.push('PROMPT_EMERGENCY');
35
+ }
36
+ else if (ratio >= THRESHOLD_HARD_REJECT_RATIO) {
37
+ tier = 'hard-reject';
38
+ warnings.push('PROMPT_TOO_LARGE');
39
+ }
40
+ else if (ratio >= THRESHOLD_NEAR_LIMIT_RATIO) {
41
+ tier = 'near-limit';
42
+ warnings.push('CONTEXT_NEAR_LIMIT');
43
+ }
44
+ else if (ratio >= THRESHOLD_SOFT_WARN_RATIO) {
45
+ tier = 'soft-warn';
46
+ warnings.push('CONTEXT_SOFT_WARN');
47
+ }
48
+ else {
49
+ tier = 'ok';
50
+ }
51
+ return {
52
+ tier,
53
+ ratio,
54
+ bytesUsed: promptSize,
55
+ capacityBytes,
56
+ warnings
57
+ };
58
+ }
59
+ /**
60
+ * Convert a threshold tier to a machine-readable code suitable for the
61
+ * CLI envelope's `code` field. Used by both the CLI and the hook layer.
62
+ */
63
+ export function tierToCode(tier) {
64
+ switch (tier) {
65
+ case 'ok':
66
+ return 'OK';
67
+ case 'soft-warn':
68
+ return 'CONTEXT_SOFT_WARN';
69
+ case 'near-limit':
70
+ return 'CONTEXT_NEAR_LIMIT';
71
+ case 'hard-reject':
72
+ return 'PROMPT_TOO_LARGE';
73
+ case 'emergency':
74
+ return 'PROMPT_EMERGENCY';
75
+ }
76
+ }
@@ -27,6 +27,26 @@ export type ProjectDashboardDoctor = {
27
27
  ok: boolean;
28
28
  passed: number;
29
29
  failed: number;
30
+ okCount?: number;
31
+ failCount?: number;
32
+ lastRunAt?: string;
33
+ checkIds?: string[];
34
+ };
35
+ export type DashboardOkPolicy = 'workspace-only' | 'strict';
36
+ /**
37
+ * Resolves the user-facing `ok` field. `workspace-only` (default) returns true
38
+ * when the runbook / workspace layout is healthy, even if 1-2 non-blocking
39
+ * doctor checks fail. `strict` returns false when the doctor aggregate fails.
40
+ * The CLI default is `workspace-only`; `peaks project dashboard --strict`
41
+ * restores the legacy aggregate semantics.
42
+ */
43
+ export declare function resolveDashboardOk(args: {
44
+ okPolicy: DashboardOkPolicy;
45
+ doctor: ProjectDashboardDoctor;
46
+ runbookHealth: ProjectDashboardRunbookHealth;
47
+ }): {
48
+ ok: boolean;
49
+ okPolicy: DashboardOkPolicy;
30
50
  };
31
51
  export type ProjectDashboardRunbookHealth = {
32
52
  ok: boolean;
@@ -51,6 +71,8 @@ export type ProjectDashboardSkillPresence = {
51
71
  export type ProjectDashboard = {
52
72
  generatedAt: string;
53
73
  projectRoot: string;
74
+ ok: boolean;
75
+ okPolicy: DashboardOkPolicy;
54
76
  requests: ProjectDashboardRequests;
55
77
  openspec: ProjectDashboardOpenSpec;
56
78
  understand: ProjectDashboardUnderstand;
@@ -71,5 +93,6 @@ export type LoadProjectDashboardOptions = {
71
93
  };
72
94
  runbookHealth?: ProjectDashboardRunbookHealth;
73
95
  skillPresence?: SkillPresence | null;
96
+ okPolicy?: DashboardOkPolicy;
74
97
  };
75
98
  export declare function loadProjectDashboard(options: LoadProjectDashboardOptions): Promise<ProjectDashboard>;
@@ -6,6 +6,19 @@ import { seedCapabilityItems } from '../recommendations/capability-seed-items.js
6
6
  import { requiredSkillNames } from '../../shared/paths.js';
7
7
  import { getSkillPresence } from '../skills/skill-presence-service.js';
8
8
  const SKILL_PRESENCE_FRESHNESS_THRESHOLD_MS = 24 * 60 * 60 * 1000;
9
+ /**
10
+ * Resolves the user-facing `ok` field. `workspace-only` (default) returns true
11
+ * when the runbook / workspace layout is healthy, even if 1-2 non-blocking
12
+ * doctor checks fail. `strict` returns false when the doctor aggregate fails.
13
+ * The CLI default is `workspace-only`; `peaks project dashboard --strict`
14
+ * restores the legacy aggregate semantics.
15
+ */
16
+ export function resolveDashboardOk(args) {
17
+ if (args.okPolicy === 'strict') {
18
+ return { ok: args.doctor.ok && args.runbookHealth.ok, okPolicy: 'strict' };
19
+ }
20
+ return { ok: args.runbookHealth.ok, okPolicy: 'workspace-only' };
21
+ }
9
22
  function defaultClock() {
10
23
  return new Date().toISOString();
11
24
  }
@@ -95,6 +108,7 @@ function buildSkillPresenceSummary(presence, projectRoot) {
95
108
  export async function loadProjectDashboard(options) {
96
109
  const clock = options.clock ?? defaultClock;
97
110
  const sampleSize = options.sampleCapabilities ?? 8;
111
+ const okPolicy = options.okPolicy ?? 'workspace-only';
98
112
  const [items, openspecReport, mcpReport, understandReport, doctorAndRunbook] = await Promise.all([
99
113
  listRequestArtifacts({ projectRoot: options.projectRoot }),
100
114
  scanOpenSpec({ openspecRoot: `${options.projectRoot}/openspec` }),
@@ -102,9 +116,16 @@ export async function loadProjectDashboard(options) {
102
116
  scanUnderstandAnything({ projectRoot: options.projectRoot }),
103
117
  loadDoctorAndRunbookHealth(options.doctorReport, options.runbookHealth)
104
118
  ]);
119
+ const okVerdict = resolveDashboardOk({
120
+ okPolicy,
121
+ doctor: doctorAndRunbook.doctor,
122
+ runbookHealth: doctorAndRunbook.runbookHealth
123
+ });
105
124
  return {
106
125
  generatedAt: clock(),
107
126
  projectRoot: options.projectRoot,
127
+ ok: okVerdict.ok,
128
+ okPolicy: okVerdict.okPolicy,
108
129
  requests: {
109
130
  count: items.length,
110
131
  byRole: groupRequestsByRole(items),
@@ -0,0 +1,27 @@
1
+ export declare const BATCH_LIMIT = 6;
2
+ export declare const BATCH_OVER_LIMIT_CODE = "BATCH_OVER_LIMIT";
3
+ export type BatchCounterWarning = {
4
+ readonly code: typeof BATCH_OVER_LIMIT_CODE;
5
+ readonly batchId: string;
6
+ readonly dispatched: number;
7
+ readonly limit: number;
8
+ readonly message: string;
9
+ };
10
+ /** Build the per-batch counter file path. */
11
+ export declare function batchCounterPath(projectRoot: string, sid: string, batchId: string): string;
12
+ /** A single batch counter record. */
13
+ export interface BatchCounterRecord {
14
+ readonly batchId: string;
15
+ readonly sessionId: string;
16
+ readonly createdAt: string;
17
+ readonly count: number;
18
+ }
19
+ /** Read the current counter; returns 0 if no file yet. */
20
+ export declare function readBatchCount(projectRoot: string, sid: string, batchId: string): number;
21
+ /** Increment the counter and return the new value (with any warning). */
22
+ export declare function noteDispatched(projectRoot: string, sid: string, batchId: string, now?: () => Date): {
23
+ count: number;
24
+ warning: BatchCounterWarning | null;
25
+ };
26
+ /** Reset a batch counter (called by the reducer when starting a new batch). */
27
+ export declare function resetBatch(projectRoot: string, sid: string, batchId: string): void;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * G5.1 / RL-1 — batch size counter.
3
+ *
4
+ * Empirical upper bound for one Dispatcher × one batch: 6 sub-agents.
5
+ * Above 6, the LLM / human is encouraged to split into multiple
6
+ * batches with an explicit reducer step in between. peaks-solo's
7
+ * swarm phase dispatches 3; peaks-rd's 4-way fan-out dispatches 4;
8
+ * peaks-qa's 3-way fan-out dispatches 3. The 6 limit leaves headroom
9
+ * for "qa-business-api" / "qa-business-frontend" / "qa-business-regression"
10
+ * subdivisions (3-way + 3-way = 6) without crossing the line.
11
+ *
12
+ * The counter is in-memory per process. The Dispatcher is expected
13
+ * to call `noteDispatched` once per `peaks sub-agent dispatch` and
14
+ * reset between batches. The CLI also persists a small per-sid
15
+ * counter file so that sub-agent spawns invoked across multiple
16
+ * `peaks sub-agent dispatch` processes within the same batch can be
17
+ * tallied (the batch id ties them together).
18
+ *
19
+ * `BATCH_OVER_LIMIT` is a warning, not a hard fail. The user has been
20
+ * explicit: "RL-1 is empirical; if you have a real reason to go to 7,
21
+ * that's your call". A warning is the right surface — let the LLM /
22
+ * human read the reason and decide.
23
+ */
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { dirname, resolve } from 'node:path';
26
+ export const BATCH_LIMIT = 6;
27
+ export const BATCH_OVER_LIMIT_CODE = 'BATCH_OVER_LIMIT';
28
+ /** Build the per-batch counter file path. */
29
+ export function batchCounterPath(projectRoot, sid, batchId) {
30
+ return resolve(projectRoot, '.peaks', '_sub_agents', sid, `batch-${batchId}.counter.json`);
31
+ }
32
+ /** Read the current counter; returns 0 if no file yet. */
33
+ export function readBatchCount(projectRoot, sid, batchId) {
34
+ const path = batchCounterPath(projectRoot, sid, batchId);
35
+ if (!existsSync(path))
36
+ return 0;
37
+ try {
38
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
39
+ return typeof parsed.count === 'number' && parsed.count >= 0 ? parsed.count : 0;
40
+ }
41
+ catch {
42
+ return 0;
43
+ }
44
+ }
45
+ /** Increment the counter and return the new value (with any warning). */
46
+ export function noteDispatched(projectRoot, sid, batchId, now = () => new Date()) {
47
+ const path = batchCounterPath(projectRoot, sid, batchId);
48
+ mkdirSync(dirname(path), { recursive: true });
49
+ const previous = readBatchCount(projectRoot, sid, batchId);
50
+ const next = {
51
+ batchId,
52
+ sessionId: sid,
53
+ createdAt: now().toISOString(),
54
+ count: previous + 1
55
+ };
56
+ writeFileSync(path, JSON.stringify(next, null, 2) + '\n', 'utf8');
57
+ if (next.count > BATCH_LIMIT) {
58
+ return {
59
+ count: next.count,
60
+ warning: {
61
+ code: BATCH_OVER_LIMIT_CODE,
62
+ batchId,
63
+ dispatched: next.count,
64
+ limit: BATCH_LIMIT,
65
+ message: `per RL-1, batch size 6 is empirical upper bound; you have ` +
66
+ `${next.count}. If you need more, split into multiple batches ` +
67
+ `with an explicit reducer step between them.`
68
+ }
69
+ };
70
+ }
71
+ return { count: next.count, warning: null };
72
+ }
73
+ /** Reset a batch counter (called by the reducer when starting a new batch). */
74
+ export function resetBatch(projectRoot, sid, batchId) {
75
+ const path = batchCounterPath(projectRoot, sid, batchId);
76
+ if (existsSync(path)) {
77
+ try {
78
+ const { unlinkSync } = require('node:fs');
79
+ unlinkSync(path);
80
+ }
81
+ catch {
82
+ /* best-effort; counter file is informational */
83
+ }
84
+ }
85
+ }