@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.
- package/LICENSE +38 -0
- package/README.md +851 -0
- package/agents/dgs-codebase-cross-analyzer.md +183 -0
- package/agents/dgs-codebase-mapper.md +782 -0
- package/agents/dgs-codebase-synthesizer.md +156 -0
- package/agents/dgs-debugger.md +1256 -0
- package/agents/dgs-executor.md +550 -0
- package/agents/dgs-integration-checker.md +481 -0
- package/agents/dgs-nyquist-auditor.md +178 -0
- package/agents/dgs-phase-researcher.md +563 -0
- package/agents/dgs-phase-verifier.md +450 -0
- package/agents/dgs-plan-checker.md +708 -0
- package/agents/dgs-planner.md +1324 -0
- package/agents/dgs-project-researcher.md +631 -0
- package/agents/dgs-research-synthesizer.md +249 -0
- package/agents/dgs-roadmapper.md +652 -0
- package/agents/dgs-verifier.md +607 -0
- package/bin/install.js +2073 -0
- package/commands/dgs/add-doc.md +45 -0
- package/commands/dgs/add-idea.md +38 -0
- package/commands/dgs/add-phase.md +43 -0
- package/commands/dgs/add-repo.md +54 -0
- package/commands/dgs/add-tests.md +41 -0
- package/commands/dgs/add-todo.md +47 -0
- package/commands/dgs/approve-spec.md +38 -0
- package/commands/dgs/audit-milestone.md +36 -0
- package/commands/dgs/audit-phase.md +37 -0
- package/commands/dgs/cancel-job.md +23 -0
- package/commands/dgs/capture-principle.md +143 -0
- package/commands/dgs/check-todos.md +45 -0
- package/commands/dgs/cleanup.md +18 -0
- package/commands/dgs/complete-milestone.md +136 -0
- package/commands/dgs/complete-project.md +70 -0
- package/commands/dgs/consolidate-ideas.md +50 -0
- package/commands/dgs/create-milestone-job.md +37 -0
- package/commands/dgs/debug.md +164 -0
- package/commands/dgs/develop-idea.md +53 -0
- package/commands/dgs/discuss-idea.md +41 -0
- package/commands/dgs/discuss-phase.md +83 -0
- package/commands/dgs/execute-phase.md +41 -0
- package/commands/dgs/fast.md +38 -0
- package/commands/dgs/find-related-ideas.md +43 -0
- package/commands/dgs/health.md +28 -0
- package/commands/dgs/help.md +22 -0
- package/commands/dgs/import-spec.md +36 -0
- package/commands/dgs/init-product.md +28 -0
- package/commands/dgs/insert-phase.md +32 -0
- package/commands/dgs/join-discord.md +18 -0
- package/commands/dgs/list-docs.md +40 -0
- package/commands/dgs/list-ideas.md +42 -0
- package/commands/dgs/list-jobs.md +22 -0
- package/commands/dgs/list-phase-assumptions.md +46 -0
- package/commands/dgs/list-projects.md +57 -0
- package/commands/dgs/list-specs.md +40 -0
- package/commands/dgs/map-codebase.md +92 -0
- package/commands/dgs/new-milestone.md +44 -0
- package/commands/dgs/new-project.md +42 -0
- package/commands/dgs/node-repair.md +26 -0
- package/commands/dgs/overlap-check.md +20 -0
- package/commands/dgs/pause-work.md +38 -0
- package/commands/dgs/plan-milestone-gaps.md +34 -0
- package/commands/dgs/plan-phase.md +44 -0
- package/commands/dgs/progress.md +24 -0
- package/commands/dgs/quick.md +41 -0
- package/commands/dgs/reactivate-project.md +70 -0
- package/commands/dgs/reapply-patches.md +110 -0
- package/commands/dgs/refine-spec.md +38 -0
- package/commands/dgs/reject-idea.md +43 -0
- package/commands/dgs/remove-doc.md +44 -0
- package/commands/dgs/remove-phase.md +31 -0
- package/commands/dgs/remove-repo.md +69 -0
- package/commands/dgs/research-idea.md +43 -0
- package/commands/dgs/research-phase.md +189 -0
- package/commands/dgs/restore-idea.md +45 -0
- package/commands/dgs/resume-work.md +40 -0
- package/commands/dgs/rollback-job.md +24 -0
- package/commands/dgs/run-job.md +35 -0
- package/commands/dgs/search.md +40 -0
- package/commands/dgs/set-profile.md +34 -0
- package/commands/dgs/settings.md +38 -0
- package/commands/dgs/switch-project.md +58 -0
- package/commands/dgs/undo-consolidation.md +42 -0
- package/commands/dgs/update-idea.md +44 -0
- package/commands/dgs/update.md +37 -0
- package/commands/dgs/validate-phase.md +35 -0
- package/commands/dgs/verify-work.md +39 -0
- package/commands/dgs/write-spec.md +49 -0
- package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-01-SUMMARY.md +84 -0
- package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-02-SUMMARY.md +86 -0
- package/deliver-great-systems/.planning/phases/10-v1-to-v2-migration-flow/10-01-SUMMARY.md +85 -0
- package/deliver-great-systems/bin/dgs-tools.cjs +1444 -0
- package/deliver-great-systems/bin/lib/auto-test.cjs +1365 -0
- package/deliver-great-systems/bin/lib/commands.cjs +570 -0
- package/deliver-great-systems/bin/lib/config.cjs +417 -0
- package/deliver-great-systems/bin/lib/conflict-agent.cjs +1063 -0
- package/deliver-great-systems/bin/lib/conflict-agent.test.cjs +554 -0
- package/deliver-great-systems/bin/lib/context.cjs +929 -0
- package/deliver-great-systems/bin/lib/context.test.cjs +693 -0
- package/deliver-great-systems/bin/lib/core.cjs +744 -0
- package/deliver-great-systems/bin/lib/core.test.cjs +822 -0
- package/deliver-great-systems/bin/lib/docs.cjs +919 -0
- package/deliver-great-systems/bin/lib/docs.test.cjs +211 -0
- package/deliver-great-systems/bin/lib/execution.cjs +705 -0
- package/deliver-great-systems/bin/lib/execution.test.cjs +1472 -0
- package/deliver-great-systems/bin/lib/frontmatter.cjs +324 -0
- package/deliver-great-systems/bin/lib/ideas.cjs +1406 -0
- package/deliver-great-systems/bin/lib/ideas.test.cjs +1417 -0
- package/deliver-great-systems/bin/lib/identity.cjs +125 -0
- package/deliver-great-systems/bin/lib/init.cjs +1114 -0
- package/deliver-great-systems/bin/lib/init.test.cjs +1271 -0
- package/deliver-great-systems/bin/lib/jobs.cjs +2015 -0
- package/deliver-great-systems/bin/lib/jobs.test.cjs +2619 -0
- package/deliver-great-systems/bin/lib/merge-conflicts.cjs +654 -0
- package/deliver-great-systems/bin/lib/merge-conflicts.test.cjs +370 -0
- package/deliver-great-systems/bin/lib/migration.cjs +352 -0
- package/deliver-great-systems/bin/lib/migration.test.cjs +582 -0
- package/deliver-great-systems/bin/lib/milestone.cjs +243 -0
- package/deliver-great-systems/bin/lib/overlap.cjs +437 -0
- package/deliver-great-systems/bin/lib/overlap.test.cjs +747 -0
- package/deliver-great-systems/bin/lib/path-audit.test.cjs +384 -0
- package/deliver-great-systems/bin/lib/paths.cjs +144 -0
- package/deliver-great-systems/bin/lib/paths.test.cjs +486 -0
- package/deliver-great-systems/bin/lib/phase.cjs +910 -0
- package/deliver-great-systems/bin/lib/projects.cjs +691 -0
- package/deliver-great-systems/bin/lib/projects.test.cjs +871 -0
- package/deliver-great-systems/bin/lib/repos.cjs +1432 -0
- package/deliver-great-systems/bin/lib/repos.test.cjs +1882 -0
- package/deliver-great-systems/bin/lib/roadmap.cjs +305 -0
- package/deliver-great-systems/bin/lib/search.cjs +570 -0
- package/deliver-great-systems/bin/lib/specs.cjs +1303 -0
- package/deliver-great-systems/bin/lib/state.cjs +893 -0
- package/deliver-great-systems/bin/lib/template.cjs +228 -0
- package/deliver-great-systems/bin/lib/test-helpers.cjs +291 -0
- package/deliver-great-systems/bin/lib/verify.cjs +796 -0
- package/deliver-great-systems/references/checkpoints.md +776 -0
- package/deliver-great-systems/references/conflict-resolution.md +66 -0
- package/deliver-great-systems/references/context-tiers.md +166 -0
- package/deliver-great-systems/references/continuation-format.md +249 -0
- package/deliver-great-systems/references/decimal-phase-calculation.md +67 -0
- package/deliver-great-systems/references/git-integration.md +250 -0
- package/deliver-great-systems/references/git-planning-commit.md +40 -0
- package/deliver-great-systems/references/model-profile-resolution.md +36 -0
- package/deliver-great-systems/references/model-profiles.md +95 -0
- package/deliver-great-systems/references/phase-argument-parsing.md +61 -0
- package/deliver-great-systems/references/planning-config.md +224 -0
- package/deliver-great-systems/references/questioning.md +162 -0
- package/deliver-great-systems/references/spec-review-loop.md +177 -0
- package/deliver-great-systems/references/tdd.md +265 -0
- package/deliver-great-systems/references/ui-brand.md +160 -0
- package/deliver-great-systems/references/verification-patterns.md +612 -0
- package/deliver-great-systems/templates/DEBUG.md +166 -0
- package/deliver-great-systems/templates/UAT.md +251 -0
- package/deliver-great-systems/templates/VALIDATION.md +95 -0
- package/deliver-great-systems/templates/claude-md.md +74 -0
- package/deliver-great-systems/templates/codebase/architecture.md +257 -0
- package/deliver-great-systems/templates/codebase/concerns.md +312 -0
- package/deliver-great-systems/templates/codebase/conventions.md +309 -0
- package/deliver-great-systems/templates/codebase/integrations.md +282 -0
- package/deliver-great-systems/templates/codebase/stack.md +188 -0
- package/deliver-great-systems/templates/codebase/structure.md +287 -0
- package/deliver-great-systems/templates/codebase/testing.md +482 -0
- package/deliver-great-systems/templates/config.json +38 -0
- package/deliver-great-systems/templates/context.md +354 -0
- package/deliver-great-systems/templates/continue-here.md +80 -0
- package/deliver-great-systems/templates/debug-subagent-prompt.md +93 -0
- package/deliver-great-systems/templates/discovery.md +148 -0
- package/deliver-great-systems/templates/milestone-archive.md +125 -0
- package/deliver-great-systems/templates/milestone.md +117 -0
- package/deliver-great-systems/templates/phase-prompt.md +615 -0
- package/deliver-great-systems/templates/planner-subagent-prompt.md +119 -0
- package/deliver-great-systems/templates/project.md +186 -0
- package/deliver-great-systems/templates/requirements.md +233 -0
- package/deliver-great-systems/templates/research-project/ARCHITECTURE.md +206 -0
- package/deliver-great-systems/templates/research-project/FEATURES.md +149 -0
- package/deliver-great-systems/templates/research-project/PITFALLS.md +202 -0
- package/deliver-great-systems/templates/research-project/STACK.md +122 -0
- package/deliver-great-systems/templates/research-project/SUMMARY.md +172 -0
- package/deliver-great-systems/templates/research.md +554 -0
- package/deliver-great-systems/templates/retrospective.md +54 -0
- package/deliver-great-systems/templates/roadmap.md +204 -0
- package/deliver-great-systems/templates/state.md +178 -0
- package/deliver-great-systems/templates/summary-complex.md +59 -0
- package/deliver-great-systems/templates/summary-minimal.md +41 -0
- package/deliver-great-systems/templates/summary-standard.md +48 -0
- package/deliver-great-systems/templates/summary.md +253 -0
- package/deliver-great-systems/templates/user-setup.md +313 -0
- package/deliver-great-systems/templates/verification-report.md +324 -0
- package/deliver-great-systems/workflows/add-doc.md +151 -0
- package/deliver-great-systems/workflows/add-idea.md +96 -0
- package/deliver-great-systems/workflows/add-phase.md +120 -0
- package/deliver-great-systems/workflows/add-tests.md +359 -0
- package/deliver-great-systems/workflows/add-todo.md +162 -0
- package/deliver-great-systems/workflows/approve-spec.md +194 -0
- package/deliver-great-systems/workflows/audit-milestone.md +364 -0
- package/deliver-great-systems/workflows/audit-phase.md +462 -0
- package/deliver-great-systems/workflows/cancel-job.md +108 -0
- package/deliver-great-systems/workflows/check-todos.md +181 -0
- package/deliver-great-systems/workflows/cleanup.md +247 -0
- package/deliver-great-systems/workflows/codereview.md +526 -0
- package/deliver-great-systems/workflows/complete-milestone.md +1298 -0
- package/deliver-great-systems/workflows/consolidate-ideas.md +365 -0
- package/deliver-great-systems/workflows/create-milestone-job.md +177 -0
- package/deliver-great-systems/workflows/develop-idea.md +544 -0
- package/deliver-great-systems/workflows/diagnose-issues.md +231 -0
- package/deliver-great-systems/workflows/discovery-phase.md +301 -0
- package/deliver-great-systems/workflows/discuss-idea.md +263 -0
- package/deliver-great-systems/workflows/discuss-phase.md +733 -0
- package/deliver-great-systems/workflows/execute-phase.md +571 -0
- package/deliver-great-systems/workflows/execute-plan.md +592 -0
- package/deliver-great-systems/workflows/find-related-ideas.md +271 -0
- package/deliver-great-systems/workflows/health.md +173 -0
- package/deliver-great-systems/workflows/help.md +997 -0
- package/deliver-great-systems/workflows/import-spec.md +381 -0
- package/deliver-great-systems/workflows/init-product.md +767 -0
- package/deliver-great-systems/workflows/insert-phase.md +138 -0
- package/deliver-great-systems/workflows/list-docs.md +119 -0
- package/deliver-great-systems/workflows/list-ideas.md +154 -0
- package/deliver-great-systems/workflows/list-jobs.md +89 -0
- package/deliver-great-systems/workflows/list-phase-assumptions.md +192 -0
- package/deliver-great-systems/workflows/list-specs.md +101 -0
- package/deliver-great-systems/workflows/map-codebase.md +621 -0
- package/deliver-great-systems/workflows/new-milestone.md +591 -0
- package/deliver-great-systems/workflows/new-project.md +1113 -0
- package/deliver-great-systems/workflows/node-repair.md +94 -0
- package/deliver-great-systems/workflows/overlap-check.md +86 -0
- package/deliver-great-systems/workflows/pause-work.md +134 -0
- package/deliver-great-systems/workflows/plan-milestone-gaps.md +306 -0
- package/deliver-great-systems/workflows/plan-phase.md +698 -0
- package/deliver-great-systems/workflows/progress.md +386 -0
- package/deliver-great-systems/workflows/quick.md +845 -0
- package/deliver-great-systems/workflows/refine-spec.md +275 -0
- package/deliver-great-systems/workflows/reject-idea.md +109 -0
- package/deliver-great-systems/workflows/remove-doc.md +117 -0
- package/deliver-great-systems/workflows/remove-phase.md +163 -0
- package/deliver-great-systems/workflows/research-idea.md +325 -0
- package/deliver-great-systems/workflows/research-phase.md +81 -0
- package/deliver-great-systems/workflows/restore-idea.md +101 -0
- package/deliver-great-systems/workflows/resume-project.md +311 -0
- package/deliver-great-systems/workflows/rollback-job.md +130 -0
- package/deliver-great-systems/workflows/run-job.md +498 -0
- package/deliver-great-systems/workflows/search.md +130 -0
- package/deliver-great-systems/workflows/set-profile.md +83 -0
- package/deliver-great-systems/workflows/settings.md +470 -0
- package/deliver-great-systems/workflows/transition.md +563 -0
- package/deliver-great-systems/workflows/undo-consolidation.md +155 -0
- package/deliver-great-systems/workflows/update-idea.md +157 -0
- package/deliver-great-systems/workflows/update.md +242 -0
- package/deliver-great-systems/workflows/validate-phase.md +177 -0
- package/deliver-great-systems/workflows/verify-phase.md +253 -0
- package/deliver-great-systems/workflows/verify-work.md +671 -0
- package/deliver-great-systems/workflows/write-spec.md +450 -0
- package/hooks/dist/dgs-check-update.js +62 -0
- package/hooks/dist/dgs-context-monitor.js +141 -0
- package/hooks/dist/dgs-statusline.js +115 -0
- package/package.json +60 -0
- 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
|
+
};
|