specpi 0.10.0

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 (57) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/LICENSE +21 -0
  3. package/NPM_RELEASE.md +110 -0
  4. package/README.md +155 -0
  5. package/SECURITY.md +85 -0
  6. package/SECURITY_MODEL.md +107 -0
  7. package/THIRD_PARTY.md +61 -0
  8. package/browser-runtime/package-lock.json +86 -0
  9. package/browser-runtime/package.json +15 -0
  10. package/extensions/browser/core.mjs +306 -0
  11. package/extensions/browser/index.ts +723 -0
  12. package/extensions/browser/smoke.mjs +47 -0
  13. package/extensions/command-guard/bash.mjs +1426 -0
  14. package/extensions/command-guard/cmd.mjs +369 -0
  15. package/extensions/command-guard/core.mjs +506 -0
  16. package/extensions/command-guard/index.ts +634 -0
  17. package/extensions/command-guard/managed-files.mjs +22 -0
  18. package/extensions/command-guard/paths.mjs +398 -0
  19. package/extensions/command-guard/powershell-parser.ps1 +47 -0
  20. package/extensions/command-guard/powershell.mjs +655 -0
  21. package/extensions/command-guard/redact.mjs +65 -0
  22. package/extensions/command-guard/rules.mjs +2557 -0
  23. package/extensions/command-guard/smoke.mjs +422 -0
  24. package/extensions/files/core.mjs +422 -0
  25. package/extensions/files/index.ts +678 -0
  26. package/extensions/spec/core.mjs +47 -0
  27. package/extensions/spec.ts +457 -0
  28. package/extensions/tool-wishlist/capabilities.json +114 -0
  29. package/extensions/tool-wishlist/core.mjs +1525 -0
  30. package/extensions/tool-wishlist/index.ts +804 -0
  31. package/extensions/tool-wishlist/registry.mjs +99 -0
  32. package/extensions/tool-wishlist/validators.mjs +345 -0
  33. package/extensions/ui-refresh/index.ts +54 -0
  34. package/extensions/workflow-controls/challenge.mjs +196 -0
  35. package/extensions/workflow-controls/experiments.mjs +628 -0
  36. package/extensions/workflow-controls/index.ts +1144 -0
  37. package/extensions/workflow-controls/scope.mjs +272 -0
  38. package/extensions/workflow-controls/smoke.mjs +201 -0
  39. package/package.json +98 -0
  40. package/scripts/check-package.mjs +483 -0
  41. package/scripts/check-pi-package.mjs +223 -0
  42. package/scripts/check-release-order.mjs +97 -0
  43. package/scripts/lib.mjs +182 -0
  44. package/scripts/lock.mjs +122 -0
  45. package/scripts/specpi.mjs +2037 -0
  46. package/scripts/verify-artifact.mjs +21 -0
  47. package/shell/pi-profiles.sh +14 -0
  48. package/site/logo.svg +9 -0
  49. package/site/self-improvement-loop-v2.svg +108 -0
  50. package/skills/donsetch/SKILL.md +76 -0
  51. package/skills/specpi-improve/SKILL.md +54 -0
  52. package/specpi +4 -0
  53. package/specpi.cmd +4 -0
  54. package/templates/AGENTS.md +23 -0
  55. package/templates/settings.json +10 -0
  56. package/themes/specpi-spec.json +96 -0
  57. package/themes/tea-house.json +89 -0
