@ijfw/memory-server 1.4.3 → 1.5.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 (233) hide show
  1. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  2. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  3. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  4. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  5. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  6. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  7. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  8. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  9. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  23. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  24. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  26. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  27. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  28. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  29. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  47. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  48. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  49. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  50. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  52. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  53. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  54. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  68. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  69. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  71. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  72. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  73. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  74. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  92. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  93. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  94. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  95. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  96. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  97. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  98. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  99. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  115. package/package.json +1 -1
  116. package/src/active-extension-writer.js +144 -64
  117. package/src/api-client.js +43 -5
  118. package/src/audit-roster.js +80 -5
  119. package/src/blackboard.js +298 -6
  120. package/src/cli-run.js +33 -5
  121. package/src/codex-agents.js +96 -5
  122. package/src/cost/aggregator.js +39 -9
  123. package/src/cost/pricing.js +57 -0
  124. package/src/cost/readers/gemini.js +1 -1
  125. package/src/cross-audit-chunker.js +189 -0
  126. package/src/cross-dispatcher.js +124 -21
  127. package/src/cross-orchestrator-cli.js +550 -14
  128. package/src/cross-orchestrator.js +1171 -10
  129. package/src/cross-project-search.js +195 -9
  130. package/src/dashboard-client-planning.html +273 -0
  131. package/src/dashboard-client-waves.html +304 -0
  132. package/src/dashboard-client.html +17 -2
  133. package/src/dashboard-server.js +152 -0
  134. package/src/deploy-alerts.js +150 -0
  135. package/src/design/iframe-bridge.js +242 -0
  136. package/src/design-companion.js +144 -0
  137. package/src/dispatch/checkpoint-cli.js +97 -0
  138. package/src/dispatch/colon-syntax.js +81 -1
  139. package/src/dispatch/extension.js +27 -1
  140. package/src/dispatch/registry-cli.js +4 -1
  141. package/src/dispatch/wave-cli.js +323 -0
  142. package/src/dispatch/worktree-cli.js +40 -0
  143. package/src/dispatch-planner.js +97 -2
  144. package/src/dream/runner.mjs +47 -11
  145. package/src/dream/stage-runner.js +40 -0
  146. package/src/dream/state-file.js +102 -0
  147. package/src/extension-installer.js +70 -24
  148. package/src/extension-quota-tracker.js +4 -2
  149. package/src/extension-registry.js +289 -35
  150. package/src/feedback-detector.js +26 -0
  151. package/src/fs-lock.js +259 -7
  152. package/src/gate-result.js +95 -1
  153. package/src/hero-line.js +86 -5
  154. package/src/intent-router.js +35 -0
  155. package/src/lib/a11y-contract.js +117 -0
  156. package/src/lib/atomic-io.js +29 -8
  157. package/src/lib/cache-keepalive.js +150 -0
  158. package/src/lib/jsonl-rotation.js +104 -0
  159. package/src/lib/lighthouse-pillar.js +121 -0
  160. package/src/lib/llm-call.js +121 -0
  161. package/src/lib/playwright-baseline.js +205 -0
  162. package/src/lib/rekor-bridge.js +221 -0
  163. package/src/lib/repo-map.js +392 -0
  164. package/src/lib/shasum-verify.js +164 -0
  165. package/src/lib/sketches-gc.js +132 -0
  166. package/src/lib/tmp-suffix.js +62 -0
  167. package/src/lib/ui-review-runner.js +554 -0
  168. package/src/lib/uispec-drift.js +301 -0
  169. package/src/lib/uispec-intake.js +381 -0
  170. package/src/lib/worktree-guards.js +118 -0
  171. package/src/lib/worktree-recovery.js +100 -0
  172. package/src/memory/auto-linker.js +152 -0
  173. package/src/memory/benchmark.js +498 -0
  174. package/src/memory/dedup.js +126 -0
  175. package/src/memory/embedding-cache.js +136 -0
  176. package/src/memory/fact-extractor.js +168 -0
  177. package/src/memory/fts5.js +65 -1
  178. package/src/memory/migrations/004-bitemporal.js +91 -0
  179. package/src/memory/migrations/005-vector-cache.js +61 -0
  180. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  181. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  182. package/src/memory/migrations/008-write-provenance.js +41 -0
  183. package/src/memory/obsidian-parser.js +91 -0
  184. package/src/memory/query-dataview.js +86 -0
  185. package/src/memory/search.js +10 -0
  186. package/src/memory/temporal.js +529 -0
  187. package/src/memory/tokenize.js +10 -0
  188. package/src/memory-facts-handler.js +37 -0
  189. package/src/memory-feedback.js +260 -2
  190. package/src/model-refresh.js +292 -0
  191. package/src/observability/cost-anomaly.js +166 -0
  192. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  193. package/src/observability/trace-id.js +163 -0
  194. package/src/orchestrator/agents-md-blackboard.js +152 -0
  195. package/src/orchestrator/checkpoint-contract.md +140 -0
  196. package/src/orchestrator/debug-trident.js +570 -0
  197. package/src/orchestrator/merge-block-aware.js +350 -0
  198. package/src/orchestrator/plan-checker.js +475 -0
  199. package/src/orchestrator/post-done-runner.js +249 -0
  200. package/src/orchestrator/review.js +136 -0
  201. package/src/orchestrator/runtime-loop.js +430 -0
  202. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  203. package/src/orchestrator/skill-telemetry.js +37 -0
  204. package/src/orchestrator/state-events.js +459 -0
  205. package/src/orchestrator/state-sdk.js +1764 -0
  206. package/src/orchestrator/status-protocol.js +235 -0
  207. package/src/orchestrator/subagent-telemetry.js +452 -0
  208. package/src/orchestrator/termination.js +160 -0
  209. package/src/orchestrator/verification-gate.js +281 -0
  210. package/src/orchestrator/wave-state.js +564 -0
  211. package/src/orchestrator/worktree-provision.js +77 -0
  212. package/src/override-use-registry.js +111 -5
  213. package/src/receipts.js +36 -4
  214. package/src/recovery/checkpoint.js +56 -3
  215. package/src/recovery/code-fixer.js +656 -0
  216. package/src/recovery/truncation.js +317 -0
  217. package/src/redactor.js +75 -6
  218. package/src/runtime-mediator.js +15 -0
  219. package/src/sanitizer.js +10 -0
  220. package/src/search-hybrid.js +139 -0
  221. package/src/server.js +603 -59
  222. package/src/swarm/worktree.js +27 -4
  223. package/src/swarm-config.js +113 -12
  224. package/src/team/domain-templates/book.json +51 -0
  225. package/src/team/domain-templates/business.json +41 -0
  226. package/src/team/domain-templates/content.json +50 -0
  227. package/src/team/domain-templates/design.json +44 -0
  228. package/src/team/domain-templates/research.json +41 -0
  229. package/src/team/domain-templates/software.json +40 -0
  230. package/src/team/generator.js +278 -3
  231. package/src/team/modify.js +203 -0
  232. package/src/team/schemas.js +48 -0
  233. package/src/update-apply.js +19 -3
