gsd-opencode 1.33.2 → 1.35.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 (130) hide show
  1. package/agents/gsd-advisor-researcher.md +23 -0
  2. package/agents/gsd-ai-researcher.md +142 -0
  3. package/agents/gsd-code-fixer.md +523 -0
  4. package/agents/gsd-code-reviewer.md +361 -0
  5. package/agents/gsd-debugger.md +14 -1
  6. package/agents/gsd-domain-researcher.md +162 -0
  7. package/agents/gsd-eval-auditor.md +170 -0
  8. package/agents/gsd-eval-planner.md +161 -0
  9. package/agents/gsd-executor.md +70 -7
  10. package/agents/gsd-framework-selector.md +167 -0
  11. package/agents/gsd-intel-updater.md +320 -0
  12. package/agents/gsd-phase-researcher.md +26 -0
  13. package/agents/gsd-plan-checker.md +12 -0
  14. package/agents/gsd-planner.md +16 -6
  15. package/agents/gsd-project-researcher.md +23 -0
  16. package/agents/gsd-ui-researcher.md +23 -0
  17. package/agents/gsd-verifier.md +55 -1
  18. package/commands/gsd/gsd-add-backlog.md +1 -1
  19. package/commands/gsd/gsd-add-phase.md +1 -1
  20. package/commands/gsd/gsd-add-todo.md +1 -1
  21. package/commands/gsd/gsd-ai-integration-phase.md +36 -0
  22. package/commands/gsd/gsd-audit-fix.md +33 -0
  23. package/commands/gsd/gsd-autonomous.md +1 -0
  24. package/commands/gsd/gsd-check-todos.md +1 -1
  25. package/commands/gsd/gsd-code-review-fix.md +52 -0
  26. package/commands/gsd/gsd-code-review.md +55 -0
  27. package/commands/gsd/gsd-complete-milestone.md +1 -1
  28. package/commands/gsd/gsd-debug.md +1 -1
  29. package/commands/gsd/gsd-eval-review.md +32 -0
  30. package/commands/gsd/gsd-explore.md +27 -0
  31. package/commands/gsd/gsd-from-gsd2.md +45 -0
  32. package/commands/gsd/gsd-health.md +1 -1
  33. package/commands/gsd/gsd-import.md +36 -0
  34. package/commands/gsd/gsd-insert-phase.md +1 -1
  35. package/commands/gsd/gsd-intel.md +183 -0
  36. package/commands/gsd/gsd-manager.md +1 -1
  37. package/commands/gsd/gsd-next.md +2 -0
  38. package/commands/gsd/gsd-reapply-patches.md +58 -3
  39. package/commands/gsd/gsd-remove-phase.md +1 -1
  40. package/commands/gsd/gsd-review.md +4 -2
  41. package/commands/gsd/gsd-scan.md +26 -0
  42. package/commands/gsd/gsd-set-profile.md +1 -1
  43. package/commands/gsd/gsd-thread.md +1 -1
  44. package/commands/gsd/gsd-undo.md +34 -0
  45. package/commands/gsd/gsd-workstreams.md +6 -6
  46. package/get-shit-done/bin/gsd-tools.cjs +143 -5
  47. package/get-shit-done/bin/lib/commands.cjs +10 -2
  48. package/get-shit-done/bin/lib/config.cjs +71 -37
  49. package/get-shit-done/bin/lib/core.cjs +70 -8
  50. package/get-shit-done/bin/lib/gsd2-import.cjs +511 -0
  51. package/get-shit-done/bin/lib/init.cjs +20 -6
  52. package/get-shit-done/bin/lib/intel.cjs +660 -0
  53. package/get-shit-done/bin/lib/learnings.cjs +378 -0
  54. package/get-shit-done/bin/lib/milestone.cjs +25 -15
  55. package/get-shit-done/bin/lib/model-profiles.cjs +17 -17
  56. package/get-shit-done/bin/lib/phase.cjs +148 -112
  57. package/get-shit-done/bin/lib/roadmap.cjs +12 -5
  58. package/get-shit-done/bin/lib/security.cjs +119 -0
  59. package/get-shit-done/bin/lib/state.cjs +283 -221
  60. package/get-shit-done/bin/lib/template.cjs +8 -4
  61. package/get-shit-done/bin/lib/verify.cjs +42 -5
  62. package/get-shit-done/references/ai-evals.md +156 -0
  63. package/get-shit-done/references/ai-frameworks.md +186 -0
  64. package/get-shit-done/references/common-bug-patterns.md +114 -0
  65. package/get-shit-done/references/few-shot-examples/plan-checker.md +73 -0
  66. package/get-shit-done/references/few-shot-examples/verifier.md +109 -0
  67. package/get-shit-done/references/gates.md +70 -0
  68. package/get-shit-done/references/ios-scaffold.md +123 -0
  69. package/get-shit-done/references/model-profile-resolution.md +6 -7
  70. package/get-shit-done/references/model-profiles.md +20 -14
  71. package/get-shit-done/references/planning-config.md +237 -0
  72. package/get-shit-done/references/thinking-models-debug.md +44 -0
  73. package/get-shit-done/references/thinking-models-execution.md +50 -0
  74. package/get-shit-done/references/thinking-models-planning.md +62 -0
  75. package/get-shit-done/references/thinking-models-research.md +50 -0
  76. package/get-shit-done/references/thinking-models-verification.md +55 -0
  77. package/get-shit-done/references/thinking-partner.md +96 -0
  78. package/get-shit-done/references/universal-anti-patterns.md +6 -1
  79. package/get-shit-done/references/verification-overrides.md +227 -0
  80. package/get-shit-done/templates/AI-SPEC.md +246 -0
  81. package/get-shit-done/workflows/add-tests.md +3 -0
  82. package/get-shit-done/workflows/add-todo.md +2 -0
  83. package/get-shit-done/workflows/ai-integration-phase.md +284 -0
  84. package/get-shit-done/workflows/audit-fix.md +154 -0
  85. package/get-shit-done/workflows/autonomous.md +33 -2
  86. package/get-shit-done/workflows/check-todos.md +2 -0
  87. package/get-shit-done/workflows/cleanup.md +2 -0
  88. package/get-shit-done/workflows/code-review-fix.md +497 -0
  89. package/get-shit-done/workflows/code-review.md +515 -0
  90. package/get-shit-done/workflows/complete-milestone.md +40 -15
  91. package/get-shit-done/workflows/diagnose-issues.md +1 -1
  92. package/get-shit-done/workflows/discovery-phase.md +3 -1
  93. package/get-shit-done/workflows/discuss-phase-assumptions.md +1 -1
  94. package/get-shit-done/workflows/discuss-phase.md +21 -7
  95. package/get-shit-done/workflows/do.md +2 -0
  96. package/get-shit-done/workflows/docs-update.md +2 -0
  97. package/get-shit-done/workflows/eval-review.md +155 -0
  98. package/get-shit-done/workflows/execute-phase.md +307 -57
  99. package/get-shit-done/workflows/execute-plan.md +64 -93
  100. package/get-shit-done/workflows/explore.md +136 -0
  101. package/get-shit-done/workflows/help.md +1 -1
  102. package/get-shit-done/workflows/import.md +273 -0
  103. package/get-shit-done/workflows/inbox.md +387 -0
  104. package/get-shit-done/workflows/manager.md +4 -10
  105. package/get-shit-done/workflows/new-milestone.md +3 -1
  106. package/get-shit-done/workflows/new-project.md +2 -0
  107. package/get-shit-done/workflows/new-workspace.md +2 -0
  108. package/get-shit-done/workflows/next.md +56 -0
  109. package/get-shit-done/workflows/note.md +2 -0
  110. package/get-shit-done/workflows/plan-phase.md +97 -17
  111. package/get-shit-done/workflows/plant-seed.md +3 -0
  112. package/get-shit-done/workflows/pr-branch.md +41 -13
  113. package/get-shit-done/workflows/profile-user.md +4 -2
  114. package/get-shit-done/workflows/quick.md +99 -4
  115. package/get-shit-done/workflows/remove-workspace.md +2 -0
  116. package/get-shit-done/workflows/review.md +53 -6
  117. package/get-shit-done/workflows/scan.md +98 -0
  118. package/get-shit-done/workflows/secure-phase.md +2 -0
  119. package/get-shit-done/workflows/settings.md +18 -3
  120. package/get-shit-done/workflows/ship.md +3 -0
  121. package/get-shit-done/workflows/ui-phase.md +10 -2
  122. package/get-shit-done/workflows/ui-review.md +2 -0
  123. package/get-shit-done/workflows/undo.md +314 -0
  124. package/get-shit-done/workflows/update.md +2 -0
  125. package/get-shit-done/workflows/validate-phase.md +2 -0
  126. package/get-shit-done/workflows/verify-phase.md +83 -0
  127. package/get-shit-done/workflows/verify-work.md +12 -1
  128. package/package.json +1 -1
  129. package/skills/gsd-code-review/SKILL.md +48 -0
  130. package/skills/gsd-code-review-fix/SKILL.md +44 -0