@@ -0,0 +1,1525 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
4
+ import { fileURLToPath } from "node:url";
5
+ import { normalizeCapability, validateCapabilityRegistry } from "./registry.mjs";
6
+
7
+ export { normalizeCapability } from "./registry.mjs";
8
+
9
+ const MAX_EVENT_FILE_BYTES = 5 * 1024 * 1024;
10
+ const MAX_DECISION_FILE_BYTES = 1024 * 1024;
11
+ const IMPACT_WEIGHT = { minor: 1, degraded: 2, blocked: 4 };
12
+ const REOPEN_EVIDENCE_LIMIT = 5;
13
+ const JOURNAL_EVIDENCE_LIMIT = 8;
14
+ const JOURNAL_GATE_LIMIT = 8;
15
+ const JOURNAL_CHANGED_FILE_LIMIT = 40;
16
+ const JOURNAL_HISTORY_LIMIT = 20;
17
+ const GATE_PATTERN = /^(?=.*[A-Za-z0-9])[A-Za-z0-9 ._-]{1,60}$/;
18
+ const VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,39}$/;
19
+ const COLLECTION_MODES = new Set(["on", "off"]);
20
+ const STATE_ACTIONS = new Set(["select", "decline", "retire", "reopen"]);
21
+ const DECISION_ACTIONS = new Set([...STATE_ACTIONS, "merge", "unmerge"]);
22
+ export const WISHLIST_FILENAMES = {
23
+ events: "tool-wishlist-events.jsonl",
24
+ decisions: "tool-wishlist-decisions.jsonl",
25
+ config: "tool-wishlist-config.json",
26
+ report: "TOOL_WISHLIST.md",
27
+ salt: ".tool-wishlist-salt",
28
+ lock: ".tool-wishlist.lock",
29
+ archives: "tool-wishlist-archives",
30
+ archiveTransaction: ".tool-wishlist-archive-transaction.json",
31
+ };
32
+
33
+ function compactText(value, maxLength) {
34
+ return String(value ?? "")
35
+ .normalize("NFKC")
36
+ .replace(/[\u0000-\u001f\u007f]+/g, " ")
37
+ .replace(/\s+/g, " ")
38
+ .trim()
39
+ .slice(0, maxLength);
40
+ }
41
+
42
+ function readCapabilityRegistry() {
43
+ const file = fileURLToPath(new URL("./capabilities.json", import.meta.url));
44
+ const registry = validateCapabilityRegistry(JSON.parse(fs.readFileSync(file, "utf8")));
45
+
46
+ return Object.freeze({
47
+ schema: 1,
48
+ capabilities: Object.freeze(
49
+ registry.capabilities.map((item) =>
50
+ Object.freeze({
51
+ ...item,
52
+ aliases: Object.freeze([...item.aliases]),
53
+ validations: Object.freeze([...item.validations]),
54
+ }),
55
+ ),
56
+ ),
57
+ });
58
+ }
59
+
60
+ export const CAPABILITY_REGISTRY = readCapabilityRegistry();
61
+
62
+ function registryAliasMap() {
63
+ const aliases = new Map();
64
+ for (const item of CAPABILITY_REGISTRY.capabilities) {
65
+ for (const alias of item.aliases) {
66
+ aliases.set(alias, item.id);
67
+ }
68
+ }
69
+
70
+ return aliases;
71
+ }
72
+
73
+ function registryCapability(value) {
74
+ const key = normalizeCapability(value);
75
+ const aliases = registryAliasMap();
76
+ const canonical = aliases.get(key) ?? key;
77
+
78
+ return CAPABILITY_REGISTRY.capabilities.find((item) => item.id === canonical);
79
+ }
80
+
81
+ export function isImplementedCapability(value) {
82
+ return Boolean(registryCapability(value));
83
+ }
84
+
85
+ function pathsFor(stateDir) {
86
+ return Object.fromEntries(
87
+ Object.entries(WISHLIST_FILENAMES).map(([key, file]) => [key, path.join(stateDir, file)]),
88
+ );
89
+ }
90
+
91
+ function fileHash(file) {
92
+ return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
93
+ }
94
+
95
+ function recoverArchiveTransaction(stateDir) {
96
+ const files = pathsFor(stateDir);
97
+ if (!fs.existsSync(files.archiveTransaction)) {
98
+ return;
99
+ }
100
+
101
+ assertNotSymlink(files.archiveTransaction);
102
+ const transaction = JSON.parse(fs.readFileSync(files.archiveTransaction, "utf8"));
103
+ if (transaction?.schema !== 1 || typeof transaction.archiveDir !== "string" || !Array.isArray(transaction.files)) {
104
+ throw new Error("Invalid wishlist archive transaction; active state was not changed");
105
+ }
106
+
107
+ for (const item of transaction.files) {
108
+ const archived = path.join(transaction.archiveDir, item.name);
109
+ if (!fs.existsSync(archived) || fileHash(archived) !== item.sha256) {
110
+ throw new Error(
111
+ "Wishlist archive transaction cannot be recovered because its prepared snapshot is incomplete",
112
+ );
113
+ }
114
+ }
115
+
116
+ atomicWrite(files.events, "", 0o600);
117
+ atomicWrite(files.decisions, "", 0o600);
118
+ writeReport(files.report, [], [], 0, 0, transaction.timestamp);
119
+ fs.rmSync(files.archiveTransaction, { force: true });
120
+ }
121
+
122
+ function assertNotSymlink(file) {
123
+ try {
124
+ if (fs.lstatSync(file).isSymbolicLink()) {
125
+ throw new Error(`Refusing to use symlinked wishlist state: ${file}`);
126
+ }
127
+ } catch (error) {
128
+ if (error.code !== "ENOENT") {
129
+ throw error;
130
+ }
131
+ }
132
+ }
133
+
134
+ function atomicWrite(file, content, mode = 0o600) {
135
+ assertNotSymlink(file);
136
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
137
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
138
+ try {
139
+ fs.writeFileSync(temporary, content, { mode });
140
+ fs.chmodSync(temporary, mode);
141
+ fs.renameSync(temporary, file);
142
+ } finally {
143
+ fs.rmSync(temporary, { force: true });
144
+ }
145
+ }
146
+
147
+ function delay(ms, signal) {
148
+ return new Promise((resolve, reject) => {
149
+ if (signal?.aborted) {
150
+ reject(signal.reason ?? new Error("Cancelled"));
151
+
152
+ return;
153
+ }
154
+
155
+ const onAbort = () => {
156
+ clearTimeout(timer);
157
+ reject(signal.reason ?? new Error("Cancelled"));
158
+ };
159
+
160
+ const timer = setTimeout(() => {
161
+ signal?.removeEventListener("abort", onAbort);
162
+ resolve();
163
+ }, ms);
164
+ signal?.addEventListener("abort", onAbort, { once: true });
165
+ });
166
+ }
167
+
168
+ function removeEmptyLockDirectory(lock) {
169
+ try {
170
+ fs.rmdirSync(lock);
171
+ } catch (error) {
172
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) {
173
+ throw error;
174
+ }
175
+ }
176
+ }
177
+
178
+ function releaseOwnedLock(lock, marker) {
179
+ try {
180
+ fs.unlinkSync(marker);
181
+ } catch (error) {
182
+ if (error.code === "ENOENT") {
183
+ return;
184
+ }
185
+
186
+ throw error;
187
+ }
188
+
189
+ removeEmptyLockDirectory(lock);
190
+ }
191
+
192
+ async function withStateLock(stateDir, signal, operation) {
193
+ const { lock } = pathsFor(stateDir);
194
+ const token = `${process.pid}-${randomUUID()}`;
195
+ const marker = path.join(lock, token);
196
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
197
+ for (let attempt = 0; attempt < 50; attempt += 1) {
198
+ try {
199
+ fs.mkdirSync(lock, { mode: 0o700 });
200
+ } catch (error) {
201
+ if (error.code !== "EEXIST") {
202
+ throw error;
203
+ }
204
+
205
+ await delay(20 + attempt * 5, signal);
206
+ continue;
207
+ }
208
+
209
+ let markerWritten = false;
210
+ try {
211
+ fs.writeFileSync(marker, "owned\n", { flag: "wx", mode: 0o600 });
212
+ markerWritten = true;
213
+ recoverArchiveTransaction(stateDir);
214
+
215
+ return await operation();
216
+ } finally {
217
+ if (markerWritten) {
218
+ releaseOwnedLock(lock, marker);
219
+ } else {
220
+ removeEmptyLockDirectory(lock);
221
+ }
222
+ }
223
+ }
224
+
225
+ throw new Error(
226
+ `Timed out waiting for ${lock}. Remove it only after confirming no Pi session is updating the wishlist.`,
227
+ );
228
+ }
229
+
230
+ function isStoredEvent(event) {
231
+ return (
232
+ event?.schema === 1 &&
233
+ typeof event.timestamp === "string" &&
234
+ Number.isFinite(Date.parse(event.timestamp)) &&
235
+ typeof event.canonicalKey === "string" &&
236
+ (event.observedKey === undefined || typeof event.observedKey === "string") &&
237
+ typeof event.sessionHash === "string" &&
238
+ typeof event.runHash === "string" &&
239
+ typeof event.projectHash === "string" &&
240
+ typeof event.capability === "string" &&
241
+ typeof event.scenario === "string" &&
242
+ typeof event.limitation === "string" &&
243
+ Object.hasOwn(IMPACT_WEIGHT, event.impact) &&
244
+ typeof event.workaround === "string" &&
245
+ ["tool", "skill", "prompt", "config", "bug", "unknown"].includes(event.suggestedFix) &&
246
+ (event.regression === undefined || typeof event.regression === "boolean")
247
+ );
248
+ }
249
+
250
+ export function isValidChangedFilePath(value) {
251
+ return (
252
+ typeof value === "string" &&
253
+ value.length >= 1 &&
254
+ value.length <= 200 &&
255
+ !value.startsWith("/") &&
256
+ /^[A-Za-z0-9._/-]{1,200}$/.test(value) &&
257
+ !value.includes("..")
258
+ );
259
+ }
260
+
261
+ export function collectChangedFilePaths(entries) {
262
+ const paths = new Set();
263
+ for (const line of String(entries ?? "").split("\n")) {
264
+ // `git status --porcelain` emits "XY <path>": the path starts at column 4 of
265
+ // the raw line. Never trim the leading status column before slicing.
266
+ const raw = line.trimEnd();
267
+ if (raw.length < 4) {
268
+ continue;
269
+ }
270
+
271
+ const staged = raw.slice(3);
272
+ const renamed = staged.includes(" -> ") ? staged.split(" -> ").pop() : staged;
273
+ if (isValidChangedFilePath(renamed)) {
274
+ paths.add(renamed);
275
+ }
276
+ }
277
+
278
+ return [...paths].sort();
279
+ }
280
+
281
+ function isValidEvidenceList(value, maxItems) {
282
+ return (
283
+ Array.isArray(value) &&
284
+ value.length >= 1 &&
285
+ value.length <= maxItems &&
286
+ value.every((item) => typeof item === "string" && item.length >= 1 && item.length <= 240)
287
+ );
288
+ }
289
+
290
+ function isValidJournal(journal) {
291
+ return (
292
+ typeof journal === "object" &&
293
+ journal !== null &&
294
+ journal.schema === 1 &&
295
+ isValidEvidenceList(journal.evidence, JOURNAL_EVIDENCE_LIMIT) &&
296
+ Array.isArray(journal.gates) &&
297
+ journal.gates.length >= 1 &&
298
+ journal.gates.length <= JOURNAL_GATE_LIMIT &&
299
+ journal.gates.every((gate) => typeof gate === "string" && GATE_PATTERN.test(gate)) &&
300
+ (journal.changedFiles === undefined ||
301
+ (Array.isArray(journal.changedFiles) &&
302
+ journal.changedFiles.length <= JOURNAL_CHANGED_FILE_LIMIT &&
303
+ journal.changedFiles.every(isValidChangedFilePath))) &&
304
+ (journal.changedFilesTruncated === undefined || typeof journal.changedFilesTruncated === "boolean") &&
305
+ typeof journal.version === "string" &&
306
+ VERSION_PATTERN.test(journal.version)
307
+ );
308
+ }
309
+
310
+ function isStoredDecision(event) {
311
+ return (
312
+ event?.schema === 1 &&
313
+ typeof event.id === "string" &&
314
+ typeof event.timestamp === "string" &&
315
+ Number.isFinite(Date.parse(event.timestamp)) &&
316
+ DECISION_ACTIONS.has(event.action) &&
317
+ typeof event.canonicalKey === "string" &&
318
+ typeof event.targetKey === "string" &&
319
+ typeof event.reverses === "string" &&
320
+ typeof event.note === "string" &&
321
+ (!Object.hasOwn(event, "evidence") ||
322
+ (event.action === "reopen" && isValidEvidenceList(event.evidence, REOPEN_EVIDENCE_LIMIT))) &&
323
+ (!Object.hasOwn(event, "journal") || (event.action === "retire" && isValidJournal(event.journal)))
324
+ );
325
+ }
326
+
327
+ function readJsonLines(file, validator) {
328
+ if (!fs.existsSync(file)) {
329
+ return { entries: [], invalidLines: 0, bytes: 0 };
330
+ }
331
+
332
+ assertNotSymlink(file);
333
+ const stat = fs.statSync(file);
334
+ const entries = [];
335
+ let invalidLines = 0;
336
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
337
+ if (!line.trim()) {
338
+ continue;
339
+ }
340
+
341
+ try {
342
+ const entry = JSON.parse(line);
343
+ if (validator(entry)) {
344
+ entries.push(entry);
345
+ } else {
346
+ invalidLines += 1;
347
+ }
348
+ } catch {
349
+ invalidLines += 1;
350
+ }
351
+ }
352
+
353
+ return { entries, invalidLines, bytes: stat.size };
354
+ }
355
+
356
+ export function readEventsFile(file) {
357
+ const parsed = readJsonLines(file, isStoredEvent);
358
+
359
+ return { events: parsed.entries, invalidLines: parsed.invalidLines, bytes: parsed.bytes };
360
+ }
361
+
362
+ export function readDecisionsFile(file) {
363
+ const parsed = readJsonLines(file, isStoredDecision);
364
+
365
+ return { decisions: parsed.entries, invalidLines: parsed.invalidLines, bytes: parsed.bytes };
366
+ }
367
+
368
+ function getSalt(file) {
369
+ assertNotSymlink(file);
370
+ if (fs.existsSync(file)) {
371
+ const salt = fs.readFileSync(file, "utf8").trim();
372
+ if (salt) {
373
+ return salt;
374
+ }
375
+ }
376
+
377
+ const salt = randomBytes(32).toString("hex");
378
+ atomicWrite(file, `${salt}\n`, 0o600);
379
+
380
+ return salt;
381
+ }
382
+
383
+ function privateHash(salt, value) {
384
+ return createHash("sha256").update(salt).update("\0").update(String(value)).digest("hex").slice(0, 20);
385
+ }
386
+
387
+ function sanitizeReportText(value, maxLength) {
388
+ const sanitized = compactText(value, maxLength * 2)
389
+ .replace(/https?:\/\/\S+/gi, "[url omitted]")
390
+ .replace(/\b(?:authorization\s*:\s*)?bearer\s+\S+/gi, "[credential omitted]")
391
+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\b/g, "[credential omitted]")
392
+ .replace(/\b(?:sk|ghp|github_pat|xox[baprs]|AKIA)[-_A-Za-z0-9]{8,}\b/g, "[credential omitted]")
393
+ .replace(/\b(?:api[_ -]?key|access[_ -]?token|password|secret)\s*[:=]\s*\S+/gi, "[credential omitted]")
394
+ .replace(
395
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/gi,
396
+ "[credential omitted]",
397
+ )
398
+ .replace(/(^|[\s([{'\"])(?:~\/|\.{0,2}\/|\/[A-Za-z0-9._-])\S*/g, "$1[path omitted]")
399
+ .replace(
400
+ /\b(?:[A-Za-z0-9._-]+\/)+(?:[A-Za-z0-9._-]+\.(?:c|cc|cpp|css|go|h|hpp|html|java|js|json|jsx|md|mjs|py|rb|rs|sh|sql|ts|tsx|yaml|yml))\b/gi,
401
+ "[path omitted]",
402
+ )
403
+ .replace(/\b[A-Za-z]:\\\S+/g, "[path omitted]")
404
+ .replace(/\s+/g, " ")
405
+ .trim()
406
+ .slice(0, maxLength);
407
+ const forbidden = [
408
+ /\b(?:authorization\s*:\s*)?bearer\s+\S+/i,
409
+ /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\b/,
410
+ /\b(?:sk|ghp|github_pat|xox[baprs]|AKIA)[-_A-Za-z0-9]{8,}\b/,
411
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/i,
412
+ ];
413
+ if (forbidden.some((pattern) => pattern.test(sanitized))) {
414
+ throw new Error("Capability-gap report still appears to contain sensitive data after redaction");
415
+ }
416
+
417
+ return sanitized;
418
+ }
419
+
420
+ export function sanitizeWishlistText(value, maxLength = 240) {
421
+ return sanitizeReportText(value, maxLength);
422
+ }
423
+
424
+ function sanitizeGap(gap) {
425
+ const impact = Object.hasOwn(IMPACT_WEIGHT, gap.impact) ? gap.impact : "degraded";
426
+ const allowedFixes = new Set(["tool", "skill", "prompt", "config", "bug", "unknown"]);
427
+
428
+ return {
429
+ capability: sanitizeReportText(gap.capability, 120),
430
+ scenario: sanitizeReportText(gap.scenario, 300),
431
+ limitation: sanitizeReportText(gap.limitation, 300),
432
+ impact,
433
+ workaround: sanitizeReportText(gap.workaround, 240),
434
+ suggestedFix: allowedFixes.has(gap.suggestedFix) ? gap.suggestedFix : "unknown",
435
+ };
436
+ }
437
+
438
+ function sanitizeEvidenceList(value, maxItems, label) {
439
+ if (!isValidEvidenceList(value, maxItems)) {
440
+ throw new Error(`${label} must contain 1 to ${maxItems} strings of at most 240 characters`);
441
+ }
442
+
443
+ const sanitized = value.map((item) => sanitizeReportText(item, 240));
444
+ if (!isValidEvidenceList(sanitized, maxItems)) {
445
+ throw new Error(`${label} is invalid after sanitization: no item may redact to empty`);
446
+ }
447
+
448
+ return sanitized;
449
+ }
450
+
451
+ function sanitizeJournal(journal) {
452
+ if (typeof journal !== "object" || journal === null || journal.schema !== 1) {
453
+ throw new Error("Retirement journal schema must be 1");
454
+ }
455
+
456
+ if (!isValidJournal(journal)) {
457
+ throw new Error(
458
+ "Retirement journal is invalid: evidence (1-8 items of at most 240 chars), gates (1-8 non-empty safe labels), changed files (at most 40 repo-relative paths), and a safe version label (at most 40 chars) are bounded",
459
+ );
460
+ }
461
+
462
+ const sanitized = {
463
+ schema: 1,
464
+ evidence: journal.evidence.map((item) => sanitizeReportText(item, 240)),
465
+ gates: journal.gates.map((gate) => compactText(gate, 60)),
466
+ ...(journal.changedFiles === undefined ? {} : { changedFiles: [...journal.changedFiles] }),
467
+ changedFilesTruncated: journal.changedFilesTruncated === true,
468
+ version: sanitizeReportText(journal.version, 40),
469
+ };
470
+ // Redaction can empty a value; the sanitized journal must survive its own
471
+ // reader validation or the whole decision would silently turn invalid.
472
+ if (!isValidJournal(sanitized)) {
473
+ throw new Error(
474
+ "Retirement journal is invalid after sanitization: evidence, gates, and version must remain non-empty safe values",
475
+ );
476
+ }
477
+
478
+ return sanitized;
479
+ }
480
+
481
+ function resolveAlias(key, aliases) {
482
+ let current = normalizeCapability(key);
483
+ const seen = new Set();
484
+ while (aliases.has(current) && !seen.has(current)) {
485
+ seen.add(current);
486
+ current = aliases.get(current);
487
+ }
488
+
489
+ return current;
490
+ }
491
+
492
+ export function buildAliasMap(decisions = []) {
493
+ const aliases = registryAliasMap();
494
+ const reversed = new Set(decisions.filter((item) => item.action === "unmerge").map((item) => item.reverses));
495
+ for (const decision of decisions) {
496
+ if (decision.action === "merge" && !reversed.has(decision.id)) {
497
+ aliases.set(decision.canonicalKey, decision.targetKey);
498
+ }
499
+ }
500
+
501
+ for (const [key, target] of [...aliases]) {
502
+ aliases.set(key, resolveAlias(target, aliases));
503
+ }
504
+
505
+ return aliases;
506
+ }
507
+
508
+ function canonicalizeEvents(events, decisions) {
509
+ const aliases = buildAliasMap(decisions);
510
+
511
+ return events.map((event) => ({
512
+ ...event,
513
+ canonicalKey: resolveAlias(event.observedKey ?? event.canonicalKey, aliases),
514
+ }));
515
+ }
516
+
517
+ function lifecycleStates(decisions) {
518
+ const aliases = buildAliasMap(decisions);
519
+ const states = new Map();
520
+ for (const item of CAPABILITY_REGISTRY.capabilities) {
521
+ states.set(item.id, "retired");
522
+ }
523
+
524
+ for (const decision of decisions) {
525
+ if (!STATE_ACTIONS.has(decision.action)) {
526
+ continue;
527
+ }
528
+
529
+ const key = resolveAlias(decision.canonicalKey, aliases);
530
+ states.set(
531
+ key,
532
+ decision.action === "select" ? "selected" : decision.action === "reopen" ? "open" : `${decision.action}d`,
533
+ );
534
+ }
535
+
536
+ return states;
537
+ }
538
+
539
+ function timestampMs(value) {
540
+ return Date.parse(value);
541
+ }
542
+
543
+ function uniqueByRun(events) {
544
+ const selected = new Map();
545
+ for (const event of events) {
546
+ const identity = `${event.canonicalKey}\0${event.runHash}`;
547
+ const previous = selected.get(identity);
548
+ if (
549
+ !previous ||
550
+ IMPACT_WEIGHT[event.impact] > IMPACT_WEIGHT[previous.impact] ||
551
+ (IMPACT_WEIGHT[event.impact] === IMPACT_WEIGHT[previous.impact] &&
552
+ timestampMs(event.timestamp) > timestampMs(previous.timestamp))
553
+ ) {
554
+ selected.set(identity, event);
555
+ }
556
+ }
557
+
558
+ return [...selected.values()];
559
+ }
560
+
561
+ function mostCommon(values) {
562
+ const counts = new Map();
563
+ for (const value of values.filter(Boolean)) {
564
+ counts.set(value, (counts.get(value) ?? 0) + 1);
565
+ }
566
+
567
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
568
+ }
569
+
570
+ export function aggregateEvents(events, options = {}) {
571
+ const decisions = options.decisions ?? [];
572
+ const groups = new Map();
573
+ for (const event of uniqueByRun(canonicalizeEvents(events, decisions))) {
574
+ const group = groups.get(event.canonicalKey) ?? [];
575
+ group.push(event);
576
+ groups.set(event.canonicalKey, group);
577
+ }
578
+
579
+ return [...groups.entries()]
580
+ .map(([canonicalKey, items]) => {
581
+ const ordered = [...items].sort((a, b) => timestampMs(a.timestamp) - timestampMs(b.timestamp));
582
+ const highestImpact =
583
+ [...ordered].map((event) => event.impact).sort((a, b) => IMPACT_WEIGHT[b] - IMPACT_WEIGHT[a])[0] ??
584
+ "minor";
585
+ const distinct = (field) => new Set(ordered.map((event) => event[field]).filter(Boolean)).size;
586
+ const recentUnique = (field) => {
587
+ const values = [];
588
+ for (const event of [...ordered].reverse()) {
589
+ const value = event[field];
590
+ if (value && !values.includes(value)) {
591
+ values.push(value);
592
+ }
593
+
594
+ if (values.length === 3) {
595
+ break;
596
+ }
597
+ }
598
+
599
+ return values;
600
+ };
601
+
602
+ const priority = ordered.reduce((total, event) => total + (IMPACT_WEIGHT[event.impact] ?? 1), 0);
603
+
604
+ return {
605
+ canonicalKey,
606
+ title: ordered.at(-1)?.capability ?? canonicalKey,
607
+ occurrences: ordered.length,
608
+ regressionOccurrences: ordered.filter((event) => event.regression).length,
609
+ sessions: distinct("sessionHash"),
610
+ projects: distinct("projectHash"),
611
+ firstSeen: ordered[0]?.timestamp,
612
+ lastSeen: ordered.at(-1)?.timestamp,
613
+ impact: highestImpact,
614
+ suggestedFix: mostCommon(ordered.map((event) => event.suggestedFix)) ?? "unknown",
615
+ priority,
616
+ qualified: priority >= IMPACT_WEIGHT.blocked || ordered.length >= 2,
617
+ scenarios: recentUnique("scenario"),
618
+ limitations: recentUnique("limitation"),
619
+ workarounds: recentUnique("workaround"),
620
+ };
621
+ })
622
+ .sort(
623
+ (a, b) =>
624
+ b.priority - a.priority ||
625
+ b.projects - a.projects ||
626
+ b.sessions - a.sessions ||
627
+ timestampMs(b.lastSeen) - timestampMs(a.lastSeen) ||
628
+ a.canonicalKey.localeCompare(b.canonicalKey),
629
+ );
630
+ }
631
+
632
+ export function latestRetirementDecision(decisions, canonicalKey, aliases = buildAliasMap(decisions)) {
633
+ let latest;
634
+ for (const decision of decisions) {
635
+ if (decision.action !== "retire" || resolveAlias(decision.canonicalKey, aliases) !== canonicalKey) {
636
+ continue;
637
+ }
638
+
639
+ if (latest === undefined || timestampMs(decision.timestamp) > timestampMs(latest.timestamp)) {
640
+ latest = decision;
641
+ }
642
+ }
643
+
644
+ return latest;
645
+ }
646
+
647
+ export function linkReopenToRetirement(decisions, reopenDecision) {
648
+ if (reopenDecision?.action !== "reopen") {
649
+ return undefined;
650
+ }
651
+
652
+ const aliases = buildAliasMap(decisions);
653
+ const key = resolveAlias(reopenDecision.canonicalKey, aliases);
654
+ if (reopenDecision.targetKey) {
655
+ const linked = decisions.find(
656
+ (decision) => decision.id === reopenDecision.targetKey && decision.action === "retire",
657
+ );
658
+ if (linked && resolveAlias(linked.canonicalKey, aliases) === key) {
659
+ return linked;
660
+ }
661
+ }
662
+
663
+ return latestRetirementDecision(decisions, key, aliases);
664
+ }
665
+
666
+ function latestRetirementTime(canonicalKey, decisions) {
667
+ const latest = latestRetirementDecision(decisions, canonicalKey);
668
+
669
+ return latest === undefined ? undefined : timestampMs(latest.timestamp);
670
+ }
671
+
672
+ function resolveReopenRetirementLink(decisions, key, aliases, linkedRetirementId) {
673
+ if (linkedRetirementId === undefined) {
674
+ return latestRetirementDecision(decisions, key, aliases);
675
+ }
676
+
677
+ const linked = decisions.find((decision) => decision.id === linkedRetirementId);
678
+ if (!linked || linked.action !== "retire" || resolveAlias(linked.canonicalKey, aliases) !== key) {
679
+ throw new Error(`Linked retirement ${linkedRetirementId} is not a retire decision for ${key}`);
680
+ }
681
+
682
+ return linked;
683
+ }
684
+
685
+ function reviewSignals(canonicalKey, events, decisions, state) {
686
+ if (state !== "retired") {
687
+ return [];
688
+ }
689
+
690
+ const retirement = latestRetirementTime(canonicalKey, decisions);
691
+ const projected = uniqueByRun(canonicalizeEvents(events, decisions)).filter(
692
+ (event) => event.canonicalKey === canonicalKey,
693
+ );
694
+ if (retirement !== undefined) {
695
+ return projected.filter((event) => timestampMs(event.timestamp) > retirement);
696
+ }
697
+
698
+ const registered = registryCapability(canonicalKey);
699
+
700
+ return registered
701
+ ? projected.filter((event) => timestampMs(event.timestamp) > timestampMs(registered.shippedAt))
702
+ : [];
703
+ }
704
+
705
+ function groupsWithState(events, decisions) {
706
+ const states = lifecycleStates(decisions);
707
+
708
+ return aggregateEvents(events, { decisions }).map((group) => {
709
+ const state = states.get(group.canonicalKey) ?? "open";
710
+ const signals = reviewSignals(group.canonicalKey, events, decisions, state).sort(
711
+ (a, b) => timestampMs(a.timestamp) - timestampMs(b.timestamp),
712
+ );
713
+
714
+ return {
715
+ ...group,
716
+ state,
717
+ reviewNeeded: signals.length > 0,
718
+ reviewSignalCount: signals.length,
719
+ reviewFirstSeen: signals[0]?.timestamp,
720
+ reviewLastSeen: signals.at(-1)?.timestamp,
721
+ };
722
+ });
723
+ }
724
+
725
+ function queueGroups(events, decisions) {
726
+ return groupsWithState(events, decisions).filter((group) => ["open", "selected"].includes(group.state));
727
+ }
728
+
729
+ export function harnessImprovementCandidates(events, decisions = []) {
730
+ const rank = (group) => (group.state === "selected" ? 0 : group.reviewNeeded ? 1 : 2);
731
+
732
+ return groupsWithState(events, decisions)
733
+ .filter(
734
+ (group) => group.state === "selected" || group.reviewNeeded || (group.state === "open" && group.qualified),
735
+ )
736
+ .sort((a, b) => rank(a) - rank(b));
737
+ }
738
+
739
+ export function nextWishlistCandidate(events, decisions = []) {
740
+ const groups = queueGroups(events, decisions).filter((group) => group.qualified);
741
+
742
+ return groups.find((group) => group.state === "selected") ?? groups.find((group) => group.state === "open");
743
+ }
744
+
745
+ function markdownText(value) {
746
+ return compactText(value, 500).replace(/([\\`*_[\]<>#])/g, "\\$1");
747
+ }
748
+
749
+ function day(value) {
750
+ return String(value ?? "").slice(0, 10) || "unknown";
751
+ }
752
+
753
+ function renderGroup(lines, group) {
754
+ lines.push(
755
+ "",
756
+ `## ${markdownText(group.title)}`,
757
+ "",
758
+ `- ID: \`${group.canonicalKey}\``,
759
+ `- Status: ${group.state}`,
760
+ `- Qualified: ${group.qualified ? "yes" : "not yet"}`,
761
+ `- Priority: **${group.priority}**`,
762
+ `- Occurrences: ${group.occurrences}`,
763
+ `- Distinct sessions: ${group.sessions}`,
764
+ `- Distinct projects: ${group.projects}`,
765
+ `- Impact: ${group.impact}`,
766
+ `- Suggested fix: ${group.suggestedFix}`,
767
+ `- First seen: ${day(group.firstSeen)}`,
768
+ `- Last seen: ${day(group.lastSeen)}`,
769
+ );
770
+ if (group.reviewNeeded) {
771
+ lines.push(`- Review needed: yes`, `- Unresolved post-retirement signals: ${group.reviewSignalCount}`);
772
+ }
773
+
774
+ if (group.scenarios.length) {
775
+ lines.push("", "**Observed needs**", ...group.scenarios.map((value) => `- ${markdownText(value)}`));
776
+ }
777
+
778
+ if (group.limitations.length) {
779
+ lines.push(
780
+ "",
781
+ "**Why current capabilities fell short**",
782
+ ...group.limitations.map((value) => `- ${markdownText(value)}`),
783
+ );
784
+ }
785
+
786
+ if (group.workarounds.length) {
787
+ lines.push("", "**Workarounds used**", ...group.workarounds.map((value) => `- ${markdownText(value)}`));
788
+ }
789
+ }
790
+
791
+ export function renderWishlist(events, options = {}) {
792
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
793
+ const decisions = options.decisions ?? [];
794
+ const invalidLines = options.invalidLines ?? 0;
795
+ const invalidDecisionLines = options.invalidDecisionLines ?? 0;
796
+ const groups = groupsWithState(events, decisions);
797
+ const queued = groups.filter((group) => ["open", "selected"].includes(group.state));
798
+ const lines = [
799
+ "# Tool Wishlist",
800
+ "",
801
+ "> Generated by SpecPi from privacy-minimized capability-gap reports and explicit local decisions. Do not edit this file directly.",
802
+ "> Raw observations contain sanitized summaries and salted hashes. No data is uploaded.",
803
+ "",
804
+ `Generated: ${generatedAt}`,
805
+ `Queued gaps: ${queued.length} | Recorded queue occurrences: ${queued.reduce((total, group) => total + group.occurrences, 0)}`,
806
+ "",
807
+ "Ranking uses a deterministic tuple: impact-weighted unique tasks, distinct projects, distinct sessions, recency, then ID.",
808
+ "A gap qualifies after two unique tasks or one blocked-equivalent priority.",
809
+ ];
810
+ if (invalidLines > 0) {
811
+ lines.push(`Warning: ${invalidLines} malformed observation line(s) were ignored.`);
812
+ }
813
+
814
+ if (invalidDecisionLines > 0) {
815
+ lines.push(`Warning: ${invalidDecisionLines} malformed decision line(s) were ignored.`);
816
+ }
817
+
818
+ const sections = [
819
+ ["Needs review", groups.filter((group) => group.reviewNeeded)],
820
+ ["Selected", groups.filter((group) => group.state === "selected")],
821
+ ["Open", groups.filter((group) => group.state === "open")],
822
+ ["Declined", groups.filter((group) => group.state === "declined")],
823
+ ];
824
+ const reversedMerges = new Set(decisions.filter((item) => item.action === "unmerge").map((item) => item.reverses));
825
+ const activeMerges = decisions.filter((item) => item.action === "merge" && !reversedMerges.has(item.id));
826
+ if (groups.length === 0) {
827
+ lines.push("", "No capability gaps have been recorded yet.", "");
828
+
829
+ return lines.join("\n");
830
+ }
831
+
832
+ for (const [title, items] of sections) {
833
+ if (items.length === 0) {
834
+ continue;
835
+ }
836
+
837
+ lines.push("", `# ${title}`);
838
+ for (const group of items) {
839
+ renderGroup(lines, group);
840
+ }
841
+ }
842
+
843
+ if (activeMerges.length > 0) {
844
+ lines.push(
845
+ "",
846
+ "# Active aliases",
847
+ "",
848
+ ...activeMerges.map(
849
+ (item) => `- \`${item.canonicalKey}\` → \`${item.targetKey}\` (merge decision \`${item.id}\`)`,
850
+ ),
851
+ "",
852
+ "Undo with `/wishlist unmerge <merge-decision-id>`.",
853
+ );
854
+ }
855
+
856
+ const retired = groups.filter((group) => group.state === "retired" && !group.reviewNeeded);
857
+ if (retired.length > 0) {
858
+ lines.push(
859
+ "",
860
+ "# Retired",
861
+ "",
862
+ ...retired.map((group) => {
863
+ const retirement = latestRetirementDecision(decisions, group.canonicalKey);
864
+ const proof = retirement?.journal
865
+ ? ` (verified ${day(retirement.timestamp)} via ${retirement.journal.gates.join(", ")})`
866
+ : "";
867
+
868
+ return `- \`${group.canonicalKey}\` — ${markdownText(group.title)}${proof}`;
869
+ }),
870
+ );
871
+ }
872
+
873
+ const metrics = loopMetrics(events, decisions);
874
+ lines.push(
875
+ "",
876
+ "# Loop health",
877
+ "",
878
+ `- Retirements: ${metrics.retirements} | Reopen rate: ${metrics.reopenRate}% | Open reviews: ${metrics.openReviews}`,
879
+ metrics.medianDaysToRetire === undefined
880
+ ? `- Qualification rate: ${metrics.qualificationRate}% of ${metrics.observedGroups} observed gap(s)`
881
+ : `- Median time to retire: ${metrics.medianDaysToRetire} day(s) | Qualification rate: ${metrics.qualificationRate}% of ${metrics.observedGroups} observed gap(s)`,
882
+ );
883
+ lines.push("");
884
+
885
+ return lines.join("\n");
886
+ }
887
+
888
+ export function loopMetrics(events, decisions = []) {
889
+ const groups = groupsWithState(events, decisions);
890
+ const aliases = buildAliasMap(decisions);
891
+ const retirements = decisions.filter((decision) => decision.action === "retire");
892
+ const retiredGroups = groups.filter((group) => group.state === "retired");
893
+ // Replays the lifecycle to count only reopens that left the retired state,
894
+ // so reopen-from-declined does not inflate the rate.
895
+ const states = new Map();
896
+ let reopens = 0;
897
+ for (const decision of decisions) {
898
+ if (!STATE_ACTIONS.has(decision.action)) {
899
+ continue;
900
+ }
901
+
902
+ const key = resolveAlias(decision.canonicalKey, aliases);
903
+ const current = states.get(key) ?? (registryCapability(key) ? "retired" : "open");
904
+ if (decision.action === "reopen" && current === "retired") {
905
+ reopens += 1;
906
+ }
907
+
908
+ states.set(
909
+ key,
910
+ decision.action === "select" ? "selected" : decision.action === "reopen" ? "open" : `${decision.action}d`,
911
+ );
912
+ }
913
+
914
+ const firstSeenMs = new Map();
915
+ for (const event of canonicalizeEvents(events, decisions)) {
916
+ const value = timestampMs(event.timestamp);
917
+ const existing = firstSeenMs.get(event.canonicalKey);
918
+ if (existing === undefined || value < existing) {
919
+ firstSeenMs.set(event.canonicalKey, value);
920
+ }
921
+ }
922
+
923
+ const durations = [];
924
+ for (const decision of retirements) {
925
+ const first = firstSeenMs.get(resolveAlias(decision.canonicalKey, aliases));
926
+ if (first !== undefined) {
927
+ durations.push(Math.max(0, (timestampMs(decision.timestamp) - first) / 86400000));
928
+ }
929
+ }
930
+
931
+ durations.sort((a, b) => a - b);
932
+ const median =
933
+ durations.length === 0
934
+ ? undefined
935
+ : durations.length % 2 === 1
936
+ ? durations[(durations.length - 1) / 2]
937
+ : (durations[durations.length / 2 - 1] + durations[durations.length / 2]) / 2;
938
+ const qualified = groups.filter(
939
+ (group) => group.qualified || group.state === "selected" || group.state === "retired",
940
+ ).length;
941
+
942
+ return {
943
+ observedGroups: groups.length,
944
+ retirements: retirements.length,
945
+ reopens,
946
+ reopenRate: retirements.length === 0 ? 0 : Math.round((100 * reopens) / retirements.length),
947
+ openReviews: retiredGroups.filter((group) => group.reviewNeeded).length,
948
+ medianDaysToRetire: median === undefined ? undefined : Math.round(median * 10) / 10,
949
+ qualificationRate: groups.length === 0 ? 0 : Math.round((100 * qualified) / groups.length),
950
+ };
951
+ }
952
+
953
+ function renderJournalDecision(lines, decisions, decision, options = {}) {
954
+ const stamp = day(decision.timestamp);
955
+ if (options.compact) {
956
+ const journal = decision.journal;
957
+ const proof = journal ? ` · gates: ${journal.gates.join(", ")} · v${journal.version}` : "";
958
+ lines.push(
959
+ `- ${stamp} ${decision.action} \`${decision.canonicalKey}\` — ${markdownText(decision.note)}${proof}`,
960
+ );
961
+
962
+ return;
963
+ }
964
+
965
+ lines.push(
966
+ `### ${decision.action === "retire" ? "Retired" : "Reopened"} ${stamp}`,
967
+ `- Decision: \`${decision.id.slice(0, 8)}\``,
968
+ );
969
+ if (decision.action === "retire") {
970
+ const journal = decision.journal;
971
+ if (journal) {
972
+ lines.push(`- Gates: ${journal.gates.join(", ")}`, `- SpecPi version: ${journal.version}`);
973
+ lines.push("- Evidence:", ...journal.evidence.map((item) => ` - ${markdownText(item)}`));
974
+ lines.push(
975
+ journal.changedFiles?.length
976
+ ? `- Changed files: ${journal.changedFiles.map((file) => `\`${file}\``).join(", ")}${journal.changedFilesTruncated ? " (list truncated)" : ""}`
977
+ : "- Changed files: not recorded",
978
+ );
979
+ lines.push(
980
+ "- Rollback: revert the changed files above, then re-run the linked validators through `npm run check`.",
981
+ );
982
+ } else {
983
+ lines.push(`- Note: ${markdownText(decision.note)}`);
984
+ }
985
+ } else {
986
+ const linked = linkReopenToRetirement(decisions, decision);
987
+ if (linked) {
988
+ lines.push(`- Linked retirement: \`${linked.id.slice(0, 8)}\` (${day(linked.timestamp)})`);
989
+ }
990
+
991
+ if (decision.evidence?.length) {
992
+ lines.push("- Post-retirement signals:", ...decision.evidence.map((item) => ` - ${markdownText(item)}`));
993
+ }
994
+
995
+ lines.push(`- Note: ${markdownText(decision.note)}`);
996
+ }
997
+
998
+ lines.push("");
999
+ }
1000
+
1001
+ export function renderWishlistHistory(events, decisions = [], requestedKey) {
1002
+ const aliases = buildAliasMap(decisions);
1003
+ const timeline = decisions.filter((decision) => decision.action === "retire" || decision.action === "reopen");
1004
+ const lines = [
1005
+ "# Improvement journal",
1006
+ "",
1007
+ "> Local history of harness retirements and reopens. Sanitized summaries only; nothing leaves this machine.",
1008
+ "",
1009
+ ];
1010
+ if (timeline.length === 0) {
1011
+ lines.push("No retirements or reopens recorded yet.", "");
1012
+
1013
+ return lines.join("\n");
1014
+ }
1015
+
1016
+ if (requestedKey !== undefined) {
1017
+ const key = resolveAlias(normalizeCapability(requestedKey), aliases);
1018
+ const scoped = timeline.filter((decision) => resolveAlias(decision.canonicalKey, aliases) === key);
1019
+ if (scoped.length === 0) {
1020
+ throw new Error(`No retirement history exists for wishlist gap: ${key}`);
1021
+ }
1022
+
1023
+ const groups = aggregateEvents(events, { decisions });
1024
+ const title = groups.find((group) => group.canonicalKey === key)?.title ?? key;
1025
+ lines.push(
1026
+ `## ${markdownText(title)}`,
1027
+ "",
1028
+ `\`${key}\` — ${scoped.length} lifecycle decision(s), oldest first.`,
1029
+ "",
1030
+ );
1031
+ for (const decision of scoped) {
1032
+ renderJournalDecision(lines, decisions, decision);
1033
+ }
1034
+ } else {
1035
+ lines.push(
1036
+ `Last ${Math.min(JOURNAL_HISTORY_LIMIT, timeline.length)} of ${timeline.length} lifecycle decision(s), oldest first.`,
1037
+ "",
1038
+ );
1039
+ for (const decision of timeline.slice(-JOURNAL_HISTORY_LIMIT)) {
1040
+ renderJournalDecision(lines, decisions, decision, { compact: true });
1041
+ }
1042
+ }
1043
+
1044
+ lines.push("");
1045
+
1046
+ return lines.join("\n");
1047
+ }
1048
+
1049
+ function renderImprovementCard(group) {
1050
+ if (!group) {
1051
+ return "# Next Improvement\n\nNo candidate is available.\n";
1052
+ }
1053
+
1054
+ const lines = [
1055
+ "# Next Improvement",
1056
+ "",
1057
+ `## ${markdownText(group.title)}`,
1058
+ "",
1059
+ `- ID: \`${group.canonicalKey}\``,
1060
+ `- Status: ${group.state}`,
1061
+ `- Evidence: ${group.occurrences} unique task(s), ${group.projects} project(s), ${group.sessions} session(s)`,
1062
+ `- Highest impact: ${group.impact}`,
1063
+ `- Smallest likely intervention: ${group.suggestedFix}`,
1064
+ "",
1065
+ "## Improvement card",
1066
+ "",
1067
+ `- **Observed need:** ${markdownText(group.scenarios[0] ?? "Describe the reusable need.")}`,
1068
+ `- **Current limitation:** ${markdownText(group.limitations[0] ?? "Describe why current capabilities fall short.")}`,
1069
+ "- **Hypothesis:** Define the smallest change expected to remove this friction.",
1070
+ "- **Acceptance check:** Define direct evidence that the capability works.",
1071
+ "- **Rollback:** Define how to reverse the change without losing observations.",
1072
+ "- **Privacy/security:** Confirm the change adds no unapproved collection, credentials, or remote state.",
1073
+ "",
1074
+ group.state === "selected"
1075
+ ? "This gap is selected. Run `/harness-improvement` to resume its verified implementation workflow."
1076
+ : "Run `/harness-improvement` to choose an item and begin its verified implementation workflow.",
1077
+ "",
1078
+ ];
1079
+
1080
+ return lines.join("\n");
1081
+ }
1082
+
1083
+ export function renderNextWishlist(events, decisions = []) {
1084
+ const groups = groupsWithState(events, decisions);
1085
+ const candidate = nextWishlistCandidate(events, decisions);
1086
+ const enriched = candidate ? groups.find((group) => group.canonicalKey === candidate.canonicalKey) : undefined;
1087
+
1088
+ return renderImprovementCard(enriched);
1089
+ }
1090
+
1091
+ export function renderIssueDraft(events, decisions, requestedKey) {
1092
+ const aliases = buildAliasMap(decisions);
1093
+ const key = resolveAlias(requestedKey, aliases);
1094
+ const group = groupsWithState(events, decisions).find((item) => item.canonicalKey === key);
1095
+ if (!group) {
1096
+ throw new Error(`Unknown wishlist gap: ${key}`);
1097
+ }
1098
+
1099
+ const lines = [
1100
+ `# Capability gap: ${markdownText(group.title)}`,
1101
+ "",
1102
+ "> Local draft only. Review before copying it anywhere; SpecPi does not upload or open an issue.",
1103
+ "",
1104
+ `**Gap ID:** \`${group.canonicalKey}\``,
1105
+ `**Evidence:** ${group.occurrences} unique task(s) across ${group.projects} project(s) and ${group.sessions} session(s)`,
1106
+ `**Impact:** ${group.impact}`,
1107
+ `**Suggested intervention:** ${group.suggestedFix}`,
1108
+ "",
1109
+ "## Observed needs",
1110
+ ...group.scenarios.map((value) => `- ${markdownText(value)}`),
1111
+ "",
1112
+ "## Current limitations",
1113
+ ...group.limitations.map((value) => `- ${markdownText(value)}`),
1114
+ ];
1115
+ if (group.workarounds.length) {
1116
+ lines.push("", "## Workarounds", ...group.workarounds.map((value) => `- ${markdownText(value)}`));
1117
+ }
1118
+
1119
+ lines.push(
1120
+ "",
1121
+ "## Acceptance",
1122
+ "- [ ] Define and run a direct capability check.",
1123
+ "- [ ] Confirm rollback behavior.",
1124
+ "- [ ] Confirm no prompts, source, credentials, commands, paths, or private identities are included.",
1125
+ "",
1126
+ );
1127
+
1128
+ return lines.join("\n");
1129
+ }
1130
+
1131
+ function writeReport(reportPath, events, decisions, invalidLines, invalidDecisionLines, generatedAt) {
1132
+ const report = renderWishlist(events, { decisions, invalidLines, invalidDecisionLines, generatedAt });
1133
+ atomicWrite(reportPath, report, 0o600);
1134
+
1135
+ return report;
1136
+ }
1137
+
1138
+ function appendBounded(file, entry, currentBytes, maxBytes, label) {
1139
+ assertNotSymlink(file);
1140
+ const encoded = `${JSON.stringify(entry)}\n`;
1141
+ if (currentBytes + Buffer.byteLength(encoded, "utf8") > maxBytes) {
1142
+ throw new Error(`${label} reached its ${maxBytes}-byte limit. Archive the wishlist before adding more state.`);
1143
+ }
1144
+
1145
+ fs.appendFileSync(file, encoded, { encoding: "utf8", mode: 0o600 });
1146
+
1147
+ return currentBytes + Buffer.byteLength(encoded, "utf8");
1148
+ }
1149
+
1150
+ export function readCollectionMode(stateDir) {
1151
+ const { config } = pathsFor(stateDir);
1152
+ if (!fs.existsSync(config)) {
1153
+ return "undecided";
1154
+ }
1155
+
1156
+ assertNotSymlink(config);
1157
+ try {
1158
+ const value = JSON.parse(fs.readFileSync(config, "utf8"));
1159
+
1160
+ return value?.schema === 1 && COLLECTION_MODES.has(value.mode) ? value.mode : "undecided";
1161
+ } catch {
1162
+ return "undecided";
1163
+ }
1164
+ }
1165
+
1166
+ export async function setCollectionMode({ stateDir, mode, signal }) {
1167
+ if (!COLLECTION_MODES.has(mode)) {
1168
+ throw new Error("Collection mode must be on or off");
1169
+ }
1170
+
1171
+ return withStateLock(stateDir, signal, async () => {
1172
+ atomicWrite(pathsFor(stateDir).config, `${JSON.stringify({ schema: 1, mode })}\n`, 0o600);
1173
+
1174
+ return { mode };
1175
+ });
1176
+ }
1177
+
1178
+ export async function recordCapabilityGap(options) {
1179
+ const {
1180
+ stateDir,
1181
+ sessionId,
1182
+ runId,
1183
+ cwd,
1184
+ gap,
1185
+ signal,
1186
+ now = new Date().toISOString(),
1187
+ maxEventFileBytes = MAX_EVENT_FILE_BYTES,
1188
+ } = options;
1189
+
1190
+ return withStateLock(stateDir, signal, async () => {
1191
+ if (readCollectionMode(stateDir) !== "on") {
1192
+ throw new Error("Local wishlist collection must be explicitly on before recording observations");
1193
+ }
1194
+
1195
+ if (!Number.isFinite(timestampMs(now))) {
1196
+ throw new Error("Observation timestamp is invalid");
1197
+ }
1198
+
1199
+ const files = pathsFor(stateDir);
1200
+ const parsed = readEventsFile(files.events);
1201
+ const decisionData = readDecisionsFile(files.decisions);
1202
+ const salt = getSalt(files.salt);
1203
+ const sessionHash = privateHash(salt, sessionId || "ephemeral-session");
1204
+ const runHash = privateHash(salt, `${sessionId || "ephemeral-session"}\0${runId || "unknown-run"}`);
1205
+ const projectHash = privateHash(salt, path.resolve(cwd || process.cwd()));
1206
+ const sanitized = sanitizeGap(gap);
1207
+ const aliases = buildAliasMap(decisionData.decisions);
1208
+ const observedKey = normalizeCapability(sanitized.capability);
1209
+ const canonicalKey = resolveAlias(observedKey, aliases);
1210
+ const states = lifecycleStates(decisionData.decisions);
1211
+ const priorState = states.get(canonicalKey) ?? (registryCapability(canonicalKey) ? "retired" : "open");
1212
+ const regression = priorState === "retired";
1213
+ const duplicate = parsed.events.some(
1214
+ (event) =>
1215
+ resolveAlias(event.observedKey ?? event.canonicalKey, aliases) === canonicalKey &&
1216
+ event.runHash === runHash,
1217
+ );
1218
+ if (!duplicate) {
1219
+ const event = {
1220
+ schema: 1,
1221
+ timestamp: now,
1222
+ observedKey,
1223
+ canonicalKey: observedKey,
1224
+ sessionHash,
1225
+ runHash,
1226
+ projectHash,
1227
+ ...sanitized,
1228
+ ...(regression ? { regression: true } : {}),
1229
+ };
1230
+ parsed.bytes = appendBounded(
1231
+ files.events,
1232
+ event,
1233
+ parsed.bytes ?? 0,
1234
+ maxEventFileBytes,
1235
+ "Tool wishlist event log",
1236
+ );
1237
+ parsed.events.push(event);
1238
+ }
1239
+
1240
+ writeReport(
1241
+ files.report,
1242
+ parsed.events,
1243
+ decisionData.decisions,
1244
+ parsed.invalidLines,
1245
+ decisionData.invalidLines,
1246
+ now,
1247
+ );
1248
+ const groups = groupsWithState(parsed.events, decisionData.decisions);
1249
+ const queued = groups.filter((item) => ["open", "selected"].includes(item.state));
1250
+ const group = groups.find((item) => item.canonicalKey === canonicalKey);
1251
+
1252
+ return {
1253
+ duplicate,
1254
+ regression,
1255
+ resolved: priorState === "retired",
1256
+ canonicalKey,
1257
+ reportPath: files.report,
1258
+ occurrences: group?.occurrences ?? 0,
1259
+ sessions: group?.sessions ?? 0,
1260
+ priority: group?.priority ?? 0,
1261
+ reviewNeeded: group?.reviewNeeded ?? false,
1262
+ uniqueGaps: queued.length,
1263
+ invalidLines: parsed.invalidLines + decisionData.invalidLines,
1264
+ };
1265
+ });
1266
+ }
1267
+
1268
+ function currentStateFor(key, decisions) {
1269
+ return lifecycleStates(decisions).get(key) ?? (registryCapability(key) ? "retired" : "open");
1270
+ }
1271
+
1272
+ function validateStateTransition(action, current) {
1273
+ if (action === "select" && !["open", "declined"].includes(current)) {
1274
+ return false;
1275
+ }
1276
+
1277
+ if (action === "decline" && !["open", "selected"].includes(current)) {
1278
+ return false;
1279
+ }
1280
+
1281
+ if (action === "retire" && current !== "selected") {
1282
+ return false;
1283
+ }
1284
+
1285
+ if (action === "reopen" && !["retired", "declined"].includes(current)) {
1286
+ return false;
1287
+ }
1288
+
1289
+ return true;
1290
+ }
1291
+
1292
+ export async function appendWishlistDecision(options) {
1293
+ const {
1294
+ stateDir,
1295
+ action,
1296
+ canonicalKey,
1297
+ targetKey = "",
1298
+ note = "",
1299
+ evidence,
1300
+ journal,
1301
+ linkedRetirementId,
1302
+ signal,
1303
+ now = new Date().toISOString(),
1304
+ maxDecisionFileBytes = MAX_DECISION_FILE_BYTES,
1305
+ } = options;
1306
+ if (!DECISION_ACTIONS.has(action)) {
1307
+ throw new Error(`Unknown wishlist action: ${action}`);
1308
+ }
1309
+
1310
+ if (journal !== undefined && action !== "retire") {
1311
+ throw new Error("A retirement journal is only valid on retire decisions");
1312
+ }
1313
+
1314
+ if (evidence !== undefined && action !== "reopen") {
1315
+ throw new Error(
1316
+ "Decision evidence is only valid on reopen decisions; retirement evidence belongs in the journal",
1317
+ );
1318
+ }
1319
+
1320
+ const journalValue = journal !== undefined ? sanitizeJournal(journal) : undefined;
1321
+ const evidenceValue =
1322
+ evidence !== undefined ? sanitizeEvidenceList(evidence, REOPEN_EVIDENCE_LIMIT, "Reopen evidence") : undefined;
1323
+
1324
+ return withStateLock(stateDir, signal, async () => {
1325
+ if (!Number.isFinite(timestampMs(now))) {
1326
+ throw new Error("Decision timestamp is invalid");
1327
+ }
1328
+
1329
+ const files = pathsFor(stateDir);
1330
+ const parsed = readEventsFile(files.events);
1331
+ const decisionData = readDecisionsFile(files.decisions);
1332
+ const aliases = buildAliasMap(decisionData.decisions);
1333
+ const key = resolveAlias(normalizeCapability(canonicalKey), aliases);
1334
+ let target = targetKey ? resolveAlias(normalizeCapability(targetKey), aliases) : "";
1335
+ const known = new Set([
1336
+ ...canonicalizeEvents(parsed.events, decisionData.decisions).map((event) => event.canonicalKey),
1337
+ ...CAPABILITY_REGISTRY.capabilities.map((item) => item.id),
1338
+ ]);
1339
+ if (!known.has(key) && action !== "unmerge") {
1340
+ throw new Error(`Unknown wishlist gap: ${key}`);
1341
+ }
1342
+
1343
+ if (STATE_ACTIONS.has(action)) {
1344
+ const current = currentStateFor(key, decisionData.decisions);
1345
+ if (!validateStateTransition(action, current)) {
1346
+ throw new Error(`Cannot ${action} ${key} while its state is ${current}`);
1347
+ }
1348
+
1349
+ if (action === "retire" && sanitizeReportText(note, 240).length < 5) {
1350
+ throw new Error("Retirement requires a short sanitized validation note");
1351
+ }
1352
+
1353
+ if (action === "reopen") {
1354
+ target =
1355
+ resolveReopenRetirementLink(decisionData.decisions, key, aliases, linkedRetirementId)?.id ?? "";
1356
+ }
1357
+ } else if (action === "merge") {
1358
+ if (!known.has(target)) {
1359
+ throw new Error(`Unknown merge target: ${target}`);
1360
+ }
1361
+
1362
+ if (registryCapability(key)) {
1363
+ throw new Error("Reviewed registry capabilities cannot be merged into another ID");
1364
+ }
1365
+
1366
+ if (key === target) {
1367
+ throw new Error("Cannot merge a gap into itself");
1368
+ }
1369
+
1370
+ const prospective = new Map(aliases).set(key, target);
1371
+ if (resolveAlias(target, prospective) === key) {
1372
+ throw new Error("Capability aliases must not form a cycle");
1373
+ }
1374
+ } else if (action === "unmerge") {
1375
+ const reversed = new Set(
1376
+ decisionData.decisions.filter((item) => item.action === "unmerge").map((item) => item.reverses),
1377
+ );
1378
+ const merge = decisionData.decisions.find(
1379
+ (item) => item.id === canonicalKey && item.action === "merge" && !reversed.has(item.id),
1380
+ );
1381
+ if (!merge) {
1382
+ throw new Error(`No active merge decision exists with ID ${canonicalKey}`);
1383
+ }
1384
+
1385
+ target = merge.targetKey;
1386
+ }
1387
+
1388
+ const reversedMerge =
1389
+ action === "unmerge"
1390
+ ? decisionData.decisions.find((item) => item.id === canonicalKey && item.action === "merge")
1391
+ : undefined;
1392
+ const decision = {
1393
+ schema: 1,
1394
+ id: randomUUID(),
1395
+ timestamp: now,
1396
+ action,
1397
+ canonicalKey: reversedMerge?.canonicalKey ?? key,
1398
+ targetKey: target,
1399
+ reverses: reversedMerge?.id ?? "",
1400
+ note: sanitizeReportText(note, 240),
1401
+ ...(evidenceValue ? { evidence: evidenceValue } : {}),
1402
+ ...(journalValue ? { journal: journalValue } : {}),
1403
+ };
1404
+ appendBounded(
1405
+ files.decisions,
1406
+ decision,
1407
+ decisionData.bytes ?? 0,
1408
+ maxDecisionFileBytes,
1409
+ "Tool wishlist decision log",
1410
+ );
1411
+ decisionData.decisions.push(decision);
1412
+ const report = writeReport(
1413
+ files.report,
1414
+ parsed.events,
1415
+ decisionData.decisions,
1416
+ parsed.invalidLines,
1417
+ decisionData.invalidLines,
1418
+ now,
1419
+ );
1420
+
1421
+ return {
1422
+ action,
1423
+ decisionId: decision.id,
1424
+ canonicalKey: decision.canonicalKey,
1425
+ targetKey: decision.targetKey,
1426
+ reportPath: files.report,
1427
+ report,
1428
+ };
1429
+ });
1430
+ }
1431
+
1432
+ export async function refreshWishlist(options) {
1433
+ const { stateDir, signal, now = new Date().toISOString() } = options;
1434
+
1435
+ return withStateLock(stateDir, signal, async () => {
1436
+ const files = pathsFor(stateDir);
1437
+ const parsed = readEventsFile(files.events);
1438
+ const decisionData = readDecisionsFile(files.decisions);
1439
+ const report = writeReport(
1440
+ files.report,
1441
+ parsed.events,
1442
+ decisionData.decisions,
1443
+ parsed.invalidLines,
1444
+ decisionData.invalidLines,
1445
+ now,
1446
+ );
1447
+ const groups = queueGroups(parsed.events, decisionData.decisions);
1448
+
1449
+ return {
1450
+ reportPath: files.report,
1451
+ report,
1452
+ next: renderNextWishlist(parsed.events, decisionData.decisions),
1453
+ improvements: harnessImprovementCandidates(parsed.events, decisionData.decisions),
1454
+ uniqueGaps: groups.length,
1455
+ occurrences: groups.reduce((total, group) => total + group.occurrences, 0),
1456
+ invalidLines: parsed.invalidLines + decisionData.invalidLines,
1457
+ metrics: loopMetrics(parsed.events, decisionData.decisions),
1458
+ events: parsed.events,
1459
+ decisions: decisionData.decisions,
1460
+ };
1461
+ });
1462
+ }
1463
+
1464
+ export async function createIssueDraft({ stateDir, canonicalKey, signal }) {
1465
+ return withStateLock(stateDir, signal, async () => {
1466
+ const files = pathsFor(stateDir);
1467
+ const parsed = readEventsFile(files.events);
1468
+ const decisionData = readDecisionsFile(files.decisions);
1469
+
1470
+ return {
1471
+ canonicalKey: normalizeCapability(canonicalKey),
1472
+ markdown: renderIssueDraft(parsed.events, decisionData.decisions, canonicalKey),
1473
+ };
1474
+ });
1475
+ }
1476
+
1477
+ function archiveStamp(value) {
1478
+ return String(value).replace(/[:.]/g, "-");
1479
+ }
1480
+
1481
+ export async function archiveWishlist({
1482
+ stateDir,
1483
+ signal,
1484
+ now = new Date().toISOString(),
1485
+ reason = "archive",
1486
+ failAfterPrepared = false,
1487
+ }) {
1488
+ return withStateLock(stateDir, signal, async () => {
1489
+ const files = pathsFor(stateDir);
1490
+ assertNotSymlink(files.archives);
1491
+ fs.mkdirSync(files.archives, { recursive: true, mode: 0o700 });
1492
+ const archiveDir = path.join(files.archives, `${archiveStamp(now)}-${reason}`);
1493
+ fs.mkdirSync(archiveDir, { recursive: false, mode: 0o700 });
1494
+ const prepared = [];
1495
+ for (const key of ["events", "decisions", "report"]) {
1496
+ const source = files[key];
1497
+ if (!fs.existsSync(source)) {
1498
+ continue;
1499
+ }
1500
+
1501
+ assertNotSymlink(source);
1502
+ const name = path.basename(source);
1503
+ const target = path.join(archiveDir, name);
1504
+ fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
1505
+ fs.chmodSync(target, 0o600);
1506
+ prepared.push({ name, sha256: fileHash(target) });
1507
+ }
1508
+
1509
+ const manifest = { schema: 1, timestamp: now, reason, files: prepared };
1510
+ atomicWrite(path.join(archiveDir, "archive.json"), `${JSON.stringify(manifest, null, 2)}\n`, 0o600);
1511
+ atomicWrite(files.archiveTransaction, `${JSON.stringify({ ...manifest, archiveDir })}\n`, 0o600);
1512
+ if (failAfterPrepared) {
1513
+ throw new Error("Injected failure after archive preparation");
1514
+ }
1515
+
1516
+ recoverArchiveTransaction(stateDir);
1517
+
1518
+ return {
1519
+ archiveDir,
1520
+ moved: prepared.map((item) => item.name),
1521
+ reportPath: files.report,
1522
+ report: fs.readFileSync(files.report, "utf8"),
1523
+ };
1524
+ });
1525
+ }