@@ -0,0 +1,392 @@
1
+ // v1.5.0 audit-MED-tok-M1 — Repo-map / brief-compaction for subagent dispatch.
2
+ //
3
+ // Problem: subagent briefs that "dump the source" currently consume 5-10x
4
+ // more input tokens than necessary. The orchestrator does not know which
5
+ // files matter to a given task, so it conservatively includes everything.
6
+ //
7
+ // Solution (Aider-style, simplified): walk the repo, score each file with
8
+ // TF-IDF on its symbols + path, and emit a compact map that fits in a fixed
9
+ // token budget (default 1k tokens). The brief gets prepended with this map
10
+ // so a downstream subagent can read it instead of crawling the tree.
11
+ //
12
+ // Design choices:
13
+ // - Zero deps (no tree-sitter, no fs-walk libs). The IJFW project rule is
14
+ // pure-Node. Symbol extraction is a regex pass (good enough for JS/TS/
15
+ // Python/Go signatures + markdown headings). PageRank is approximated
16
+ // with TF-IDF importance ranking — cheap, deterministic, and stable
17
+ // across runs.
18
+ // - Respects .gitignore via a simple parse (no glob library). The parse
19
+ // handles the common patterns: line comments, blank lines, leading "!"
20
+ // negations, trailing "/" directory markers, and "*" wildcards.
21
+ // - Token budget is enforced post-rank: take the top-N files until the
22
+ // running estimate of token cost (chars / 4) exceeds the budget.
23
+ //
24
+ // Public API:
25
+ // buildRepoMap({ rootDir, budgetTokens=1000, maxFiles=200, extensions=[...] })
26
+ // returns { files: [{ path, summary, importance }], totalTokens, truncated }
27
+ //
28
+ // compactBriefForSubagent({ baseBrief, repoMap, maxPrefixTokens })
29
+ // returns { brief: string, repoMapTokens, baseBriefTokens }
30
+
31
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
32
+ import { join, relative, sep, basename, extname } from 'node:path';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Constants
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const DEFAULT_BUDGET_TOKENS = 1000;
39
+ const DEFAULT_MAX_FILES = 200;
40
+ const TOKENS_PER_CHAR = 1 / 4; // ~4 chars per token (Anthropic/OpenAI average)
41
+
42
+ // File extensions considered "code" by default. Markdown/JSON ride along
43
+ // because they often carry the most-informative summaries (README, package.json).
44
+ const DEFAULT_EXTENSIONS = new Set([
45
+ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
46
+ '.py', '.go', '.rs', '.rb', '.java', '.kt', '.swift',
47
+ '.c', '.cc', '.cpp', '.h', '.hpp',
48
+ '.md', '.json', '.yaml', '.yml', '.toml',
49
+ ]);
50
+
51
+ // Always-skip directory names (independent of .gitignore). These are
52
+ // universally noise for repo-map purposes.
53
+ const ALWAYS_SKIP_DIRS = new Set([
54
+ '.git', 'node_modules', '.svn', '.hg', '__pycache__',
55
+ '.next', '.nuxt', '.cache', '.vercel', 'dist', 'build', 'coverage',
56
+ '.pytest_cache', '.mypy_cache', '.tox', 'venv', '.venv', 'env',
57
+ // IJFW-specific noise
58
+ '.ijfw', '.planning',
59
+ ]);
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // .gitignore parsing — minimal but covers the cases that matter.
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Parse a .gitignore file into a list of matcher objects.
67
+ * Each matcher is { pattern, negate, dirOnly, anchored, regex }.
68
+ *
69
+ * @param {string} content
70
+ * @returns {Array}
71
+ */
72
+ export function parseGitignore(content) {
73
+ if (typeof content !== 'string') return [];
74
+ const matchers = [];
75
+ for (const rawLine of content.split('\n')) {
76
+ let line = rawLine.replace(/\r$/, '');
77
+ line = line.replace(/\s+$/, '');
78
+ if (line.length === 0) continue;
79
+ if (line.startsWith('#')) continue;
80
+ let negate = false;
81
+ if (line.startsWith('!')) {
82
+ negate = true;
83
+ line = line.slice(1);
84
+ }
85
+ let anchored = false;
86
+ if (line.startsWith('/')) {
87
+ anchored = true;
88
+ line = line.slice(1);
89
+ }
90
+ let dirOnly = false;
91
+ if (line.endsWith('/')) {
92
+ dirOnly = true;
93
+ line = line.slice(0, -1);
94
+ }
95
+ if (line.length === 0) continue;
96
+ matchers.push({ pattern: line, negate, dirOnly, anchored, regex: globToRegex(line, anchored) });
97
+ }
98
+ return matchers;
99
+ }
100
+
101
+ // Glob -> RegExp. Supports: *, **, ?, character classes.
102
+ function globToRegex(glob, anchored) {
103
+ let re = '';
104
+ for (let i = 0; i < glob.length; i++) {
105
+ const c = glob[i];
106
+ if (c === '*') {
107
+ if (glob[i + 1] === '*') {
108
+ re += '.*';
109
+ i++;
110
+ if (glob[i + 1] === '/') i++;
111
+ } else {
112
+ re += '[^/]*';
113
+ }
114
+ } else if (c === '?') {
115
+ re += '[^/]';
116
+ } else if (c === '.' || c === '+' || c === '(' || c === ')' || c === '|' || c === '^' || c === '$' || c === '\\') {
117
+ re += '\\' + c;
118
+ } else if (c === '/') {
119
+ re += '/';
120
+ } else {
121
+ re += c;
122
+ }
123
+ }
124
+ const prefix = anchored ? '^' : '(^|.*/)';
125
+ const suffix = '(/.*)?$';
126
+ return new RegExp(prefix + re + suffix);
127
+ }
128
+
129
+ /**
130
+ * Test a relative posix-style path against parsed .gitignore matchers.
131
+ * Returns true if the path should be IGNORED.
132
+ *
133
+ * @param {string} relPathPosix
134
+ * @param {boolean} isDir
135
+ * @param {Array} matchers
136
+ * @returns {boolean}
137
+ */
138
+ export function isIgnored(relPathPosix, isDir, matchers) {
139
+ let ignored = false;
140
+ for (const m of matchers) {
141
+ if (m.dirOnly && !isDir) continue;
142
+ if (m.regex.test(relPathPosix)) {
143
+ ignored = !m.negate;
144
+ }
145
+ }
146
+ return ignored;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Filesystem walk
151
+ // ---------------------------------------------------------------------------
152
+
153
+ function* walkFiles(rootDir, matchers, extensions) {
154
+ const stack = [rootDir];
155
+ while (stack.length > 0) {
156
+ const dir = stack.pop();
157
+ let entries;
158
+ try {
159
+ entries = readdirSync(dir, { withFileTypes: true });
160
+ } catch {
161
+ continue;
162
+ }
163
+ for (const ent of entries) {
164
+ const abs = join(dir, ent.name);
165
+ const rel = relative(rootDir, abs).split(sep).join('/');
166
+ if (ent.isDirectory()) {
167
+ if (ALWAYS_SKIP_DIRS.has(ent.name)) continue;
168
+ if (isIgnored(rel, true, matchers)) continue;
169
+ stack.push(abs);
170
+ } else if (ent.isFile()) {
171
+ if (isIgnored(rel, false, matchers)) continue;
172
+ if (extensions && !extensions.has(extname(ent.name).toLowerCase())) continue;
173
+ yield { abs, rel };
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Symbol extraction + TF-IDF
181
+ // ---------------------------------------------------------------------------
182
+
183
+ // Heuristic regex-based symbol extractor. Catches:
184
+ // - JS/TS: function foo, const foo =, class Foo, export ...
185
+ // - Python: def foo, class Foo
186
+ // - Go: func Foo
187
+ // - Markdown: ^# heading text
188
+ // The goal is "stable enough to rank importance", not perfect AST parsing.
189
+ /* eslint-disable security/detect-unsafe-regex --
190
+ * SYMBOL_PATTERNS scans developer-authored source code files on the local
191
+ * filesystem (not untrusted network input). Each alternation segment is
192
+ * bounded by the source file's line length and the identifier character
193
+ * class is bounded by line content. ReDoS attack surface is not present
194
+ * because the input is repo content the developer chose to add.
195
+ */
196
+ const SYMBOL_PATTERNS = [
197
+ /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
198
+ /(?:^|\n)\s*(?:export\s+)?class\s+([A-Za-z_$][\w$]*)/g,
199
+ /(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g,
200
+ /(?:^|\n)\s*def\s+([A-Za-z_][\w]*)/g,
201
+ /(?:^|\n)\s*func\s+(?:\([^)]*\)\s+)?([A-Z][\w]*)/g,
202
+ /(?:^|\n)#{1,3}\s+(.{1,80})/g,
203
+ ];
204
+ /* eslint-enable security/detect-unsafe-regex */
205
+
206
+ function extractSymbols(content) {
207
+ const symbols = [];
208
+ for (const pat of SYMBOL_PATTERNS) {
209
+ let m;
210
+ pat.lastIndex = 0;
211
+ while ((m = pat.exec(content)) !== null) {
212
+ const s = m[1];
213
+ if (s && s.length > 0) symbols.push(s.trim());
214
+ }
215
+ }
216
+ return symbols;
217
+ }
218
+
219
+ // Build a TF-IDF importance score per file. "Document" = file's symbols +
220
+ // path components. "Corpus" = all scanned files. Files with more distinctive
221
+ // symbols (rare across the corpus) score higher.
222
+ function tfidfScore(perFileTokens) {
223
+ const docFreq = new Map();
224
+ for (const tokens of perFileTokens.values()) {
225
+ const uniq = new Set(tokens);
226
+ for (const t of uniq) docFreq.set(t, (docFreq.get(t) || 0) + 1);
227
+ }
228
+ const N = perFileTokens.size;
229
+ const scores = new Map();
230
+ for (const [path, tokens] of perFileTokens.entries()) {
231
+ if (tokens.length === 0) { scores.set(path, 0); continue; }
232
+ const tf = new Map();
233
+ for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
234
+ let score = 0;
235
+ for (const [t, count] of tf.entries()) {
236
+ const df = docFreq.get(t) || 1;
237
+ const idf = Math.log((N + 1) / df) + 1;
238
+ score += (count / tokens.length) * idf;
239
+ }
240
+ scores.set(path, score);
241
+ }
242
+ return scores;
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Public API: buildRepoMap
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * Build a compact, importance-ranked map of the repo.
251
+ *
252
+ * @param {object} args
253
+ * @param {string} args.rootDir
254
+ * @param {number} [args.budgetTokens=1000]
255
+ * @param {number} [args.maxFiles=200]
256
+ * @param {Set<string>|string[]} [args.extensions]
257
+ * @returns {{ files: Array<{path:string, summary:string, importance:number}>, totalTokens:number, truncated:boolean, scannedCount:number }}
258
+ */
259
+ export function buildRepoMap({ rootDir, budgetTokens = DEFAULT_BUDGET_TOKENS, maxFiles = DEFAULT_MAX_FILES, extensions } = {}) {
260
+ if (typeof rootDir !== 'string' || rootDir.length === 0) {
261
+ throw new TypeError('buildRepoMap: rootDir is required');
262
+ }
263
+ try {
264
+ const st = statSync(rootDir);
265
+ if (!st.isDirectory()) throw new Error('rootDir must be a directory');
266
+ } catch (err) {
267
+ throw new Error(`buildRepoMap: cannot access rootDir "${rootDir}": ${err.message}`);
268
+ }
269
+
270
+ const extSet = extensions instanceof Set
271
+ ? extensions
272
+ : Array.isArray(extensions)
273
+ ? new Set(extensions.map(e => e.toLowerCase()))
274
+ : DEFAULT_EXTENSIONS;
275
+
276
+ let matchers = [];
277
+ try {
278
+ const giContent = readFileSync(join(rootDir, '.gitignore'), 'utf8');
279
+ matchers = parseGitignore(giContent);
280
+ } catch {
281
+ matchers = [];
282
+ }
283
+
284
+ const perFileTokens = new Map();
285
+ const perFileSummary = new Map();
286
+ let scannedCount = 0;
287
+ for (const { abs, rel } of walkFiles(rootDir, matchers, extSet)) {
288
+ scannedCount++;
289
+ if (scannedCount > 10_000) break;
290
+ let content = '';
291
+ try {
292
+ content = readFileSync(abs, 'utf8');
293
+ } catch {
294
+ continue;
295
+ }
296
+ if (content.length > 256 * 1024) content = content.slice(0, 256 * 1024);
297
+
298
+ const symbols = extractSymbols(content);
299
+ const pathTokens = rel.split(/[/.]/).filter(t => t.length > 1);
300
+ perFileTokens.set(rel, [...symbols, ...pathTokens]);
301
+
302
+ const distinct = [...new Set(symbols)].slice(0, 3);
303
+ const firstLine = (content.split('\n').find(l => l.trim().length > 0) || '').trim().slice(0, 80);
304
+ const summary = distinct.length > 0
305
+ ? `${distinct.join(', ')}${firstLine ? ` - ${firstLine}` : ''}`
306
+ : firstLine || basename(rel);
307
+ perFileSummary.set(rel, summary);
308
+ }
309
+
310
+ const scores = tfidfScore(perFileTokens);
311
+ const ranked = [...perFileTokens.keys()]
312
+ .map(p => ({ path: p, summary: perFileSummary.get(p) || basename(p), importance: scores.get(p) || 0 }))
313
+ .sort((a, b) => b.importance - a.importance);
314
+
315
+ const limited = ranked.slice(0, maxFiles);
316
+
317
+ const out = [];
318
+ let running = 0;
319
+ let truncated = false;
320
+ for (const entry of limited) {
321
+ const line = `${entry.path}: ${entry.summary}\n`;
322
+ const cost = Math.ceil(line.length * TOKENS_PER_CHAR);
323
+ if (running + cost > budgetTokens) {
324
+ truncated = true;
325
+ break;
326
+ }
327
+ out.push(entry);
328
+ running += cost;
329
+ }
330
+
331
+ return { files: out, totalTokens: running, truncated, scannedCount };
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Public API: compactBriefForSubagent
336
+ // ---------------------------------------------------------------------------
337
+
338
+ /**
339
+ * Inject a repo-map prefix block in front of a subagent brief.
340
+ *
341
+ * @param {object} args
342
+ * @param {string} args.baseBrief
343
+ * @param {object} args.repoMap - output of buildRepoMap()
344
+ * @param {number} [args.maxPrefixTokens=1000]
345
+ * @returns {{ brief: string, repoMapTokens: number, baseBriefTokens: number }}
346
+ */
347
+ export function compactBriefForSubagent({ baseBrief, repoMap, maxPrefixTokens = DEFAULT_BUDGET_TOKENS } = {}) {
348
+ if (typeof baseBrief !== 'string') {
349
+ throw new TypeError('compactBriefForSubagent: baseBrief must be a string');
350
+ }
351
+ if (!repoMap || !Array.isArray(repoMap.files)) {
352
+ return {
353
+ brief: baseBrief,
354
+ repoMapTokens: 0,
355
+ baseBriefTokens: Math.ceil(baseBrief.length * TOKENS_PER_CHAR),
356
+ };
357
+ }
358
+
359
+ const header = '--- REPO MAP (importance-ranked, regex-extracted) ---\n';
360
+ const footer = '--- END REPO MAP ---\n\n';
361
+ const fixedCost = Math.ceil((header.length + footer.length) * TOKENS_PER_CHAR);
362
+ let budget = Math.max(0, maxPrefixTokens - fixedCost);
363
+
364
+ const lines = [];
365
+ let running = 0;
366
+ for (const f of repoMap.files) {
367
+ const line = `${f.path}: ${f.summary}\n`;
368
+ const cost = Math.ceil(line.length * TOKENS_PER_CHAR);
369
+ if (running + cost > budget) break;
370
+ lines.push(line);
371
+ running += cost;
372
+ }
373
+
374
+ const prefix = lines.length > 0
375
+ ? header + lines.join('') + footer
376
+ : '';
377
+
378
+ const brief = prefix + baseBrief;
379
+ return {
380
+ brief,
381
+ repoMapTokens: prefix.length > 0 ? Math.ceil(prefix.length * TOKENS_PER_CHAR) : 0,
382
+ baseBriefTokens: Math.ceil(baseBrief.length * TOKENS_PER_CHAR),
383
+ };
384
+ }
385
+
386
+ // Constants exported for tests + docs.
387
+ export const REPO_MAP_DEFAULTS = {
388
+ budgetTokens: DEFAULT_BUDGET_TOKENS,
389
+ maxFiles: DEFAULT_MAX_FILES,
390
+ tokensPerChar: TOKENS_PER_CHAR,
391
+ extensions: DEFAULT_EXTENSIONS,
392
+ };
@@ -0,0 +1,164 @@
1
+ // shasum-verify.js -- cross-verify a target npm release against the GitLab
2
+ // release asset shasum. Second-factor integrity check on top of
3
+ // `npm audit signatures` (F-SEC-7, v1.5.0 audit-H2.2).
4
+ //
5
+ // THREAT MODEL
6
+ // `npm audit signatures` proves the tarball was signed by the package's
7
+ // npm registry signing keys. This module independently fetches the
8
+ // shasum the publisher recorded on the GitLab release page and compares
9
+ // it against the npm-reported tarball shasum. A divergence means either
10
+ // the npm registry is serving a tampered tarball, OR the GitLab release
11
+ // was tampered with, OR the publisher made an inconsistent release.
12
+ // In all cases: refuse to install.
13
+ //
14
+ // MODES
15
+ // verified -- npm and GitLab shasums both available and match.
16
+ // mismatch -- both available but DIFFER. Fail closed.
17
+ // advisory -- GitLab side missing (older release, no shasum published,
18
+ // or transient fetch failure). Caller decides whether to
19
+ // proceed: interactive prompts for confirmation, non-
20
+ // interactive must abort.
21
+ // error -- npm side missing (no shasum reported by `npm view`).
22
+ // This indicates a deeper problem; refuse to install.
23
+ //
24
+ // CALLER CONTRACT
25
+ // verifyShasumCrossSource(version, opts, deps) returns
26
+ // { ok: boolean, mode, npmShasum, releaseShasum, message }
27
+ // The orchestrator MUST refuse to install when ok === false.
28
+ // advisory mode returns ok: true with a `requiresConfirmation: true`
29
+ // flag so the orchestrator can prompt or fail-closed in non-interactive.
30
+
31
+ import { spawnSync } from 'node:child_process';
32
+
33
+ // Default GitLab project path. Kept here so tests can override.
34
+ export const DEFAULT_GITLAB_PROJECT = 'therealseandonahoe%2Fijfw';
35
+
36
+ // Hex shasum extractor. Accepts standalone hex lines (sha1=40 hex, sha256=64
37
+ // hex) or the common labelled forms used in release notes:
38
+ // shasum: <hex>
39
+ // sha1: <hex>
40
+ // sha256: <hex>
41
+ // sha512: <hex>
42
+ // Returns the first 40-or-more hex run that looks like a shasum.
43
+ // Case-insensitive comparison happens at compare time.
44
+ const SHASUM_LABEL_RE = /(?:shasum|sha-?(?:1|256|512))\s*[:=]\s*([a-f0-9]{40,128})/i;
45
+ const SHASUM_BARE_RE = /\b([a-f0-9]{40})\b/i; // sha1 length is what npm publishes
46
+ const SHASUM256_BARE_RE = /\b([a-f0-9]{64})\b/i;
47
+
48
+ export function extractShasumFromText(text) {
49
+ if (!text || typeof text !== 'string') return null;
50
+ const labelMatch = text.match(SHASUM_LABEL_RE);
51
+ if (labelMatch) return labelMatch[1].toLowerCase();
52
+ // Fall back to bare hex runs only if a label wasn't found. Prefer sha256
53
+ // first because sha1 has higher false-positive risk in narrative text.
54
+ const m256 = text.match(SHASUM256_BARE_RE);
55
+ if (m256) return m256[1].toLowerCase();
56
+ const m1 = text.match(SHASUM_BARE_RE);
57
+ if (m1) return m1[1].toLowerCase();
58
+ return null;
59
+ }
60
+
61
+ // Default deps: real npm + real curl. Tests inject pure-JS mocks.
62
+ const DEFAULT_DEPS = Object.freeze({
63
+ // (pkg, version) -> { ok, shasum, message }
64
+ fetchNpmShasum(pkg, version) {
65
+ const ref = `${pkg}@${version}`;
66
+ const r = spawnSync('npm', ['view', ref, 'dist.shasum', '--json'], {
67
+ encoding: 'utf8',
68
+ timeout: 15_000,
69
+ shell: process.platform === 'win32',
70
+ });
71
+ if (r.error) return { ok: false, message: `spawn-${r.error.code || 'unknown'}` };
72
+ if (r.signal) return { ok: false, message: `killed by ${r.signal}` };
73
+ if (r.status !== 0) {
74
+ const stderr = (r.stderr || '').trim();
75
+ return { ok: false, message: stderr || `npm view exited ${r.status}` };
76
+ }
77
+ // npm view ... --json returns the string wrapped in quotes
78
+ const raw = (r.stdout || '').trim().replace(/^"|"$/g, '');
79
+ if (!/^[a-f0-9]{40}$/i.test(raw)) {
80
+ return { ok: false, message: `npm returned non-shasum: ${raw.slice(0, 80)}` };
81
+ }
82
+ return { ok: true, shasum: raw.toLowerCase() };
83
+ },
84
+ // (project, version) -> { ok, body, message }
85
+ fetchGitlabReleaseBody(project, version) {
86
+ const url = `https://gitlab.com/api/v4/projects/${project}/releases/v${version}`;
87
+ const r = spawnSync('curl', ['-fsSL', '-H', 'User-Agent: ijfw', url], {
88
+ encoding: 'utf8',
89
+ timeout: 10_000,
90
+ });
91
+ if (r.error) return { ok: false, message: `spawn-${r.error.code || 'unknown'}` };
92
+ if (r.signal) return { ok: false, message: `killed by ${r.signal}` };
93
+ if (r.status !== 0) {
94
+ return { ok: false, message: `curl exited ${r.status}` };
95
+ }
96
+ try {
97
+ const data = JSON.parse(r.stdout || '{}');
98
+ return { ok: true, body: data.description || '' };
99
+ } catch {
100
+ return { ok: false, message: 'release JSON parse failed' };
101
+ }
102
+ },
103
+ });
104
+
105
+ // version: semver string (validated by caller)
106
+ // opts: { pkg = '@ijfw/install', project = DEFAULT_GITLAB_PROJECT }
107
+ // deps: optional mock injection
108
+ export function verifyShasumCrossSource(version, opts = {}, deps = DEFAULT_DEPS) {
109
+ const pkg = opts.pkg || '@ijfw/install';
110
+ const project = opts.project || DEFAULT_GITLAB_PROJECT;
111
+ const fetchNpm = deps.fetchNpmShasum || DEFAULT_DEPS.fetchNpmShasum;
112
+ const fetchRelease = deps.fetchGitlabReleaseBody || DEFAULT_DEPS.fetchGitlabReleaseBody;
113
+
114
+ const npmRes = fetchNpm(pkg, version);
115
+ if (!npmRes.ok) {
116
+ return {
117
+ ok: false,
118
+ mode: 'error',
119
+ npmShasum: null,
120
+ releaseShasum: null,
121
+ message: `npm shasum lookup failed: ${npmRes.message}`,
122
+ };
123
+ }
124
+ const npmShasum = String(npmRes.shasum || '').toLowerCase();
125
+
126
+ const releaseRes = fetchRelease(project, version);
127
+ if (!releaseRes.ok) {
128
+ return {
129
+ ok: true,
130
+ mode: 'advisory',
131
+ requiresConfirmation: true,
132
+ npmShasum,
133
+ releaseShasum: null,
134
+ message: `release shasum unavailable (${releaseRes.message}); proceed only with explicit confirmation`,
135
+ };
136
+ }
137
+ const releaseShasum = extractShasumFromText(releaseRes.body);
138
+ if (!releaseShasum) {
139
+ return {
140
+ ok: true,
141
+ mode: 'advisory',
142
+ requiresConfirmation: true,
143
+ npmShasum,
144
+ releaseShasum: null,
145
+ message: 'GitLab release does not list a shasum for this version; proceed only with explicit confirmation',
146
+ };
147
+ }
148
+ if (releaseShasum.toLowerCase() !== npmShasum) {
149
+ return {
150
+ ok: false,
151
+ mode: 'mismatch',
152
+ npmShasum,
153
+ releaseShasum: releaseShasum.toLowerCase(),
154
+ message: `shasum mismatch: npm=${npmShasum} release=${releaseShasum.toLowerCase()}`,
155
+ };
156
+ }
157
+ return {
158
+ ok: true,
159
+ mode: 'verified',
160
+ npmShasum,
161
+ releaseShasum: releaseShasum.toLowerCase(),
162
+ message: 'shasum cross-verified (npm dist.shasum matches GitLab release asset)',
163
+ };
164
+ }
@@ -0,0 +1,132 @@
1
+ // sketches-gc.js -- v1.5.0 audit-MED-design-#9.
2
+ //
3
+ // Auto-archive sketches older than N days from .planning/sketches/ to
4
+ // .planning/sketches/.archive/. Idempotent. Zero deps. Pure stdlib.
5
+ //
6
+ // Design notes:
7
+ // - "Sketches" are throwaway HTML mockups under .planning/sketches/<name>/.
8
+ // Without GC they accumulate indefinitely -- the audit MED.
9
+ // - Default age: 30 days (configurable via opts.maxAgeMs).
10
+ // - Archive layout: .planning/sketches/.archive/<original-name>/. If a name
11
+ // collision happens (re-archived twice), suffix with timestamp.
12
+ // - mtime is used (not ctime) -- editing a sketch keeps it fresh.
13
+ // - Walks ONE level deep. Each top-level entry under sketches/ is either a
14
+ // directory (a sketch) or a stray file (also archived).
15
+ // - Returns {archived: [{from, to}], skipped: [{path, reason}], scannedAt}.
16
+ //
17
+ // Used by:
18
+ // - `ijfw run sketches-gc [--root <dir>] [--max-age-days <n>] [--dry-run]`
19
+ // via cross-orchestrator-cli.js
20
+ // - Any cron / hook that wants a maintenance pass.
21
+
22
+ import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from 'node:fs';
23
+ import { join, basename } from 'node:path';
24
+
25
+ const DEFAULT_MAX_AGE_DAYS = 30;
26
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
27
+
28
+ /**
29
+ * Run a GC pass over a sketches directory.
30
+ *
31
+ * @param {object} opts
32
+ * @param {string} [opts.root] Sketches root. Default: .planning/sketches relative to cwd.
33
+ * @param {number} [opts.maxAgeMs] Max age in ms. Default: 30 days.
34
+ * @param {number} [opts.maxAgeDays] Convenience -- overrides maxAgeMs when set.
35
+ * @param {boolean} [opts.dryRun] When true, return the plan but do not move anything.
36
+ * @param {Date} [opts.now] Reference "now" -- injected for tests.
37
+ * @returns {{archived: Array<{from:string,to:string,ageDays:number}>, skipped: Array<{path:string,reason:string}>, scannedAt:string, archiveDir:string, root:string}}
38
+ */
39
+ export function runSketchesGc(opts = {}) {
40
+ const root = opts.root || join(process.cwd(), '.planning', 'sketches');
41
+ const now = opts.now instanceof Date ? opts.now : new Date();
42
+ const maxAgeMs =
43
+ typeof opts.maxAgeDays === 'number'
44
+ ? opts.maxAgeDays * MS_PER_DAY
45
+ : typeof opts.maxAgeMs === 'number'
46
+ ? opts.maxAgeMs
47
+ : DEFAULT_MAX_AGE_DAYS * MS_PER_DAY;
48
+ const dryRun = opts.dryRun === true;
49
+
50
+ const archived = [];
51
+ const skipped = [];
52
+ const archiveDir = join(root, '.archive');
53
+ const scannedAt = now.toISOString();
54
+
55
+ if (!existsSync(root)) {
56
+ return { archived, skipped, scannedAt, archiveDir, root };
57
+ }
58
+
59
+ // Ensure archive dir exists (unless dry-run, then we just record intent).
60
+ if (!dryRun && !existsSync(archiveDir)) {
61
+ mkdirSync(archiveDir, { recursive: true, mode: 0o755 });
62
+ }
63
+
64
+ let entries;
65
+ try {
66
+ entries = readdirSync(root, { withFileTypes: true });
67
+ } catch (e) {
68
+ return { archived, skipped: [{ path: root, reason: `readdir-failed: ${e.code || e.message}` }], scannedAt, archiveDir, root };
69
+ }
70
+
71
+ for (const entry of entries) {
72
+ const name = entry.name;
73
+ // Skip the archive dir itself + dotfiles (hidden state, READMEs, etc.).
74
+ if (name === '.archive') continue;
75
+ if (name.startsWith('.')) {
76
+ skipped.push({ path: join(root, name), reason: 'dotfile' });
77
+ continue;
78
+ }
79
+ const src = join(root, name);
80
+ let st;
81
+ try {
82
+ st = statSync(src);
83
+ } catch (e) {
84
+ skipped.push({ path: src, reason: `stat-failed: ${e.code || e.message}` });
85
+ continue;
86
+ }
87
+ const ageMs = now.getTime() - st.mtimeMs;
88
+ if (ageMs < maxAgeMs) {
89
+ skipped.push({ path: src, reason: `fresh (age ${Math.round(ageMs / MS_PER_DAY)}d)` });
90
+ continue;
91
+ }
92
+
93
+ // Resolve destination. Collision -> suffix with timestamp.
94
+ let dest = join(archiveDir, name);
95
+ if (existsSync(dest)) {
96
+ const stamp = now.toISOString().replace(/[:.]/g, '-');
97
+ dest = join(archiveDir, `${name}.${stamp}`);
98
+ }
99
+
100
+ if (dryRun) {
101
+ archived.push({ from: src, to: dest, ageDays: Math.round(ageMs / MS_PER_DAY) });
102
+ continue;
103
+ }
104
+
105
+ try {
106
+ renameSync(src, dest);
107
+ archived.push({ from: src, to: dest, ageDays: Math.round(ageMs / MS_PER_DAY) });
108
+ } catch (e) {
109
+ skipped.push({ path: src, reason: `rename-failed: ${e.code || e.message}` });
110
+ }
111
+ }
112
+
113
+ return { archived, skipped, scannedAt, archiveDir, root };
114
+ }
115
+
116
+ /**
117
+ * Human-readable summary of a GC result -- one line per category.
118
+ * @param {ReturnType<typeof runSketchesGc>} result
119
+ */
120
+ export function formatGcResult(result) {
121
+ const lines = [];
122
+ lines.push(`sketches-gc -- scanned ${result.root} at ${result.scannedAt}`);
123
+ lines.push(` archived: ${result.archived.length}`);
124
+ for (const a of result.archived) {
125
+ lines.push(` ${basename(a.from)} (${a.ageDays}d) -> ${a.to}`);
126
+ }
127
+ lines.push(` skipped: ${result.skipped.length}`);
128
+ for (const s of result.skipped) {
129
+ lines.push(` ${basename(s.path)} -- ${s.reason}`);
130
+ }
131
+ return lines.join('\n');
132
+ }