@ktpartners/dgs-platform 2.6.2

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 (256) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +851 -0
  3. package/agents/dgs-codebase-cross-analyzer.md +183 -0
  4. package/agents/dgs-codebase-mapper.md +782 -0
  5. package/agents/dgs-codebase-synthesizer.md +156 -0
  6. package/agents/dgs-debugger.md +1256 -0
  7. package/agents/dgs-executor.md +550 -0
  8. package/agents/dgs-integration-checker.md +481 -0
  9. package/agents/dgs-nyquist-auditor.md +178 -0
  10. package/agents/dgs-phase-researcher.md +563 -0
  11. package/agents/dgs-phase-verifier.md +450 -0
  12. package/agents/dgs-plan-checker.md +708 -0
  13. package/agents/dgs-planner.md +1324 -0
  14. package/agents/dgs-project-researcher.md +631 -0
  15. package/agents/dgs-research-synthesizer.md +249 -0
  16. package/agents/dgs-roadmapper.md +652 -0
  17. package/agents/dgs-verifier.md +607 -0
  18. package/bin/install.js +2073 -0
  19. package/commands/dgs/add-doc.md +45 -0
  20. package/commands/dgs/add-idea.md +38 -0
  21. package/commands/dgs/add-phase.md +43 -0
  22. package/commands/dgs/add-repo.md +54 -0
  23. package/commands/dgs/add-tests.md +41 -0
  24. package/commands/dgs/add-todo.md +47 -0
  25. package/commands/dgs/approve-spec.md +38 -0
  26. package/commands/dgs/audit-milestone.md +36 -0
  27. package/commands/dgs/audit-phase.md +37 -0
  28. package/commands/dgs/cancel-job.md +23 -0
  29. package/commands/dgs/capture-principle.md +143 -0
  30. package/commands/dgs/check-todos.md +45 -0
  31. package/commands/dgs/cleanup.md +18 -0
  32. package/commands/dgs/complete-milestone.md +136 -0
  33. package/commands/dgs/complete-project.md +70 -0
  34. package/commands/dgs/consolidate-ideas.md +50 -0
  35. package/commands/dgs/create-milestone-job.md +37 -0
  36. package/commands/dgs/debug.md +164 -0
  37. package/commands/dgs/develop-idea.md +53 -0
  38. package/commands/dgs/discuss-idea.md +41 -0
  39. package/commands/dgs/discuss-phase.md +83 -0
  40. package/commands/dgs/execute-phase.md +41 -0
  41. package/commands/dgs/fast.md +38 -0
  42. package/commands/dgs/find-related-ideas.md +43 -0
  43. package/commands/dgs/health.md +28 -0
  44. package/commands/dgs/help.md +22 -0
  45. package/commands/dgs/import-spec.md +36 -0
  46. package/commands/dgs/init-product.md +28 -0
  47. package/commands/dgs/insert-phase.md +32 -0
  48. package/commands/dgs/join-discord.md +18 -0
  49. package/commands/dgs/list-docs.md +40 -0
  50. package/commands/dgs/list-ideas.md +42 -0
  51. package/commands/dgs/list-jobs.md +22 -0
  52. package/commands/dgs/list-phase-assumptions.md +46 -0
  53. package/commands/dgs/list-projects.md +57 -0
  54. package/commands/dgs/list-specs.md +40 -0
  55. package/commands/dgs/map-codebase.md +92 -0
  56. package/commands/dgs/new-milestone.md +44 -0
  57. package/commands/dgs/new-project.md +42 -0
  58. package/commands/dgs/node-repair.md +26 -0
  59. package/commands/dgs/overlap-check.md +20 -0
  60. package/commands/dgs/pause-work.md +38 -0
  61. package/commands/dgs/plan-milestone-gaps.md +34 -0
  62. package/commands/dgs/plan-phase.md +44 -0
  63. package/commands/dgs/progress.md +24 -0
  64. package/commands/dgs/quick.md +41 -0
  65. package/commands/dgs/reactivate-project.md +70 -0
  66. package/commands/dgs/reapply-patches.md +110 -0
  67. package/commands/dgs/refine-spec.md +38 -0
  68. package/commands/dgs/reject-idea.md +43 -0
  69. package/commands/dgs/remove-doc.md +44 -0
  70. package/commands/dgs/remove-phase.md +31 -0
  71. package/commands/dgs/remove-repo.md +69 -0
  72. package/commands/dgs/research-idea.md +43 -0
  73. package/commands/dgs/research-phase.md +189 -0
  74. package/commands/dgs/restore-idea.md +45 -0
  75. package/commands/dgs/resume-work.md +40 -0
  76. package/commands/dgs/rollback-job.md +24 -0
  77. package/commands/dgs/run-job.md +35 -0
  78. package/commands/dgs/search.md +40 -0
  79. package/commands/dgs/set-profile.md +34 -0
  80. package/commands/dgs/settings.md +38 -0
  81. package/commands/dgs/switch-project.md +58 -0
  82. package/commands/dgs/undo-consolidation.md +42 -0
  83. package/commands/dgs/update-idea.md +44 -0
  84. package/commands/dgs/update.md +37 -0
  85. package/commands/dgs/validate-phase.md +35 -0
  86. package/commands/dgs/verify-work.md +39 -0
  87. package/commands/dgs/write-spec.md +49 -0
  88. package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-01-SUMMARY.md +84 -0
  89. package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-02-SUMMARY.md +86 -0
  90. package/deliver-great-systems/.planning/phases/10-v1-to-v2-migration-flow/10-01-SUMMARY.md +85 -0
  91. package/deliver-great-systems/bin/dgs-tools.cjs +1444 -0
  92. package/deliver-great-systems/bin/lib/auto-test.cjs +1365 -0
  93. package/deliver-great-systems/bin/lib/commands.cjs +570 -0
  94. package/deliver-great-systems/bin/lib/config.cjs +417 -0
  95. package/deliver-great-systems/bin/lib/conflict-agent.cjs +1063 -0
  96. package/deliver-great-systems/bin/lib/conflict-agent.test.cjs +554 -0
  97. package/deliver-great-systems/bin/lib/context.cjs +929 -0
  98. package/deliver-great-systems/bin/lib/context.test.cjs +693 -0
  99. package/deliver-great-systems/bin/lib/core.cjs +744 -0
  100. package/deliver-great-systems/bin/lib/core.test.cjs +822 -0
  101. package/deliver-great-systems/bin/lib/docs.cjs +919 -0
  102. package/deliver-great-systems/bin/lib/docs.test.cjs +211 -0
  103. package/deliver-great-systems/bin/lib/execution.cjs +705 -0
  104. package/deliver-great-systems/bin/lib/execution.test.cjs +1472 -0
  105. package/deliver-great-systems/bin/lib/frontmatter.cjs +324 -0
  106. package/deliver-great-systems/bin/lib/ideas.cjs +1406 -0
  107. package/deliver-great-systems/bin/lib/ideas.test.cjs +1417 -0
  108. package/deliver-great-systems/bin/lib/identity.cjs +125 -0
  109. package/deliver-great-systems/bin/lib/init.cjs +1114 -0
  110. package/deliver-great-systems/bin/lib/init.test.cjs +1271 -0
  111. package/deliver-great-systems/bin/lib/jobs.cjs +2015 -0
  112. package/deliver-great-systems/bin/lib/jobs.test.cjs +2619 -0
  113. package/deliver-great-systems/bin/lib/merge-conflicts.cjs +654 -0
  114. package/deliver-great-systems/bin/lib/merge-conflicts.test.cjs +370 -0
  115. package/deliver-great-systems/bin/lib/migration.cjs +352 -0
  116. package/deliver-great-systems/bin/lib/migration.test.cjs +582 -0
  117. package/deliver-great-systems/bin/lib/milestone.cjs +243 -0
  118. package/deliver-great-systems/bin/lib/overlap.cjs +437 -0
  119. package/deliver-great-systems/bin/lib/overlap.test.cjs +747 -0
  120. package/deliver-great-systems/bin/lib/path-audit.test.cjs +384 -0
  121. package/deliver-great-systems/bin/lib/paths.cjs +144 -0
  122. package/deliver-great-systems/bin/lib/paths.test.cjs +486 -0
  123. package/deliver-great-systems/bin/lib/phase.cjs +910 -0
  124. package/deliver-great-systems/bin/lib/projects.cjs +691 -0
  125. package/deliver-great-systems/bin/lib/projects.test.cjs +871 -0
  126. package/deliver-great-systems/bin/lib/repos.cjs +1432 -0
  127. package/deliver-great-systems/bin/lib/repos.test.cjs +1882 -0
  128. package/deliver-great-systems/bin/lib/roadmap.cjs +305 -0
  129. package/deliver-great-systems/bin/lib/search.cjs +570 -0
  130. package/deliver-great-systems/bin/lib/specs.cjs +1303 -0
  131. package/deliver-great-systems/bin/lib/state.cjs +893 -0
  132. package/deliver-great-systems/bin/lib/template.cjs +228 -0
  133. package/deliver-great-systems/bin/lib/test-helpers.cjs +291 -0
  134. package/deliver-great-systems/bin/lib/verify.cjs +796 -0
  135. package/deliver-great-systems/references/checkpoints.md +776 -0
  136. package/deliver-great-systems/references/conflict-resolution.md +66 -0
  137. package/deliver-great-systems/references/context-tiers.md +166 -0
  138. package/deliver-great-systems/references/continuation-format.md +249 -0
  139. package/deliver-great-systems/references/decimal-phase-calculation.md +67 -0
  140. package/deliver-great-systems/references/git-integration.md +250 -0
  141. package/deliver-great-systems/references/git-planning-commit.md +40 -0
  142. package/deliver-great-systems/references/model-profile-resolution.md +36 -0
  143. package/deliver-great-systems/references/model-profiles.md +95 -0
  144. package/deliver-great-systems/references/phase-argument-parsing.md +61 -0
  145. package/deliver-great-systems/references/planning-config.md +224 -0
  146. package/deliver-great-systems/references/questioning.md +162 -0
  147. package/deliver-great-systems/references/spec-review-loop.md +177 -0
  148. package/deliver-great-systems/references/tdd.md +265 -0
  149. package/deliver-great-systems/references/ui-brand.md +160 -0
  150. package/deliver-great-systems/references/verification-patterns.md +612 -0
  151. package/deliver-great-systems/templates/DEBUG.md +166 -0
  152. package/deliver-great-systems/templates/UAT.md +251 -0
  153. package/deliver-great-systems/templates/VALIDATION.md +95 -0
  154. package/deliver-great-systems/templates/claude-md.md +74 -0
  155. package/deliver-great-systems/templates/codebase/architecture.md +257 -0
  156. package/deliver-great-systems/templates/codebase/concerns.md +312 -0
  157. package/deliver-great-systems/templates/codebase/conventions.md +309 -0
  158. package/deliver-great-systems/templates/codebase/integrations.md +282 -0
  159. package/deliver-great-systems/templates/codebase/stack.md +188 -0
  160. package/deliver-great-systems/templates/codebase/structure.md +287 -0
  161. package/deliver-great-systems/templates/codebase/testing.md +482 -0
  162. package/deliver-great-systems/templates/config.json +38 -0
  163. package/deliver-great-systems/templates/context.md +354 -0
  164. package/deliver-great-systems/templates/continue-here.md +80 -0
  165. package/deliver-great-systems/templates/debug-subagent-prompt.md +93 -0
  166. package/deliver-great-systems/templates/discovery.md +148 -0
  167. package/deliver-great-systems/templates/milestone-archive.md +125 -0
  168. package/deliver-great-systems/templates/milestone.md +117 -0
  169. package/deliver-great-systems/templates/phase-prompt.md +615 -0
  170. package/deliver-great-systems/templates/planner-subagent-prompt.md +119 -0
  171. package/deliver-great-systems/templates/project.md +186 -0
  172. package/deliver-great-systems/templates/requirements.md +233 -0
  173. package/deliver-great-systems/templates/research-project/ARCHITECTURE.md +206 -0
  174. package/deliver-great-systems/templates/research-project/FEATURES.md +149 -0
  175. package/deliver-great-systems/templates/research-project/PITFALLS.md +202 -0
  176. package/deliver-great-systems/templates/research-project/STACK.md +122 -0
  177. package/deliver-great-systems/templates/research-project/SUMMARY.md +172 -0
  178. package/deliver-great-systems/templates/research.md +554 -0
  179. package/deliver-great-systems/templates/retrospective.md +54 -0
  180. package/deliver-great-systems/templates/roadmap.md +204 -0
  181. package/deliver-great-systems/templates/state.md +178 -0
  182. package/deliver-great-systems/templates/summary-complex.md +59 -0
  183. package/deliver-great-systems/templates/summary-minimal.md +41 -0
  184. package/deliver-great-systems/templates/summary-standard.md +48 -0
  185. package/deliver-great-systems/templates/summary.md +253 -0
  186. package/deliver-great-systems/templates/user-setup.md +313 -0
  187. package/deliver-great-systems/templates/verification-report.md +324 -0
  188. package/deliver-great-systems/workflows/add-doc.md +151 -0
  189. package/deliver-great-systems/workflows/add-idea.md +96 -0
  190. package/deliver-great-systems/workflows/add-phase.md +120 -0
  191. package/deliver-great-systems/workflows/add-tests.md +359 -0
  192. package/deliver-great-systems/workflows/add-todo.md +162 -0
  193. package/deliver-great-systems/workflows/approve-spec.md +194 -0
  194. package/deliver-great-systems/workflows/audit-milestone.md +364 -0
  195. package/deliver-great-systems/workflows/audit-phase.md +462 -0
  196. package/deliver-great-systems/workflows/cancel-job.md +108 -0
  197. package/deliver-great-systems/workflows/check-todos.md +181 -0
  198. package/deliver-great-systems/workflows/cleanup.md +247 -0
  199. package/deliver-great-systems/workflows/codereview.md +526 -0
  200. package/deliver-great-systems/workflows/complete-milestone.md +1298 -0
  201. package/deliver-great-systems/workflows/consolidate-ideas.md +365 -0
  202. package/deliver-great-systems/workflows/create-milestone-job.md +177 -0
  203. package/deliver-great-systems/workflows/develop-idea.md +544 -0
  204. package/deliver-great-systems/workflows/diagnose-issues.md +231 -0
  205. package/deliver-great-systems/workflows/discovery-phase.md +301 -0
  206. package/deliver-great-systems/workflows/discuss-idea.md +263 -0
  207. package/deliver-great-systems/workflows/discuss-phase.md +733 -0
  208. package/deliver-great-systems/workflows/execute-phase.md +571 -0
  209. package/deliver-great-systems/workflows/execute-plan.md +592 -0
  210. package/deliver-great-systems/workflows/find-related-ideas.md +271 -0
  211. package/deliver-great-systems/workflows/health.md +173 -0
  212. package/deliver-great-systems/workflows/help.md +997 -0
  213. package/deliver-great-systems/workflows/import-spec.md +381 -0
  214. package/deliver-great-systems/workflows/init-product.md +767 -0
  215. package/deliver-great-systems/workflows/insert-phase.md +138 -0
  216. package/deliver-great-systems/workflows/list-docs.md +119 -0
  217. package/deliver-great-systems/workflows/list-ideas.md +154 -0
  218. package/deliver-great-systems/workflows/list-jobs.md +89 -0
  219. package/deliver-great-systems/workflows/list-phase-assumptions.md +192 -0
  220. package/deliver-great-systems/workflows/list-specs.md +101 -0
  221. package/deliver-great-systems/workflows/map-codebase.md +621 -0
  222. package/deliver-great-systems/workflows/new-milestone.md +591 -0
  223. package/deliver-great-systems/workflows/new-project.md +1113 -0
  224. package/deliver-great-systems/workflows/node-repair.md +94 -0
  225. package/deliver-great-systems/workflows/overlap-check.md +86 -0
  226. package/deliver-great-systems/workflows/pause-work.md +134 -0
  227. package/deliver-great-systems/workflows/plan-milestone-gaps.md +306 -0
  228. package/deliver-great-systems/workflows/plan-phase.md +698 -0
  229. package/deliver-great-systems/workflows/progress.md +386 -0
  230. package/deliver-great-systems/workflows/quick.md +845 -0
  231. package/deliver-great-systems/workflows/refine-spec.md +275 -0
  232. package/deliver-great-systems/workflows/reject-idea.md +109 -0
  233. package/deliver-great-systems/workflows/remove-doc.md +117 -0
  234. package/deliver-great-systems/workflows/remove-phase.md +163 -0
  235. package/deliver-great-systems/workflows/research-idea.md +325 -0
  236. package/deliver-great-systems/workflows/research-phase.md +81 -0
  237. package/deliver-great-systems/workflows/restore-idea.md +101 -0
  238. package/deliver-great-systems/workflows/resume-project.md +311 -0
  239. package/deliver-great-systems/workflows/rollback-job.md +130 -0
  240. package/deliver-great-systems/workflows/run-job.md +498 -0
  241. package/deliver-great-systems/workflows/search.md +130 -0
  242. package/deliver-great-systems/workflows/set-profile.md +83 -0
  243. package/deliver-great-systems/workflows/settings.md +470 -0
  244. package/deliver-great-systems/workflows/transition.md +563 -0
  245. package/deliver-great-systems/workflows/undo-consolidation.md +155 -0
  246. package/deliver-great-systems/workflows/update-idea.md +157 -0
  247. package/deliver-great-systems/workflows/update.md +242 -0
  248. package/deliver-great-systems/workflows/validate-phase.md +177 -0
  249. package/deliver-great-systems/workflows/verify-phase.md +253 -0
  250. package/deliver-great-systems/workflows/verify-work.md +671 -0
  251. package/deliver-great-systems/workflows/write-spec.md +450 -0
  252. package/hooks/dist/dgs-check-update.js +62 -0
  253. package/hooks/dist/dgs-context-monitor.js +141 -0
  254. package/hooks/dist/dgs-statusline.js +115 -0
  255. package/package.json +60 -0
  256. package/scripts/build-hooks.js +43 -0