@@ -870,6 +870,23 @@ function cmdInitManager(cwd, raw) {
870
870
  const phasesDir = paths.phases;
871
871
  const isDirInMilestone = getMilestonePhaseFilter(cwd);
872
872
 
873
+ // Pre-compute directory listing once (avoids O(N) readdirSync per phase)
874
+ const _phaseDirEntries = (() => {
875
+ try {
876
+ return fs.readdirSync(phasesDir, { withFileTypes: true })
877
+ .filter(e => e.isDirectory())
878
+ .map(e => e.name);
879
+ } catch { return []; }
880
+ })();
881
+
882
+ // Pre-extract all checkbox states in a single pass (avoids O(N) regex per phase)
883
+ const _checkboxStates = new Map();
884
+ const _cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
885
+ let _cbMatch;
886
+ while ((_cbMatch = _cbPattern.exec(content)) !== null) {
887
+ _checkboxStates.set(_cbMatch[2], _cbMatch[1].toLowerCase() === 'x');
888
+ }
889
+
873
890
  const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
874
891
  const phases = [];
875
892
  let match;
@@ -900,8 +917,7 @@ function cmdInitManager(cwd, raw) {
900
917
  let isActive = false;
901
918
 
902
919
  try {
903
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
904
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(isDirInMilestone);
920
+ const dirs = _phaseDirEntries.filter(isDirInMilestone);
905
921
  const dirMatch = dirs.find(d => phaseTokenMatches(d, normalized));
906
922
 
907
923
  if (dirMatch) {
@@ -935,10 +951,8 @@ function cmdInitManager(cwd, raw) {
935
951
  }
936
952
  } catch { /* intentionally empty */ }
937
953
 
938
- // Check ROADMAP checkbox status
939
- const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseNum.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[:\\s]`, 'i');
940
- const checkboxMatch = content.match(checkboxPattern);
941
- const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
954
+ // Check ROADMAP checkbox status (pre-extracted above the loop)
955
+ const roadmapComplete = _checkboxStates.get(phaseNum) || false;
942
956
  if (roadmapComplete && diskStatus !== 'complete') {
943
957
  diskStatus = 'complete';
944
958
  }
@@ -0,0 +1,660 @@
1
+ /**
2
+ * lib/intel.cjs -- Intel storage and query operations for GSD.
3
+ *
4
+ * Provides a persistent, queryable intelligence system for project metadata.
5
+ * Intel files live in .planning/intel/ and store structured data about
6
+ * the project's files, APIs, dependencies, architecture, and tech stack.
7
+ *
8
+ * All public functions gate on intel.enabled config (no-op when false).
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const crypto = require('crypto');
16
+
17
+ // ─── Constants ───────────────────────────────────────────────────────────────
18
+
19
+ const INTEL_DIR = '.planning/intel';
20
+
21
+ const INTEL_FILES = {
22
+ files: 'files.json',
23
+ apis: 'apis.json',
24
+ deps: 'deps.json',
25
+ arch: 'arch.md',
26
+ stack: 'stack.json'
27
+ };
28
+
29
+ // ─── Internal helpers ────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Ensure the intel directory exists under the given planning dir.
33
+ *
34
+ * @param {string} planningDir - Path to .planning directory
35
+ * @returns {string} Full path to .planning/intel/
36
+ */
37
+ function ensureIntelDir(planningDir) {
38
+ const intelPath = path.join(planningDir, 'intel');
39
+ if (!fs.existsSync(intelPath)) {
40
+ fs.mkdirSync(intelPath, { recursive: true });
41
+ }
42
+ return intelPath;
43
+ }
44
+
45
+ /**
46
+ * Check whether intel is enabled in the project config.
47
+ * Reads config.json directly via fs. Returns false by default
48
+ * (when no config, no intel key, or on error).
49
+ *
50
+ * @param {string} planningDir - Path to .planning directory
51
+ * @returns {boolean}
52
+ */
53
+ function isIntelEnabled(planningDir) {
54
+ try {
55
+ const configPath = path.join(planningDir, 'config.json');
56
+ if (!fs.existsSync(configPath)) return false;
57
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
58
+ if (config && config.intel && config.intel.enabled === true) return true;
59
+ return false;
60
+ } catch (_e) {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Return the standard disabled response object.
67
+ * @returns {{ disabled: true, message: string }}
68
+ */
69
+ function disabledResponse() {
70
+ return { disabled: true, message: 'Intel system disabled. Set intel.enabled=true in config.json to activate.' };
71
+ }
72
+
73
+ /**
74
+ * Resolve full path to an intel file.
75
+ * @param {string} planningDir
76
+ * @param {string} filename
77
+ * @returns {string}
78
+ */
79
+ function intelFilePath(planningDir, filename) {
80
+ return path.join(planningDir, 'intel', filename);
81
+ }
82
+
83
+ /**
84
+ * Safely read and parse a JSON intel file.
85
+ * Returns null if file doesn't exist or can't be parsed.
86
+ *
87
+ * @param {string} filePath
88
+ * @returns {object|null}
89
+ */
90
+ function safeReadJson(filePath) {
91
+ try {
92
+ if (!fs.existsSync(filePath)) return null;
93
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
94
+ } catch (_e) {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Compute SHA-256 hash of a file's contents.
101
+ * Returns null if the file doesn't exist.
102
+ *
103
+ * @param {string} filePath
104
+ * @returns {string|null}
105
+ */
106
+ function hashFile(filePath) {
107
+ try {
108
+ if (!fs.existsSync(filePath)) return null;
109
+ const content = fs.readFileSync(filePath, 'utf8');
110
+ return crypto.createHash('sha256').update(content).digest('hex');
111
+ } catch (_e) {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Search for a term (case-insensitive) in a JSON object's keys and string values.
118
+ * Returns an array of matching entries.
119
+ *
120
+ * @param {object} data - The JSON data (expects { _meta, entries } or flat object)
121
+ * @param {string} term - Search term
122
+ * @returns {Array<{ key: string, value: * }>}
123
+ */
124
+ function searchJsonEntries(data, term) {
125
+ if (!data || typeof data !== 'object') return [];
126
+
127
+ const entries = data.entries || data;
128
+ if (!entries || typeof entries !== 'object') return [];
129
+
130
+ const lowerTerm = term.toLowerCase();
131
+ const matches = [];
132
+
133
+ for (const [key, value] of Object.entries(entries)) {
134
+ if (key === '_meta') continue;
135
+
136
+ // Check key match
137
+ if (key.toLowerCase().includes(lowerTerm)) {
138
+ matches.push({ key, value });
139
+ continue;
140
+ }
141
+
142
+ // Check string value match (recursive for objects)
143
+ if (matchesInValue(value, lowerTerm)) {
144
+ matches.push({ key, value });
145
+ }
146
+ }
147
+
148
+ return matches;
149
+ }
150
+
151
+ /**
152
+ * Recursively check if a term appears in any string value.
153
+ *
154
+ * @param {*} value
155
+ * @param {string} lowerTerm
156
+ * @returns {boolean}
157
+ */
158
+ function matchesInValue(value, lowerTerm) {
159
+ if (typeof value === 'string') {
160
+ return value.toLowerCase().includes(lowerTerm);
161
+ }
162
+ if (Array.isArray(value)) {
163
+ return value.some(v => matchesInValue(v, lowerTerm));
164
+ }
165
+ if (value && typeof value === 'object') {
166
+ return Object.values(value).some(v => matchesInValue(v, lowerTerm));
167
+ }
168
+ return false;
169
+ }
170
+
171
+ /**
172
+ * Search for a term in arch.md text content.
173
+ * Returns matching lines.
174
+ *
175
+ * @param {string} filePath - Path to arch.md
176
+ * @param {string} term - Search term
177
+ * @returns {string[]}
178
+ */
179
+ function searchArchMd(filePath, term) {
180
+ try {
181
+ if (!fs.existsSync(filePath)) return [];
182
+ const content = fs.readFileSync(filePath, 'utf8');
183
+ const lowerTerm = term.toLowerCase();
184
+ const lines = content.split(/\r?\n/);
185
+ return lines.filter(line => line.toLowerCase().includes(lowerTerm));
186
+ } catch (_e) {
187
+ return [];
188
+ }
189
+ }
190
+
191
+ // ─── Public API ──────────────────────────────────────────────────────────────
192
+
193
+ /**
194
+ * Query intel files for a search term.
195
+ * Searches across all JSON intel files (keys and values) and arch.md (text lines).
196
+ *
197
+ * @param {string} term - Search term (case-insensitive)
198
+ * @param {string} planningDir - Path to .planning directory
199
+ * @returns {{ matches: Array<{ source: string, entries: Array }>, term: string, total: number } | { disabled: true, message: string }}
200
+ */
201
+ function intelQuery(term, planningDir) {
202
+ if (!isIntelEnabled(planningDir)) return disabledResponse();
203
+
204
+ const matches = [];
205
+ let total = 0;
206
+
207
+ // Search JSON intel files
208
+ for (const [_key, filename] of Object.entries(INTEL_FILES)) {
209
+ if (filename.endsWith('.md')) continue; // Skip arch.md here
210
+
211
+ const filePath = intelFilePath(planningDir, filename);
212
+ const data = safeReadJson(filePath);
213
+ if (!data) continue;
214
+
215
+ const found = searchJsonEntries(data, term);
216
+ if (found.length > 0) {
217
+ matches.push({ source: filename, entries: found });
218
+ total += found.length;
219
+ }
220
+ }
221
+
222
+ // Search arch.md
223
+ const archPath = intelFilePath(planningDir, INTEL_FILES.arch);
224
+ const archMatches = searchArchMd(archPath, term);
225
+ if (archMatches.length > 0) {
226
+ matches.push({ source: INTEL_FILES.arch, entries: archMatches });
227
+ total += archMatches.length;
228
+ }
229
+
230
+ return { matches, term, total };
231
+ }
232
+
233
+ /**
234
+ * Report status and staleness of each intel file.
235
+ * A file is considered stale if its updated_at is older than 24 hours.
236
+ *
237
+ * @param {string} planningDir - Path to .planning directory
238
+ * @returns {{ files: object, overall_stale: boolean } | { disabled: true, message: string }}
239
+ */
240
+ function intelStatus(planningDir) {
241
+ if (!isIntelEnabled(planningDir)) return disabledResponse();
242
+
243
+ const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
244
+ const now = Date.now();
245
+ const files = {};
246
+ let overallStale = false;
247
+
248
+ for (const [_key, filename] of Object.entries(INTEL_FILES)) {
249
+ const filePath = intelFilePath(planningDir, filename);
250
+ const exists = fs.existsSync(filePath);
251
+
252
+ if (!exists) {
253
+ files[filename] = { exists: false, updated_at: null, stale: true };
254
+ overallStale = true;
255
+ continue;
256
+ }
257
+
258
+ let updatedAt = null;
259
+
260
+ if (filename.endsWith('.md')) {
261
+ // For arch.md, use file mtime
262
+ try {
263
+ const stat = fs.statSync(filePath);
264
+ updatedAt = stat.mtime.toISOString();
265
+ } catch (_e) {
266
+ // intentionally silent: fall through on error
267
+ }
268
+ } else {
269
+ // For JSON files, read _meta.updated_at
270
+ const data = safeReadJson(filePath);
271
+ if (data && data._meta && data._meta.updated_at) {
272
+ updatedAt = data._meta.updated_at;
273
+ }
274
+ }
275
+
276
+ let stale = true;
277
+ if (updatedAt) {
278
+ const age = now - new Date(updatedAt).getTime();
279
+ stale = age > STALE_MS;
280
+ }
281
+
282
+ if (stale) overallStale = true;
283
+ files[filename] = { exists: true, updated_at: updatedAt, stale };
284
+ }
285
+
286
+ return { files, overall_stale: overallStale };
287
+ }
288
+
289
+ /**
290
+ * Show changes since the last full refresh by comparing file hashes.
291
+ *
292
+ * @param {string} planningDir - Path to .planning directory
293
+ * @returns {{ changed: string[], added: string[], removed: string[] } | { no_baseline: true } | { disabled: true, message: string }}
294
+ */
295
+ function intelDiff(planningDir) {
296
+ if (!isIntelEnabled(planningDir)) return disabledResponse();
297
+
298
+ const snapshotPath = intelFilePath(planningDir, '.last-refresh.json');
299
+ const snapshot = safeReadJson(snapshotPath);
300
+
301
+ if (!snapshot) {
302
+ return { no_baseline: true };
303
+ }
304
+
305
+ const prevHashes = snapshot.hashes || {};
306
+ const changed = [];
307
+ const added = [];
308
+ const removed = [];
309
+
310
+ // Check current files against snapshot
311
+ for (const [_key, filename] of Object.entries(INTEL_FILES)) {
312
+ const filePath = intelFilePath(planningDir, filename);
313
+ const currentHash = hashFile(filePath);
314
+
315
+ if (currentHash && !prevHashes[filename]) {
316
+ added.push(filename);
317
+ } else if (currentHash && prevHashes[filename] && currentHash !== prevHashes[filename]) {
318
+ changed.push(filename);
319
+ } else if (!currentHash && prevHashes[filename]) {
320
+ removed.push(filename);
321
+ }
322
+ }
323
+
324
+ return { changed, added, removed };
325
+ }
326
+
327
+ /**
328
+ * Stub for triggering an intel update.
329
+ * The actual update is performed by the intel-updater agent (PLAN-02).
330
+ *
331
+ * @param {string} planningDir - Path to .planning directory
332
+ * @returns {{ action: string, message: string } | { disabled: true, message: string }}
333
+ */
334
+ function intelUpdate(planningDir) {
335
+ if (!isIntelEnabled(planningDir)) return disabledResponse();
336
+
337
+ return {
338
+ action: 'spawn_agent',
339
+ message: 'Run gsd-tools intel update or spawn gsd-intel-updater agent for full refresh'
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Save a refresh snapshot with hashes of all current intel files.
345
+ * Called by the intel-updater agent after completing a refresh.
346
+ *
347
+ * @param {string} planningDir - Path to .planning directory
348
+ * @returns {{ saved: boolean, timestamp: string, files: number }}
349
+ */
350
+ function saveRefreshSnapshot(planningDir) {
351
+ const intelPath = ensureIntelDir(planningDir);
352
+ const hashes = {};
353
+ let fileCount = 0;
354
+
355
+ for (const [_key, filename] of Object.entries(INTEL_FILES)) {
356
+ const filePath = path.join(intelPath, filename);
357
+ const hash = hashFile(filePath);
358
+ if (hash) {
359
+ hashes[filename] = hash;
360
+ fileCount++;
361
+ }
362
+ }
363
+
364
+ const timestamp = new Date().toISOString();
365
+ const snapshotPath = path.join(intelPath, '.last-refresh.json');
366
+ fs.writeFileSync(snapshotPath, JSON.stringify({
367
+ hashes,
368
+ timestamp,
369
+ version: 1
370
+ }, null, 2), 'utf8');
371
+
372
+ return { saved: true, timestamp, files: fileCount };
373
+ }
374
+
375
+ // ─── CLI Subcommands ─────────────────────────────────────────────────────────
376
+
377
+ /**
378
+ * Thin wrapper around saveRefreshSnapshot for CLI dispatch.
379
+ * Writes .last-refresh.json with accurate timestamps and hashes.
380
+ *
381
+ * @param {string} planningDir - Path to .planning directory
382
+ * @returns {{ saved: boolean, timestamp: string, files: number } | { disabled: true, message: string }}
383
+ */
384
+ function intelSnapshot(planningDir) {
385
+ if (!isIntelEnabled(planningDir)) return disabledResponse();
386
+ return saveRefreshSnapshot(planningDir);
387
+ }
388
+
389
+ /**
390
+ * Validate all intel files for correctness and freshness.
391
+ *
392
+ * @param {string} planningDir - Path to .planning directory
393
+ * @returns {{ valid: boolean, errors: string[], warnings: string[] } | { disabled: true, message: string }}
394
+ */
395
+ function intelValidate(planningDir) {
396
+ if (!isIntelEnabled(planningDir)) return disabledResponse();
397
+
398
+ const errors = [];
399
+ const warnings = [];
400
+ const STALE_MS = 24 * 60 * 60 * 1000;
401
+ const now = Date.now();
402
+
403
+ for (const [key, filename] of Object.entries(INTEL_FILES)) {
404
+ const filePath = intelFilePath(planningDir, filename);
405
+
406
+ // Check existence
407
+ if (!fs.existsSync(filePath)) {
408
+ errors.push(`${filename}: file does not exist`);
409
+ continue;
410
+ }
411
+
412
+ // Skip non-JSON files (arch.md)
413
+ if (filename.endsWith('.md')) continue;
414
+
415
+ // Parse JSON
416
+ let data;
417
+ try {
418
+ data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
419
+ } catch (e) {
420
+ errors.push(`${filename}: invalid JSON — ${e.message}`);
421
+ continue;
422
+ }
423
+
424
+ // Check _meta.updated_at recency
425
+ if (data._meta && data._meta.updated_at) {
426
+ const age = now - new Date(data._meta.updated_at).getTime();
427
+ if (age > STALE_MS) {
428
+ warnings.push(`${filename}: _meta.updated_at is ${Math.round(age / 3600000)} hours old (>24 hr)`);
429
+ }
430
+ } else {
431
+ warnings.push(`${filename}: missing _meta.updated_at`);
432
+ }
433
+
434
+ // Validate entries are objects with expected fields
435
+ if (data.entries && typeof data.entries === 'object') {
436
+ // files.json: check exports are actual symbol names (no spaces)
437
+ if (key === 'files') {
438
+ for (const [entryPath, entry] of Object.entries(data.entries)) {
439
+ if (entry.exports && Array.isArray(entry.exports)) {
440
+ for (const exp of entry.exports) {
441
+ if (typeof exp === 'string' && exp.includes(' ')) {
442
+ warnings.push(`${filename}: "${entryPath}" export "${exp}" looks like a description (contains space)`);
443
+ }
444
+ }
445
+ }
446
+ }
447
+ // Spot-check first 5 file paths exist on disk
448
+ const entryPaths = Object.keys(data.entries).slice(0, 5);
449
+ for (const ep of entryPaths) {
450
+ if (!fs.existsSync(ep)) {
451
+ warnings.push(`${filename}: entry path "${ep}" does not exist on disk`);
452
+ }
453
+ }
454
+ }
455
+
456
+ // deps.json: check entries have version, type, used_by
457
+ if (key === 'deps') {
458
+ for (const [depName, entry] of Object.entries(data.entries)) {
459
+ const missing = [];
460
+ if (!entry.version) missing.push('version');
461
+ if (!entry.type) missing.push('type');
462
+ if (!entry.used_by) missing.push('used_by');
463
+ if (missing.length > 0) {
464
+ warnings.push(`${filename}: "${depName}" missing fields: ${missing.join(', ')}`);
465
+ }
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ return { valid: errors.length === 0, errors, warnings };
472
+ }
473
+
474
+ /**
475
+ * patch _meta.updated_at in a JSON intel file to the current timestamp.
476
+ * Reads the file, updates _meta.updated_at, increments version, writes back.
477
+ *
478
+ * NOTE: Does not gate on isIntelEnabled — operates on arbitrary file paths
479
+ * for use by agents patching individual files outside the intel store.
480
+ *
481
+ * @param {string} filePath - Absolute or relative path to the JSON intel file
482
+ * @returns {{ patched: boolean, file: string, timestamp: string } | { patched: false, error: string }}
483
+ */
484
+ function intelPatchMeta(filePath) {
485
+ try {
486
+ if (!fs.existsSync(filePath)) {
487
+ return { patched: false, error: `File not found: ${filePath}` };
488
+ }
489
+
490
+ const content = fs.readFileSync(filePath, 'utf8');
491
+ let data;
492
+ try {
493
+ data = JSON.parse(content);
494
+ } catch (e) {
495
+ return { patched: false, error: `Invalid JSON: ${e.message}` };
496
+ }
497
+
498
+ if (!data._meta) {
499
+ data._meta = {};
500
+ }
501
+
502
+ const timestamp = new Date().toISOString();
503
+ data._meta.updated_at = timestamp;
504
+ data._meta.version = (data._meta.version || 0) + 1;
505
+
506
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
507
+
508
+ return { patched: true, file: filePath, timestamp };
509
+ } catch (e) {
510
+ return { patched: false, error: e.message };
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Extract exports from a JS/CJS file by parsing module.exports or exports.X patterns.
516
+ *
517
+ * NOTE: Does not gate on isIntelEnabled — operates on arbitrary source files
518
+ * for use by agents building intel data from project files.
519
+ *
520
+ * @param {string} filePath - Path to the JS/CJS file
521
+ * @returns {{ file: string, exports: string[], method: string }}
522
+ */
523
+ function intelExtractExports(filePath) {
524
+ if (!fs.existsSync(filePath)) {
525
+ return { file: filePath, exports: [], method: 'none' };
526
+ }
527
+
528
+ const content = fs.readFileSync(filePath, 'utf8');
529
+ let exports = [];
530
+ let method = 'none';
531
+
532
+ // Try module.exports = { ... } pattern (handle multi-line)
533
+ // Find the LAST module.exports assignment (the actual one, not references in code)
534
+ const allMatches = [...content.matchAll(/module\.exports\s*=\s*\{/g)];
535
+ if (allMatches.length > 0) {
536
+ const lastMatch = allMatches[allMatches.length - 1];
537
+ const startIdx = lastMatch.index + lastMatch[0].length;
538
+ // Find matching closing brace by counting braces
539
+ let depth = 1;
540
+ let endIdx = startIdx;
541
+ while (endIdx < content.length && depth > 0) {
542
+ if (content[endIdx] === '{') depth++;
543
+ else if (content[endIdx] === '}') depth--;
544
+ if (depth > 0) endIdx++;
545
+ }
546
+ const block = content.substring(startIdx, endIdx);
547
+ method = 'module.exports';
548
+ // Extract key names from lines like " keyName," or " keyName: value,"
549
+ const lines = block.split('\n');
550
+ for (const line of lines) {
551
+ const trimmed = line.trim();
552
+ // Skip comments and empty lines
553
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
554
+ // Match identifier at start of line (before comma, colon, end of line)
555
+ const keyMatch = trimmed.match(/^(\w+)\s*[,}:]/) || trimmed.match(/^(\w+)$/);
556
+ if (keyMatch) {
557
+ exports.push(keyMatch[1]);
558
+ }
559
+ }
560
+ }
561
+
562
+ // Also try individual exports.X = patterns (only at start of line, not inside strings/regex)
563
+ const individualPattern = /^exports\.(\w+)\s*=/gm;
564
+ let im;
565
+ while ((im = individualPattern.exec(content)) !== null) {
566
+ if (!exports.includes(im[1])) {
567
+ exports.push(im[1]);
568
+ if (method === 'none') method = 'exports.X';
569
+ }
570
+ }
571
+
572
+ const hadCjs = exports.length > 0;
573
+
574
+ // ESM patterns
575
+ const esmExports = [];
576
+
577
+ // export default function X / export default class X
578
+ const defaultNamedPattern = /^export\s+default\s+(?:function|class)\s+(\w+)/gm;
579
+ let em;
580
+ while ((em = defaultNamedPattern.exec(content)) !== null) {
581
+ if (!esmExports.includes(em[1])) esmExports.push(em[1]);
582
+ }
583
+
584
+ // export default (without named function/class)
585
+ const defaultAnonPattern = /^export\s+default\s+(?!function\s|class\s)/gm;
586
+ if (defaultAnonPattern.test(content) && esmExports.length === 0) {
587
+ if (!esmExports.includes('default')) esmExports.push('default');
588
+ }
589
+
590
+ // export function X( / export async function X(
591
+ const exportFnPattern = /^export\s+(?:async\s+)?function\s+(\w+)\s*\(/gm;
592
+ while ((em = exportFnPattern.exec(content)) !== null) {
593
+ if (!esmExports.includes(em[1])) esmExports.push(em[1]);
594
+ }
595
+
596
+ // export const X = / export let X = / export var X =
597
+ const exportVarPattern = /^export\s+(?:const|let|var)\s+(\w+)\s*=/gm;
598
+ while ((em = exportVarPattern.exec(content)) !== null) {
599
+ if (!esmExports.includes(em[1])) esmExports.push(em[1]);
600
+ }
601
+
602
+ // export class X
603
+ const exportClassPattern = /^export\s+class\s+(\w+)/gm;
604
+ while ((em = exportClassPattern.exec(content)) !== null) {
605
+ if (!esmExports.includes(em[1])) esmExports.push(em[1]);
606
+ }
607
+
608
+ // export { X, Y, Z } — strip "as alias" parts
609
+ const exportBlockPattern = /^export\s*\{([^}]+)\}/gm;
610
+ while ((em = exportBlockPattern.exec(content)) !== null) {
611
+ const items = em[1].split(',');
612
+ for (const item of items) {
613
+ const trimmed = item.trim();
614
+ if (!trimmed) continue;
615
+ // "foo as bar" -> extract "foo"
616
+ const name = trimmed.split(/\s+as\s+/)[0].trim();
617
+ if (name && !esmExports.includes(name)) esmExports.push(name);
618
+ }
619
+ }
620
+
621
+ // Merge ESM exports into the result
622
+ for (const e of esmExports) {
623
+ if (!exports.includes(e)) exports.push(e);
624
+ }
625
+
626
+ // Determine method
627
+ const hadEsm = esmExports.length > 0;
628
+ if (hadCjs && hadEsm) {
629
+ method = 'mixed';
630
+ } else if (hadEsm && !hadCjs) {
631
+ method = 'esm';
632
+ }
633
+
634
+ return { file: filePath, exports, method };
635
+ }
636
+
637
+ // ─── Exports ─────────────────────────────────────────────────────────────────
638
+
639
+ module.exports = {
640
+ // Public API
641
+ intelQuery,
642
+ intelUpdate,
643
+ intelStatus,
644
+ intelDiff,
645
+ saveRefreshSnapshot,
646
+
647
+ // CLI subcommands
648
+ intelSnapshot,
649
+ intelValidate,
650
+ intelExtractExports,
651
+ intelPatchMeta,
652
+
653
+ // Utilities
654
+ ensureIntelDir,
655
+ isIntelEnabled,
656
+
657
+ // Constants
658
+ INTEL_FILES,
659
+ INTEL_DIR
660
+ };