@@ -0,0 +1,919 @@
1
+ /**
2
+ * Docs — Document management, INDEX.md generation, text extraction, and scope organization
3
+ *
4
+ * Provides the data model and operations for document management:
5
+ * add, list, remove, move, and INDEX.md maintenance.
6
+ * Documents are stored in scope-specific docs/ directories:
7
+ * - Product-level: .planning/docs/
8
+ * - Idea-scoped: .planning/ideas/{state}/{idea-slug}/docs/
9
+ * - Spec-scoped: .planning/specs/{spec-slug}/docs/
10
+ * Text extraction produces .extracted.txt sidecars for PDF, XLSX, CSV, DOCX.
11
+ * Large extractions also generate .summary.txt sidecars.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const crypto = require('crypto');
17
+ const { safeReadFile, execGit, generateSlugInternal, output, error } = require('./core.cjs');
18
+ const { getPlanningRoot } = require('./paths.cjs');
19
+ const { findIdeaFile } = require('./ideas.cjs');
20
+
21
+ // ─── Constants ──────────────────────────────────────────────────────────────
22
+
23
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB hard limit
24
+ const SUMMARY_THRESHOLD = 5000; // chars — extractions above this get .summary.txt
25
+ const SUMMARY_LENGTH = 500; // chars — summary truncation length
26
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp']);
27
+ const EXTRACTABLE_EXTENSIONS = new Set(['.pdf', '.xlsx', '.csv', '.docx']);
28
+
29
+ // ─── Filename Slugification ─────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Slugify a filename preserving its extension.
33
+ * "Q4 Budget.pdf" -> "q4-budget.pdf"
34
+ * Lowercases, replaces spaces/special chars with hyphens, strips leading/trailing hyphens.
35
+ *
36
+ * @param {string} name - Original filename
37
+ * @returns {string} Slugified filename
38
+ */
39
+ function slugifyFilename(name) {
40
+ if (!name) return '';
41
+ const ext = path.extname(name);
42
+ const base = path.basename(name, ext);
43
+ const slug = base
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]+/g, '-')
46
+ .replace(/^-+|-+$/g, '');
47
+ return slug + ext.toLowerCase();
48
+ }
49
+
50
+ // ─── Checksum ───────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Compute SHA-256 hex digest of a file.
54
+ *
55
+ * @param {string} filePath - Absolute path to file
56
+ * @returns {string} SHA-256 hex digest
57
+ */
58
+ function computeChecksum(filePath) {
59
+ const data = fs.readFileSync(filePath);
60
+ return crypto.createHash('sha256').update(data).digest('hex');
61
+ }
62
+
63
+ // ─── Scope Resolution ───────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Resolve the docs/ directory path for a given scope.
67
+ *
68
+ * @param {string} cwd - Working directory
69
+ * @param {string} scope - Scope type: "product", "idea", "spec"
70
+ * @param {string} [scopeId] - Scope identifier (idea slug or spec slug)
71
+ * @returns {string} Absolute path to docs/ directory
72
+ */
73
+ function resolveDocsDir(cwd, scope, scopeId) {
74
+ const planRoot = getPlanningRoot(cwd);
75
+ if (scope === 'product') {
76
+ return path.join(planRoot, 'docs', 'product');
77
+ }
78
+ if (scope === 'idea') {
79
+ if (!scopeId) {
80
+ error('scope-id required for idea scope');
81
+ }
82
+ // Resolve scopeId: accept numeric ID ("1"), padded ID ("001"), full slug, or filename
83
+ const idea = findIdeaFile(cwd, scopeId);
84
+ if (!idea) {
85
+ error(`idea not found: ${scopeId}. No matching idea file exists in pending, done, or rejected`);
86
+ }
87
+ // Derive directory name from idea filename (strip .md extension)
88
+ const ideaDirName = idea.filename.replace(/\.md$/, '');
89
+ const ideaDir = path.join(planRoot, 'ideas', idea.state, ideaDirName);
90
+ // Auto-create idea directory if it only exists as a file
91
+ fs.mkdirSync(ideaDir, { recursive: true });
92
+ return path.join(ideaDir, 'docs');
93
+ }
94
+ if (scope === 'spec') {
95
+ if (!scopeId) {
96
+ error('scope-id required for spec scope');
97
+ }
98
+ return path.join(planRoot, 'specs', scopeId, 'docs');
99
+ }
100
+ error(`unknown scope: ${scope}. Use product, idea, or spec`);
101
+ }
102
+
103
+ /**
104
+ * Ensure a docs directory exists, creating it silently if needed.
105
+ *
106
+ * @param {string} dirPath - Absolute path to docs directory
107
+ */
108
+ function ensureDocsDir(dirPath) {
109
+ fs.mkdirSync(dirPath, { recursive: true });
110
+ }
111
+
112
+ // ─── Text Extraction ────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Extract text from a document file based on its extension.
116
+ * Uses dynamic require with try/catch so the module loads even if deps are missing.
117
+ *
118
+ * @param {string} filePath - Absolute path to the file
119
+ * @param {string} ext - File extension (lowercase, with dot)
120
+ * @returns {{ text: string|null, error: string|null }}
121
+ */
122
+ function extractText(filePath, ext) {
123
+ if (!EXTRACTABLE_EXTENSIONS.has(ext)) {
124
+ return { text: null, error: null };
125
+ }
126
+
127
+ try {
128
+ if (ext === '.pdf') {
129
+ try {
130
+ const pdfParse = require('pdf-parse');
131
+ const buffer = fs.readFileSync(filePath);
132
+ // pdf-parse returns a promise; we use execSync workaround for sync context
133
+ // Instead, we'll use a sync extraction approach
134
+ let result = null;
135
+ const { execSync } = require('child_process');
136
+ const script = `
137
+ const pdfParse = require('pdf-parse');
138
+ const fs = require('fs');
139
+ const buffer = fs.readFileSync(${JSON.stringify(filePath)});
140
+ pdfParse(buffer).then(data => {
141
+ process.stdout.write(JSON.stringify({ text: data.text }));
142
+ }).catch(err => {
143
+ process.stdout.write(JSON.stringify({ error: err.message }));
144
+ });
145
+ `;
146
+ const out = execSync(`node -e ${JSON.stringify(script)}`, {
147
+ encoding: 'utf-8',
148
+ timeout: 30000,
149
+ stdio: ['pipe', 'pipe', 'pipe'],
150
+ });
151
+ result = JSON.parse(out.trim());
152
+ if (result.error) {
153
+ return { text: null, error: result.error };
154
+ }
155
+ return { text: result.text || '', error: null };
156
+ } catch (e) {
157
+ return { text: null, error: `PDF extraction unavailable: ${e.message}` };
158
+ }
159
+ }
160
+
161
+ if (ext === '.xlsx') {
162
+ try {
163
+ const XLSX = require('xlsx');
164
+ const workbook = XLSX.readFile(filePath);
165
+ const texts = [];
166
+ for (const sheetName of workbook.SheetNames) {
167
+ const sheet = workbook.Sheets[sheetName];
168
+ const csv = XLSX.utils.sheet_to_csv(sheet);
169
+ texts.push(`--- Sheet: ${sheetName} ---\n${csv}`);
170
+ }
171
+ return { text: texts.join('\n\n'), error: null };
172
+ } catch (e) {
173
+ return { text: null, error: `XLSX extraction failed: ${e.message}` };
174
+ }
175
+ }
176
+
177
+ if (ext === '.csv') {
178
+ try {
179
+ const text = fs.readFileSync(filePath, 'utf-8');
180
+ return { text, error: null };
181
+ } catch (e) {
182
+ return { text: null, error: `CSV read failed: ${e.message}` };
183
+ }
184
+ }
185
+
186
+ if (ext === '.docx') {
187
+ try {
188
+ const mammoth = require('mammoth');
189
+ const { execSync } = require('child_process');
190
+ const script = `
191
+ const mammoth = require('mammoth');
192
+ mammoth.extractRawText({ path: ${JSON.stringify(filePath)} })
193
+ .then(result => {
194
+ process.stdout.write(JSON.stringify({ text: result.value }));
195
+ })
196
+ .catch(err => {
197
+ process.stdout.write(JSON.stringify({ error: err.message }));
198
+ });
199
+ `;
200
+ const out = execSync(`node -e ${JSON.stringify(script)}`, {
201
+ encoding: 'utf-8',
202
+ timeout: 30000,
203
+ stdio: ['pipe', 'pipe', 'pipe'],
204
+ });
205
+ const result = JSON.parse(out.trim());
206
+ if (result.error) {
207
+ return { text: null, error: result.error };
208
+ }
209
+ return { text: result.text || '', error: null };
210
+ } catch (e) {
211
+ return { text: null, error: `DOCX extraction unavailable: ${e.message}` };
212
+ }
213
+ }
214
+
215
+ return { text: null, error: null };
216
+ } catch (e) {
217
+ return { text: null, error: e.message };
218
+ }
219
+ }
220
+
221
+ // ─── INDEX.md Management ────────────────────────────────────────────────────
222
+
223
+ /**
224
+ * Scan a docs directory and rebuild INDEX.md with metadata table.
225
+ * Creates or updates the INDEX.md file in the given docs directory.
226
+ *
227
+ * @param {string} docsDir - Absolute path to docs/ directory
228
+ */
229
+ function updateIndex(docsDir) {
230
+ if (!fs.existsSync(docsDir)) return;
231
+
232
+ let files;
233
+ try {
234
+ files = fs.readdirSync(docsDir).filter(f =>
235
+ f !== 'INDEX.md' && !f.endsWith('.extracted.txt') && !f.endsWith('.summary.txt') && !f.startsWith('.')
236
+ );
237
+ } catch {
238
+ return;
239
+ }
240
+
241
+ if (files.length === 0) {
242
+ // Remove INDEX.md if no docs remain
243
+ const indexPath = path.join(docsDir, 'INDEX.md');
244
+ if (fs.existsSync(indexPath)) {
245
+ fs.unlinkSync(indexPath);
246
+ }
247
+ return;
248
+ }
249
+
250
+ const rows = [];
251
+ for (const file of files.sort()) {
252
+ const filePath = path.join(docsDir, file);
253
+ let stat;
254
+ try {
255
+ stat = fs.statSync(filePath);
256
+ } catch {
257
+ continue;
258
+ }
259
+
260
+ if (stat.isDirectory()) continue;
261
+
262
+ const ext = path.extname(file).toLowerCase();
263
+ const checksum = computeChecksum(filePath);
264
+ const sizeKb = (stat.size / 1024).toFixed(1) + ' KB';
265
+ const dateAdded = stat.birthtime ? stat.birthtime.toISOString().split('T')[0] : new Date().toISOString().split('T')[0];
266
+
267
+ // Determine extraction status
268
+ const extractedPath = path.join(docsDir, file + '.extracted.txt');
269
+ let extractionStatus = 'skipped';
270
+ if (fs.existsSync(extractedPath)) {
271
+ const extractedContent = safeReadFile(extractedPath) || '';
272
+ extractionStatus = extractedContent.startsWith('[Extraction failed') ? 'failed' : 'success';
273
+ } else if (EXTRACTABLE_EXTENSIONS.has(ext)) {
274
+ extractionStatus = 'pending';
275
+ }
276
+
277
+ // Try to find original name from existing INDEX.md
278
+ let originalName = file;
279
+ const existingIndex = safeReadFile(path.join(docsDir, 'INDEX.md'));
280
+ if (existingIndex) {
281
+ const row = existingIndex.split('\n').find(line => line.includes(`| ${file} |`));
282
+ if (row) {
283
+ const cols = row.split('|').map(c => c.trim()).filter(c => c);
284
+ if (cols.length > 0) {
285
+ originalName = cols[0];
286
+ }
287
+ }
288
+ }
289
+
290
+ rows.push({
291
+ original_name: originalName,
292
+ slug: file,
293
+ date_added: dateAdded,
294
+ added_by: '\u2014',
295
+ type: ext.replace('.', '').toUpperCase() || 'FILE',
296
+ size: sizeKb,
297
+ checksum: checksum.substring(0, 12) + '...',
298
+ extraction_status: extractionStatus,
299
+ });
300
+ }
301
+
302
+ let content = '# Document Index\n\n';
303
+ content += '| Original Name | Slug | Date Added | Added By | Type | Size | Checksum | Extraction |\n';
304
+ content += '|---|---|---|---|---|---|---|---|\n';
305
+ for (const row of rows) {
306
+ content += `| ${row.original_name} | ${row.slug} | ${row.date_added} | ${row.added_by} | ${row.type} | ${row.size} | ${row.checksum} | ${row.extraction_status} |\n`;
307
+ }
308
+
309
+ fs.writeFileSync(path.join(docsDir, 'INDEX.md'), content, 'utf-8');
310
+ }
311
+
312
+ /**
313
+ * Parse INDEX.md and return document metadata entries.
314
+ *
315
+ * @param {string} docsDir - Absolute path to docs/ directory
316
+ * @returns {Array<{ original_name: string, slug: string, date_added: string, type: string, size: string, checksum: string, extraction_status: string }>}
317
+ */
318
+ function parseIndex(docsDir) {
319
+ const indexPath = path.join(docsDir, 'INDEX.md');
320
+ const content = safeReadFile(indexPath);
321
+ if (!content) return [];
322
+
323
+ const lines = content.split('\n').filter(l => l.startsWith('|') && !l.startsWith('|---') && !l.startsWith('| Original'));
324
+ return lines.map(line => {
325
+ const cols = line.split('|').map(c => c.trim()).filter(c => c);
326
+ if (cols.length < 8) return null;
327
+ return {
328
+ original_name: cols[0],
329
+ slug: cols[1],
330
+ date_added: cols[2],
331
+ added_by: cols[3],
332
+ type: cols[4],
333
+ size: cols[5],
334
+ checksum: cols[6],
335
+ extraction_status: cols[7],
336
+ };
337
+ }).filter(Boolean);
338
+ }
339
+
340
+ // ─── CRUD Operations ────────────────────────────────────────────────────────
341
+
342
+ /**
343
+ * Add a document to a scope's docs/ directory.
344
+ *
345
+ * @param {string} cwd - Working directory
346
+ * @param {string} sourcePath - Path to source file (absolute or relative to cwd)
347
+ * @param {string} scope - Scope type: "product", "idea", "spec"
348
+ * @param {string} [scopeId] - Scope identifier
349
+ * @param {boolean} raw - Raw output mode
350
+ * @param {string} [author] - Author string from gateIdentity() (e.g., "Name <email>")
351
+ */
352
+ function cmdDocsAdd(cwd, sourcePath, scope, scopeId, raw, author) {
353
+ if (!sourcePath) {
354
+ error('source path required for docs add');
355
+ }
356
+ if (!scope) {
357
+ error('scope required for docs add');
358
+ }
359
+
360
+ // Resolve source path
361
+ const absSourcePath = path.isAbsolute(sourcePath) ? sourcePath : path.join(cwd, sourcePath);
362
+
363
+ // Validate file exists
364
+ if (!fs.existsSync(absSourcePath)) {
365
+ error(`file not found: ${sourcePath}`);
366
+ }
367
+
368
+ // Validate file size
369
+ const stat = fs.statSync(absSourcePath);
370
+ if (stat.size > MAX_FILE_SIZE) {
371
+ error(`file exceeds 10MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB): ${sourcePath}`);
372
+ }
373
+
374
+ const originalName = path.basename(absSourcePath);
375
+ const slug = slugifyFilename(originalName);
376
+ const ext = path.extname(slug).toLowerCase();
377
+
378
+ // Resolve destination directory
379
+ const docsDir = resolveDocsDir(cwd, scope, scopeId);
380
+ ensureDocsDir(docsDir);
381
+
382
+ const destPath = path.join(docsDir, slug);
383
+
384
+ // Check for duplicate
385
+ if (fs.existsSync(destPath)) {
386
+ const existingChecksum = computeChecksum(destPath);
387
+ const newChecksum = computeChecksum(absSourcePath);
388
+ if (existingChecksum === newChecksum) {
389
+ output({ duplicate: true, slug, scope, scope_id: scopeId || null, checksum: existingChecksum }, raw);
390
+ return;
391
+ }
392
+ // Different checksum — re-add (overwrite and re-extract)
393
+ }
394
+
395
+ // Copy file to docs directory
396
+ fs.copyFileSync(absSourcePath, destPath);
397
+
398
+ // Track all files created/modified for commit enumeration
399
+ const filesCreated = [];
400
+ filesCreated.push(path.relative(cwd, destPath));
401
+
402
+ // Compute checksum
403
+ const checksum = computeChecksum(destPath);
404
+
405
+ // Extract text if applicable
406
+ let extractionStatus = 'skipped';
407
+ if (IMAGE_EXTENSIONS.has(ext)) {
408
+ extractionStatus = 'skipped';
409
+ } else if (EXTRACTABLE_EXTENSIONS.has(ext)) {
410
+ const { text, error: extractError } = extractText(destPath, ext);
411
+ const extractedPath = destPath + '.extracted.txt';
412
+
413
+ if (extractError) {
414
+ fs.writeFileSync(extractedPath, `[Extraction failed: ${extractError}]\n`, 'utf-8');
415
+ extractionStatus = 'failed';
416
+ filesCreated.push(path.relative(cwd, extractedPath));
417
+ } else if (text !== null) {
418
+ fs.writeFileSync(extractedPath, text, 'utf-8');
419
+ extractionStatus = 'success';
420
+ filesCreated.push(path.relative(cwd, extractedPath));
421
+
422
+ // Generate summary for large extractions
423
+ if (text.length > SUMMARY_THRESHOLD) {
424
+ const summary = text.substring(0, SUMMARY_LENGTH) + '...';
425
+ const summaryPath = destPath + '.summary.txt';
426
+ fs.writeFileSync(summaryPath, summary, 'utf-8');
427
+ filesCreated.push(path.relative(cwd, summaryPath));
428
+ }
429
+ }
430
+ }
431
+
432
+ // Update INDEX.md — store original name mapping
433
+ // First write a temporary marker so updateIndex can find the original name
434
+ const indexPath = path.join(docsDir, 'INDEX.md');
435
+ const existingIndex = safeReadFile(indexPath) || '';
436
+ // Ensure original name is preserved in INDEX.md
437
+ if (!existingIndex.includes(`| ${slug} |`)) {
438
+ // Will be rebuilt by updateIndex, but we need to ensure original name mapping
439
+ // Write a helper file to track original names
440
+ const metaPath = path.join(docsDir, '.names.json');
441
+ let nameMap = {};
442
+ const existingMeta = safeReadFile(metaPath);
443
+ if (existingMeta) {
444
+ try { nameMap = JSON.parse(existingMeta); } catch { /* ignore */ }
445
+ }
446
+ nameMap[slug] = originalName;
447
+ if (author) {
448
+ if (!nameMap._authors) nameMap._authors = {};
449
+ nameMap._authors[slug] = author;
450
+ }
451
+ fs.writeFileSync(metaPath, JSON.stringify(nameMap, null, 2) + '\n', 'utf-8');
452
+ }
453
+ filesCreated.push(path.relative(cwd, path.join(docsDir, '.names.json')));
454
+
455
+ // Rebuild INDEX.md with original name tracking
456
+ updateIndexWithNames(docsDir);
457
+ filesCreated.push(path.relative(cwd, path.join(docsDir, 'INDEX.md')));
458
+
459
+ // Build relative link from planning root
460
+ const planningDir = getPlanningRoot(cwd);
461
+ const relativePath = path.relative(planningDir, destPath);
462
+ const relativeLink = `[${originalName}](${relativePath})`;
463
+
464
+ const result = {
465
+ added: true,
466
+ scope,
467
+ scope_id: scopeId || null,
468
+ slug,
469
+ original_name: originalName,
470
+ checksum,
471
+ extraction_status: extractionStatus,
472
+ files_created: filesCreated,
473
+ relative_link: relativeLink,
474
+ added_by: author || null,
475
+ };
476
+ output(result, raw);
477
+ }
478
+
479
+ /**
480
+ * Update INDEX.md using the .names.json mapping for original names.
481
+ *
482
+ * @param {string} docsDir - Absolute path to docs/ directory
483
+ */
484
+ function updateIndexWithNames(docsDir) {
485
+ if (!fs.existsSync(docsDir)) return;
486
+
487
+ // Load name map
488
+ const metaPath = path.join(docsDir, '.names.json');
489
+ let nameMap = {};
490
+ const existingMeta = safeReadFile(metaPath);
491
+ if (existingMeta) {
492
+ try { nameMap = JSON.parse(existingMeta); } catch { /* ignore */ }
493
+ }
494
+ const authors = nameMap._authors || {};
495
+
496
+ let files;
497
+ try {
498
+ files = fs.readdirSync(docsDir).filter(f =>
499
+ f !== 'INDEX.md' &&
500
+ !f.endsWith('.extracted.txt') &&
501
+ !f.endsWith('.summary.txt') &&
502
+ !f.startsWith('.') &&
503
+ f !== '.names.json'
504
+ );
505
+ } catch {
506
+ return;
507
+ }
508
+
509
+ if (files.length === 0) {
510
+ const indexPath = path.join(docsDir, 'INDEX.md');
511
+ if (fs.existsSync(indexPath)) {
512
+ fs.unlinkSync(indexPath);
513
+ }
514
+ return;
515
+ }
516
+
517
+ const rows = [];
518
+ for (const file of files.sort()) {
519
+ const filePath = path.join(docsDir, file);
520
+ let stat;
521
+ try {
522
+ stat = fs.statSync(filePath);
523
+ } catch {
524
+ continue;
525
+ }
526
+
527
+ if (stat.isDirectory()) continue;
528
+
529
+ const ext = path.extname(file).toLowerCase();
530
+ const checksum = computeChecksum(filePath);
531
+ const sizeKb = (stat.size / 1024).toFixed(1) + ' KB';
532
+ const dateAdded = stat.birthtime ? stat.birthtime.toISOString().split('T')[0] : new Date().toISOString().split('T')[0];
533
+
534
+ // Determine extraction status
535
+ const extractedPath = path.join(docsDir, file + '.extracted.txt');
536
+ let extractionStatus = 'skipped';
537
+ if (fs.existsSync(extractedPath)) {
538
+ const extractedContent = safeReadFile(extractedPath) || '';
539
+ extractionStatus = extractedContent.startsWith('[Extraction failed') ? 'failed' : 'success';
540
+ } else if (EXTRACTABLE_EXTENSIONS.has(ext)) {
541
+ extractionStatus = 'pending';
542
+ }
543
+
544
+ const originalName = nameMap[file] || file;
545
+
546
+ rows.push({
547
+ original_name: originalName,
548
+ slug: file,
549
+ date_added: dateAdded,
550
+ added_by: authors[file] || '\u2014',
551
+ type: ext.replace('.', '').toUpperCase() || 'FILE',
552
+ size: sizeKb,
553
+ checksum: checksum.substring(0, 12) + '...',
554
+ extraction_status: extractionStatus,
555
+ });
556
+ }
557
+
558
+ let content = '# Document Index\n\n';
559
+ content += '| Original Name | Slug | Date Added | Added By | Type | Size | Checksum | Extraction |\n';
560
+ content += '|---|---|---|---|---|---|---|---|\n';
561
+ for (const row of rows) {
562
+ content += `| ${row.original_name} | ${row.slug} | ${row.date_added} | ${row.added_by} | ${row.type} | ${row.size} | ${row.checksum} | ${row.extraction_status} |\n`;
563
+ }
564
+
565
+ fs.writeFileSync(path.join(docsDir, 'INDEX.md'), content, 'utf-8');
566
+ }
567
+
568
+ /**
569
+ * List all documents grouped by scope.
570
+ *
571
+ * @param {string} cwd - Working directory
572
+ * @param {string} [scopeFilter] - Optional scope filter (e.g., "product", "idea:my-idea")
573
+ * @param {boolean} raw - Raw output mode
574
+ */
575
+ function cmdDocsList(cwd, scopeFilter, raw) {
576
+ const scopes = [];
577
+
578
+ // Parse scope filter
579
+ let filterScope = null;
580
+ let filterId = null;
581
+ if (scopeFilter) {
582
+ const parts = scopeFilter.split(':');
583
+ filterScope = parts[0];
584
+ filterId = parts[1] || null;
585
+ }
586
+
587
+ // Product docs
588
+ if (!filterScope || filterScope === 'product') {
589
+ const productDir = resolveDocsDir(cwd, 'product');
590
+ if (fs.existsSync(productDir)) {
591
+ const docs = scanDocsDir(productDir);
592
+ if (docs.length > 0) {
593
+ scopes.push({ scope: 'product', scope_id: null, docs });
594
+ }
595
+ }
596
+ }
597
+
598
+ // Idea docs
599
+ if (!filterScope || filterScope === 'idea') {
600
+ const states = ['pending', 'done', 'rejected'];
601
+ for (const state of states) {
602
+ const stateDir = path.join(getPlanningRoot(cwd), 'ideas', state);
603
+ if (!fs.existsSync(stateDir)) continue;
604
+
605
+ let ideaDirs;
606
+ try {
607
+ ideaDirs = fs.readdirSync(stateDir, { withFileTypes: true })
608
+ .filter(e => e.isDirectory())
609
+ .map(e => e.name);
610
+ } catch {
611
+ continue;
612
+ }
613
+
614
+ for (const ideaSlug of ideaDirs) {
615
+ if (filterId && ideaSlug !== filterId) continue;
616
+ const docsDir = path.join(stateDir, ideaSlug, 'docs');
617
+ if (!fs.existsSync(docsDir)) continue;
618
+
619
+ const docs = scanDocsDir(docsDir);
620
+ if (docs.length > 0) {
621
+ scopes.push({ scope: 'idea', scope_id: `${state}/${ideaSlug}`, docs });
622
+ }
623
+ }
624
+ }
625
+ }
626
+
627
+ // Spec docs
628
+ if (!filterScope || filterScope === 'spec') {
629
+ const specsDir = path.join(getPlanningRoot(cwd), 'specs');
630
+ if (fs.existsSync(specsDir)) {
631
+ let specDirs;
632
+ try {
633
+ specDirs = fs.readdirSync(specsDir, { withFileTypes: true })
634
+ .filter(e => e.isDirectory())
635
+ .map(e => e.name);
636
+ } catch {
637
+ specDirs = [];
638
+ }
639
+
640
+ for (const specSlug of specDirs) {
641
+ if (filterId && specSlug !== filterId) continue;
642
+ const docsDir = path.join(specsDir, specSlug, 'docs');
643
+ if (!fs.existsSync(docsDir)) continue;
644
+
645
+ const docs = scanDocsDir(docsDir);
646
+ if (docs.length > 0) {
647
+ scopes.push({ scope: 'spec', scope_id: specSlug, docs });
648
+ }
649
+ }
650
+ }
651
+ }
652
+
653
+ output({ scopes }, raw);
654
+ }
655
+
656
+ /**
657
+ * Scan a docs directory and return document metadata.
658
+ *
659
+ * @param {string} docsDir - Absolute path to docs/ directory
660
+ * @returns {Array<{ original_name: string, slug: string, date_added: string, added_by: string, size: string, checksum: string, extraction_status: string }>}
661
+ */
662
+ function scanDocsDir(docsDir) {
663
+ // Load name map
664
+ const metaPath = path.join(docsDir, '.names.json');
665
+ let nameMap = {};
666
+ const existingMeta = safeReadFile(metaPath);
667
+ if (existingMeta) {
668
+ try { nameMap = JSON.parse(existingMeta); } catch { /* ignore */ }
669
+ }
670
+ const authors = nameMap._authors || {};
671
+
672
+ let files;
673
+ try {
674
+ files = fs.readdirSync(docsDir).filter(f =>
675
+ f !== 'INDEX.md' &&
676
+ !f.endsWith('.extracted.txt') &&
677
+ !f.endsWith('.summary.txt') &&
678
+ !f.startsWith('.')
679
+ );
680
+ } catch {
681
+ return [];
682
+ }
683
+
684
+ return files.sort().map(file => {
685
+ const filePath = path.join(docsDir, file);
686
+ let stat;
687
+ try {
688
+ stat = fs.statSync(filePath);
689
+ } catch {
690
+ return null;
691
+ }
692
+
693
+ if (stat.isDirectory()) return null;
694
+
695
+ const ext = path.extname(file).toLowerCase();
696
+ const checksum = computeChecksum(filePath);
697
+ const sizeKb = (stat.size / 1024).toFixed(1) + ' KB';
698
+ const dateAdded = stat.birthtime ? stat.birthtime.toISOString().split('T')[0] : new Date().toISOString().split('T')[0];
699
+
700
+ const extractedPath = path.join(docsDir, file + '.extracted.txt');
701
+ let extractionStatus = 'skipped';
702
+ if (fs.existsSync(extractedPath)) {
703
+ const extractedContent = safeReadFile(extractedPath) || '';
704
+ extractionStatus = extractedContent.startsWith('[Extraction failed') ? 'failed' : 'success';
705
+ } else if (EXTRACTABLE_EXTENSIONS.has(ext)) {
706
+ extractionStatus = 'pending';
707
+ }
708
+
709
+ return {
710
+ original_name: nameMap[file] || file,
711
+ slug: file,
712
+ date_added: dateAdded,
713
+ added_by: authors[file] || '\u2014',
714
+ size: sizeKb,
715
+ checksum,
716
+ extraction_status: extractionStatus,
717
+ };
718
+ }).filter(Boolean);
719
+ }
720
+
721
+ /**
722
+ * Remove a document and all its sidecars from a scope.
723
+ *
724
+ * @param {string} cwd - Working directory
725
+ * @param {string} scope - Scope type
726
+ * @param {string} [scopeId] - Scope identifier
727
+ * @param {string} slug - Document slug filename
728
+ * @param {boolean} raw - Raw output mode
729
+ */
730
+ function cmdDocsRemove(cwd, scope, scopeId, slug, raw) {
731
+ if (!scope) {
732
+ error('scope required for docs remove');
733
+ }
734
+ if (!slug) {
735
+ error('slug required for docs remove');
736
+ }
737
+
738
+ const docsDir = resolveDocsDir(cwd, scope, scopeId);
739
+ const docPath = path.join(docsDir, slug);
740
+
741
+ if (!fs.existsSync(docPath)) {
742
+ error(`document not found: ${slug} in ${scope}${scopeId ? ':' + scopeId : ''}`);
743
+ }
744
+
745
+ const filesRemoved = [];
746
+
747
+ // Remove document file
748
+ fs.unlinkSync(docPath);
749
+ filesRemoved.push(slug);
750
+
751
+ // Remove sidecars
752
+ const extractedPath = docPath + '.extracted.txt';
753
+ if (fs.existsSync(extractedPath)) {
754
+ fs.unlinkSync(extractedPath);
755
+ filesRemoved.push(slug + '.extracted.txt');
756
+ }
757
+
758
+ const summaryPath = docPath + '.summary.txt';
759
+ if (fs.existsSync(summaryPath)) {
760
+ fs.unlinkSync(summaryPath);
761
+ filesRemoved.push(slug + '.summary.txt');
762
+ }
763
+
764
+ // Update name map
765
+ const metaPath = path.join(docsDir, '.names.json');
766
+ const existingMeta = safeReadFile(metaPath);
767
+ if (existingMeta) {
768
+ try {
769
+ const nameMap = JSON.parse(existingMeta);
770
+ delete nameMap[slug];
771
+ // Clean up _authors entry for the removed slug
772
+ if (nameMap._authors) {
773
+ delete nameMap._authors[slug];
774
+ if (Object.keys(nameMap._authors).length === 0) {
775
+ delete nameMap._authors;
776
+ }
777
+ }
778
+ if (Object.keys(nameMap).length === 0) {
779
+ fs.unlinkSync(metaPath);
780
+ } else {
781
+ fs.writeFileSync(metaPath, JSON.stringify(nameMap, null, 2) + '\n', 'utf-8');
782
+ }
783
+ } catch { /* ignore */ }
784
+ }
785
+
786
+ // Update INDEX.md
787
+ updateIndexWithNames(docsDir);
788
+
789
+ output({ removed: true, slug, scope, scope_id: scopeId || null, files_removed: filesRemoved }, raw);
790
+ }
791
+
792
+ /**
793
+ * Move a document between scopes using git mv.
794
+ *
795
+ * @param {string} cwd - Working directory
796
+ * @param {string} slug - Document slug filename
797
+ * @param {string} fromScope - Source scope
798
+ * @param {string} [fromScopeId] - Source scope ID
799
+ * @param {string} toScope - Destination scope
800
+ * @param {string} [toScopeId] - Destination scope ID
801
+ * @param {boolean} raw - Raw output mode
802
+ */
803
+ function cmdDocsMove(cwd, slug, fromScope, fromScopeId, toScope, toScopeId, raw) {
804
+ if (!slug) {
805
+ error('slug required for docs move');
806
+ }
807
+ if (!fromScope) {
808
+ error('from-scope required for docs move');
809
+ }
810
+ if (!toScope) {
811
+ error('to-scope required for docs move');
812
+ }
813
+
814
+ const srcDir = resolveDocsDir(cwd, fromScope, fromScopeId);
815
+ const destDir = resolveDocsDir(cwd, toScope, toScopeId);
816
+ const srcPath = path.join(srcDir, slug);
817
+
818
+ if (!fs.existsSync(srcPath)) {
819
+ error(`document not found: ${slug} in ${fromScope}${fromScopeId ? ':' + fromScopeId : ''}`);
820
+ }
821
+
822
+ ensureDocsDir(destDir);
823
+
824
+ // Collect files to move (document + sidecars)
825
+ const filesToMove = [slug];
826
+ if (fs.existsSync(srcPath + '.extracted.txt')) {
827
+ filesToMove.push(slug + '.extracted.txt');
828
+ }
829
+ if (fs.existsSync(srcPath + '.summary.txt')) {
830
+ filesToMove.push(slug + '.summary.txt');
831
+ }
832
+
833
+ // Git mv each file
834
+ for (const file of filesToMove) {
835
+ const fromRel = path.relative(cwd, path.join(srcDir, file));
836
+ const toRel = path.relative(cwd, path.join(destDir, file));
837
+ const result = execGit(cwd, ['mv', fromRel, toRel]);
838
+ if (result.exitCode !== 0) {
839
+ // Fall back to filesystem move if git mv fails (file not tracked)
840
+ fs.renameSync(path.join(srcDir, file), path.join(destDir, file));
841
+ }
842
+ }
843
+
844
+ // Move name mapping
845
+ const srcMetaPath = path.join(srcDir, '.names.json');
846
+ const destMetaPath = path.join(destDir, '.names.json');
847
+ const srcMeta = safeReadFile(srcMetaPath);
848
+ let srcNameMap = {};
849
+ let destNameMap = {};
850
+ if (srcMeta) {
851
+ try { srcNameMap = JSON.parse(srcMeta); } catch { /* ignore */ }
852
+ }
853
+ const destMeta = safeReadFile(destMetaPath);
854
+ if (destMeta) {
855
+ try { destNameMap = JSON.parse(destMeta); } catch { /* ignore */ }
856
+ }
857
+
858
+ if (srcNameMap[slug]) {
859
+ destNameMap[slug] = srcNameMap[slug];
860
+ delete srcNameMap[slug];
861
+
862
+ // Migrate _authors entry alongside display name
863
+ if (srcNameMap._authors && srcNameMap._authors[slug]) {
864
+ if (!destNameMap._authors) destNameMap._authors = {};
865
+ destNameMap._authors[slug] = srcNameMap._authors[slug];
866
+ delete srcNameMap._authors[slug];
867
+ // Clean up empty _authors object
868
+ if (Object.keys(srcNameMap._authors).length === 0) {
869
+ delete srcNameMap._authors;
870
+ }
871
+ }
872
+
873
+ // Update source name map
874
+ if (Object.keys(srcNameMap).length === 0) {
875
+ try { fs.unlinkSync(srcMetaPath); } catch { /* ignore */ }
876
+ } else {
877
+ fs.writeFileSync(srcMetaPath, JSON.stringify(srcNameMap, null, 2) + '\n', 'utf-8');
878
+ }
879
+
880
+ // Update dest name map
881
+ fs.writeFileSync(destMetaPath, JSON.stringify(destNameMap, null, 2) + '\n', 'utf-8');
882
+ }
883
+
884
+ // Update INDEX.md in both directories
885
+ updateIndexWithNames(srcDir);
886
+ updateIndexWithNames(destDir);
887
+
888
+ output({
889
+ moved: true,
890
+ slug,
891
+ from_scope: fromScope,
892
+ from_scope_id: fromScopeId || null,
893
+ to_scope: toScope,
894
+ to_scope_id: toScopeId || null,
895
+ }, raw);
896
+ }
897
+
898
+ // ─── Exports ──────────────────────────────────────────────────────────────────
899
+
900
+ module.exports = {
901
+ slugifyFilename,
902
+ computeChecksum,
903
+ resolveDocsDir,
904
+ ensureDocsDir,
905
+ extractText,
906
+ updateIndex,
907
+ updateIndexWithNames,
908
+ parseIndex,
909
+ scanDocsDir,
910
+ cmdDocsAdd,
911
+ cmdDocsList,
912
+ cmdDocsRemove,
913
+ cmdDocsMove,
914
+ MAX_FILE_SIZE,
915
+ SUMMARY_THRESHOLD,
916
+ SUMMARY_LENGTH,
917
+ IMAGE_EXTENSIONS,
918
+ EXTRACTABLE_EXTENSIONS,
919
+ };