@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,1406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ideas — Idea capture, CRUD, manifest-based ID allocation, state transitions,
|
|
3
|
+
* and section-aware parsing/building with Discussion Log and Research Log support.
|
|
4
|
+
*
|
|
5
|
+
* Provides the data model and operations for the ideas lifecycle:
|
|
6
|
+
* create, update, append-note, list, reject, restore, move-state,
|
|
7
|
+
* discuss-save, research-save, appendDiscussionEntry, and appendResearchEntry.
|
|
8
|
+
*
|
|
9
|
+
* Ideas are stored as markdown files with YAML frontmatter in
|
|
10
|
+
* .planning/ideas/{pending,done,rejected}/ directories.
|
|
11
|
+
* A manifest.json tracks the next available sequential ID (never reused).
|
|
12
|
+
*
|
|
13
|
+
* Section order: Body > ## Notes > ## Discussion Log > ## Research Log
|
|
14
|
+
* Sections are optional; headings only appear when content exists.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { safeReadFile, execGit, generateSlugInternal, output, error } = require('./core.cjs');
|
|
20
|
+
const { getPlanningRoot } = require('./paths.cjs');
|
|
21
|
+
const { extractNameFromAuthor } = require('./identity.cjs');
|
|
22
|
+
|
|
23
|
+
// ─── Manifest Management ─────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load the ideas manifest from .planning/ideas/manifest.json.
|
|
27
|
+
* Returns { next_id: N }. If missing, returns { next_id: 1 }.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} cwd - Working directory
|
|
30
|
+
* @returns {{ next_id: number }}
|
|
31
|
+
*/
|
|
32
|
+
function loadManifest(cwd) {
|
|
33
|
+
const manifestPath = path.join(getPlanningRoot(cwd), 'ideas', 'manifest.json');
|
|
34
|
+
const content = safeReadFile(manifestPath);
|
|
35
|
+
if (!content) return { next_id: 1 };
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(content);
|
|
38
|
+
return { next_id: parsed.next_id || 1 };
|
|
39
|
+
} catch {
|
|
40
|
+
return { next_id: 1 };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Save the ideas manifest to .planning/ideas/manifest.json.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} cwd - Working directory
|
|
48
|
+
* @param {{ next_id: number }} manifest
|
|
49
|
+
*/
|
|
50
|
+
function saveManifest(cwd, manifest) {
|
|
51
|
+
const manifestDir = path.join(getPlanningRoot(cwd), 'ideas');
|
|
52
|
+
fs.mkdirSync(manifestDir, { recursive: true });
|
|
53
|
+
const manifestPath = path.join(manifestDir, 'manifest.json');
|
|
54
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Allocate a new unique ID from the manifest. Increments next_id and saves.
|
|
59
|
+
* Returns zero-padded 3-digit string (e.g., "001", "042").
|
|
60
|
+
* IDs are never reused -- next_id only goes up.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} cwd - Working directory
|
|
63
|
+
* @returns {string} Zero-padded ID string
|
|
64
|
+
*/
|
|
65
|
+
function allocateId(cwd) {
|
|
66
|
+
const manifest = loadManifest(cwd);
|
|
67
|
+
const id = manifest.next_id;
|
|
68
|
+
manifest.next_id = id + 1;
|
|
69
|
+
saveManifest(cwd, manifest);
|
|
70
|
+
return String(id).padStart(3, '0');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Directory & File Helpers ─────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const IDEA_STATES = ['pending', 'done', 'rejected', 'consolidated'];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Ensure .planning/ideas/{pending,done,rejected}/ directories exist.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} cwd - Working directory
|
|
81
|
+
*/
|
|
82
|
+
function ensureIdeasDirs(cwd) {
|
|
83
|
+
for (const state of IDEA_STATES) {
|
|
84
|
+
fs.mkdirSync(path.join(getPlanningRoot(cwd), 'ideas', state), { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse YAML frontmatter from idea file content.
|
|
90
|
+
* Uses simple regex parsing (no YAML library -- zero-dependency project).
|
|
91
|
+
*
|
|
92
|
+
* Section-aware: splits content after frontmatter into body, notes,
|
|
93
|
+
* discussionLog, and researchLog based on H2 markers.
|
|
94
|
+
* Section markers are case-sensitive: `## Notes`, `## Discussion Log`, `## Research Log`.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} content - File content
|
|
97
|
+
* @returns {{ frontmatter: object, body: string, notes: string, discussionLog: string, researchLog: string }}
|
|
98
|
+
*/
|
|
99
|
+
function parseIdeaFrontmatter(content) {
|
|
100
|
+
const match = content.match(/^---\n([\s\S]+?)\n---\n?([\s\S]*)/);
|
|
101
|
+
if (!match) return { frontmatter: {}, body: content, notes: '', discussionLog: '', researchLog: '' };
|
|
102
|
+
|
|
103
|
+
const yaml = match[1];
|
|
104
|
+
const remaining = match[2] || '';
|
|
105
|
+
const frontmatter = {};
|
|
106
|
+
|
|
107
|
+
const lines = yaml.split('\n');
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
if (line.trim() === '') continue;
|
|
110
|
+
|
|
111
|
+
// Handle array values (tags)
|
|
112
|
+
const arrayMatch = line.match(/^(\w+):\s*\[([^\]]*)\]/);
|
|
113
|
+
if (arrayMatch) {
|
|
114
|
+
const key = arrayMatch[1];
|
|
115
|
+
const values = arrayMatch[2]
|
|
116
|
+
.split(',')
|
|
117
|
+
.map(v => v.trim().replace(/^["']|["']$/g, ''))
|
|
118
|
+
.filter(v => v !== '');
|
|
119
|
+
frontmatter[key] = values;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Handle simple key: value
|
|
124
|
+
const kvMatch = line.match(/^(\w+):\s*(.*)/);
|
|
125
|
+
if (kvMatch) {
|
|
126
|
+
const key = kvMatch[1];
|
|
127
|
+
let value = kvMatch[2].trim();
|
|
128
|
+
// Remove quotes
|
|
129
|
+
value = value.replace(/^["']|["']$/g, '');
|
|
130
|
+
// Parse numeric id
|
|
131
|
+
if (key === 'id') {
|
|
132
|
+
frontmatter[key] = parseInt(value, 10);
|
|
133
|
+
} else {
|
|
134
|
+
frontmatter[key] = value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Split remaining content into sections using H2 markers (case-sensitive)
|
|
140
|
+
const sectionMarkers = [
|
|
141
|
+
{ key: 'notes', heading: '## Notes' },
|
|
142
|
+
{ key: 'discussionLog', heading: '## Discussion Log' },
|
|
143
|
+
{ key: 'researchLog', heading: '## Research Log' },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// Find position of each marker in the remaining content
|
|
147
|
+
const positions = [];
|
|
148
|
+
for (const marker of sectionMarkers) {
|
|
149
|
+
// Match the heading at the start of a line (after newline or at start)
|
|
150
|
+
const pattern = new RegExp('(?:^|\\n)(' + marker.heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')\\n');
|
|
151
|
+
const m = remaining.match(pattern);
|
|
152
|
+
if (m) {
|
|
153
|
+
// Find the actual index of the heading in remaining
|
|
154
|
+
const idx = remaining.indexOf(m[1], m.index);
|
|
155
|
+
positions.push({ key: marker.key, heading: marker.heading, index: idx });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Sort positions by index
|
|
160
|
+
positions.sort((a, b) => a.index - b.index);
|
|
161
|
+
|
|
162
|
+
// Extract body (everything before first section marker, or all content if no markers)
|
|
163
|
+
let body = '';
|
|
164
|
+
const sections = { notes: '', discussionLog: '', researchLog: '' };
|
|
165
|
+
|
|
166
|
+
if (positions.length === 0) {
|
|
167
|
+
body = remaining;
|
|
168
|
+
} else {
|
|
169
|
+
body = remaining.substring(0, positions[0].index);
|
|
170
|
+
// Extract each section's content
|
|
171
|
+
for (let i = 0; i < positions.length; i++) {
|
|
172
|
+
const pos = positions[i];
|
|
173
|
+
const headingEnd = pos.index + pos.heading.length + 1; // +1 for the newline after heading
|
|
174
|
+
const nextStart = (i + 1 < positions.length) ? positions[i + 1].index : remaining.length;
|
|
175
|
+
sections[pos.key] = remaining.substring(headingEnd, nextStart);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Trim trailing whitespace from sections but preserve internal formatting
|
|
180
|
+
// Remove leading newline from section content (the one right after the heading)
|
|
181
|
+
for (const key of Object.keys(sections)) {
|
|
182
|
+
if (sections[key]) {
|
|
183
|
+
sections[key] = sections[key].replace(/^\n/, '');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { frontmatter, body, notes: sections.notes, discussionLog: sections.discussionLog, researchLog: sections.researchLog };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build idea file content from frontmatter and sections.
|
|
192
|
+
*
|
|
193
|
+
* Assembles sections in order: body, ## Notes, ## Discussion Log, ## Research Log.
|
|
194
|
+
* Section headings are only emitted when the section has non-empty content.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} frontmatter - Frontmatter fields
|
|
197
|
+
* @param {string} body - Body content
|
|
198
|
+
* @param {string} [notes=''] - Notes section content (without ## Notes heading)
|
|
199
|
+
* @param {string} [discussionLog=''] - Discussion Log content (without ## Discussion Log heading)
|
|
200
|
+
* @param {string} [researchLog=''] - Research Log content (without ## Research Log heading)
|
|
201
|
+
* @returns {string} Full file content
|
|
202
|
+
*/
|
|
203
|
+
function buildIdeaContent(frontmatter, body, notes, discussionLog, researchLog) {
|
|
204
|
+
notes = notes || '';
|
|
205
|
+
discussionLog = discussionLog || '';
|
|
206
|
+
researchLog = researchLog || '';
|
|
207
|
+
|
|
208
|
+
let yaml = '---\n';
|
|
209
|
+
yaml += `id: ${frontmatter.id}\n`;
|
|
210
|
+
yaml += `title: "${frontmatter.title}"\n`;
|
|
211
|
+
if (frontmatter.tags && frontmatter.tags.length > 0) {
|
|
212
|
+
yaml += `tags: [${frontmatter.tags.map(t => `"${t}"`).join(', ')}]\n`;
|
|
213
|
+
} else {
|
|
214
|
+
yaml += 'tags: []\n';
|
|
215
|
+
}
|
|
216
|
+
yaml += `created: ${frontmatter.created}\n`;
|
|
217
|
+
yaml += `updated: ${frontmatter.updated}\n`;
|
|
218
|
+
if (frontmatter.author) {
|
|
219
|
+
yaml += `author: "${frontmatter.author}"\n`;
|
|
220
|
+
}
|
|
221
|
+
if (frontmatter.updated_by) {
|
|
222
|
+
yaml += `updated_by: "${frontmatter.updated_by}"\n`;
|
|
223
|
+
}
|
|
224
|
+
if (frontmatter.consolidated_from && frontmatter.consolidated_from.length > 0) {
|
|
225
|
+
yaml += `consolidated_from: [${frontmatter.consolidated_from.map(id => `"${id}"`).join(', ')}]\n`;
|
|
226
|
+
}
|
|
227
|
+
if (frontmatter.consolidated_into) {
|
|
228
|
+
yaml += `consolidated_into: "${frontmatter.consolidated_into}"\n`;
|
|
229
|
+
}
|
|
230
|
+
yaml += '---\n';
|
|
231
|
+
|
|
232
|
+
// Body
|
|
233
|
+
if (body) {
|
|
234
|
+
yaml += '\n' + body;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Ensure a blank line before sections
|
|
238
|
+
if (notes || discussionLog || researchLog) {
|
|
239
|
+
// Ensure the body ends with a newline for clean separation
|
|
240
|
+
if (!yaml.endsWith('\n')) {
|
|
241
|
+
yaml += '\n';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Notes section
|
|
246
|
+
if (notes) {
|
|
247
|
+
yaml += '\n## Notes\n\n' + notes;
|
|
248
|
+
if (!notes.endsWith('\n')) {
|
|
249
|
+
yaml += '\n';
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Discussion Log section
|
|
254
|
+
if (discussionLog) {
|
|
255
|
+
yaml += '\n## Discussion Log\n\n' + discussionLog;
|
|
256
|
+
if (!discussionLog.endsWith('\n')) {
|
|
257
|
+
yaml += '\n';
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Research Log section
|
|
262
|
+
if (researchLog) {
|
|
263
|
+
yaml += '\n## Research Log\n\n' + researchLog;
|
|
264
|
+
if (!researchLog.endsWith('\n')) {
|
|
265
|
+
yaml += '\n';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return yaml;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Find an idea file by numeric ID or exact filename across all state directories.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} cwd - Working directory
|
|
276
|
+
* @param {string} idOrFilename - Numeric ID (e.g., "42", "042") or filename
|
|
277
|
+
* @returns {{ path: string, state: string, filename: string, frontmatter: object, body: string, notes: string, discussionLog: string, researchLog: string } | null}
|
|
278
|
+
*/
|
|
279
|
+
function findIdeaFile(cwd, idOrFilename) {
|
|
280
|
+
if (!idOrFilename) return null;
|
|
281
|
+
|
|
282
|
+
const idStr = idOrFilename.replace(/^0+/, '') || '0';
|
|
283
|
+
const paddedId = String(parseInt(idStr, 10)).padStart(3, '0');
|
|
284
|
+
|
|
285
|
+
for (const state of IDEA_STATES) {
|
|
286
|
+
const dir = path.join(getPlanningRoot(cwd), 'ideas', state);
|
|
287
|
+
let files;
|
|
288
|
+
try {
|
|
289
|
+
files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
290
|
+
} catch {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const file of files) {
|
|
295
|
+
// Match by ID prefix
|
|
296
|
+
if (file.startsWith(paddedId + '-')) {
|
|
297
|
+
const content = safeReadFile(path.join(dir, file));
|
|
298
|
+
if (!content) continue;
|
|
299
|
+
const { frontmatter, body, notes, discussionLog, researchLog } = parseIdeaFrontmatter(content);
|
|
300
|
+
return { path: path.join(dir, file), state, filename: file, frontmatter, body, notes, discussionLog, researchLog };
|
|
301
|
+
}
|
|
302
|
+
// Match by exact filename
|
|
303
|
+
if (file === idOrFilename) {
|
|
304
|
+
const content = safeReadFile(path.join(dir, file));
|
|
305
|
+
if (!content) continue;
|
|
306
|
+
const { frontmatter, body, notes, discussionLog, researchLog } = parseIdeaFrontmatter(content);
|
|
307
|
+
return { path: path.join(dir, file), state, filename: file, frontmatter, body, notes, discussionLog, researchLog };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ─── CRUD Operations ──────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Create a new idea with auto-assigned ID.
|
|
319
|
+
*
|
|
320
|
+
* @param {string} cwd - Working directory
|
|
321
|
+
* @param {string} title - Idea title
|
|
322
|
+
* @param {string} body - Freeform body text
|
|
323
|
+
* @param {string} tags - Comma-separated tags string (or null)
|
|
324
|
+
* @param {boolean} raw - Raw output mode
|
|
325
|
+
* @param {string} [author] - Author string (e.g., "Name <email>"). When provided, sets author and updated_by.
|
|
326
|
+
*/
|
|
327
|
+
function cmdIdeasCreate(cwd, title, body, tags, raw, author) {
|
|
328
|
+
if (!title) {
|
|
329
|
+
error('title required for ideas create');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
ensureIdeasDirs(cwd);
|
|
333
|
+
|
|
334
|
+
const id = allocateId(cwd);
|
|
335
|
+
const slug = generateSlugInternal(title);
|
|
336
|
+
const filename = `${id}-${slug}.md`;
|
|
337
|
+
const planRoot = getPlanningRoot(cwd);
|
|
338
|
+
const planRootRel = path.relative(cwd, planRoot) || '.';
|
|
339
|
+
const filePath = path.join(planRoot, 'ideas', 'pending', filename);
|
|
340
|
+
const now = new Date().toISOString();
|
|
341
|
+
|
|
342
|
+
const parsedTags = tags
|
|
343
|
+
? tags.split(',').map(t => t.trim()).filter(t => t !== '')
|
|
344
|
+
: [];
|
|
345
|
+
|
|
346
|
+
const frontmatter = {
|
|
347
|
+
id: parseInt(id, 10),
|
|
348
|
+
title,
|
|
349
|
+
tags: parsedTags,
|
|
350
|
+
created: now,
|
|
351
|
+
updated: now,
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
if (author) {
|
|
355
|
+
frontmatter.author = author;
|
|
356
|
+
frontmatter.updated_by = author;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const content = buildIdeaContent(frontmatter, body || '');
|
|
360
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
361
|
+
|
|
362
|
+
const result = {
|
|
363
|
+
id: parseInt(id, 10),
|
|
364
|
+
filename,
|
|
365
|
+
path: path.join(planRootRel, 'ideas', 'pending', filename),
|
|
366
|
+
title,
|
|
367
|
+
};
|
|
368
|
+
output(result, raw);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Update a field on an existing idea.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} cwd - Working directory
|
|
375
|
+
* @param {string} idOrFilename - Idea ID or filename
|
|
376
|
+
* @param {string} field - Field to update (title, body, tags)
|
|
377
|
+
* @param {string} value - New value
|
|
378
|
+
* @param {boolean} raw - Raw output mode
|
|
379
|
+
* @param {string} [author] - Author string. When provided, sets updated_by (never changes original author).
|
|
380
|
+
*/
|
|
381
|
+
function cmdIdeasUpdate(cwd, idOrFilename, field, value, raw, author) {
|
|
382
|
+
if (!idOrFilename) {
|
|
383
|
+
error('id required for ideas update');
|
|
384
|
+
}
|
|
385
|
+
if (!field) {
|
|
386
|
+
error('field required for ideas update');
|
|
387
|
+
}
|
|
388
|
+
if (value === null || value === undefined) {
|
|
389
|
+
error('value required for ideas update');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const idea = findIdeaFile(cwd, idOrFilename);
|
|
393
|
+
if (!idea) {
|
|
394
|
+
error(`idea not found: ${idOrFilename}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const fm = idea.frontmatter;
|
|
398
|
+
let oldValue;
|
|
399
|
+
let body = idea.body;
|
|
400
|
+
|
|
401
|
+
if (field === 'title') {
|
|
402
|
+
oldValue = fm.title;
|
|
403
|
+
fm.title = value;
|
|
404
|
+
} else if (field === 'body') {
|
|
405
|
+
oldValue = body.trim();
|
|
406
|
+
body = value;
|
|
407
|
+
} else if (field === 'tags') {
|
|
408
|
+
oldValue = (fm.tags || []).join(', ');
|
|
409
|
+
fm.tags = value.split(',').map(t => t.trim()).filter(t => t !== '');
|
|
410
|
+
} else {
|
|
411
|
+
error(`unknown field: ${field}. Use title, body, or tags`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
fm.updated = new Date().toISOString();
|
|
415
|
+
if (author) {
|
|
416
|
+
fm.updated_by = author;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const content = buildIdeaContent(fm, body, idea.notes, idea.discussionLog, idea.researchLog);
|
|
420
|
+
fs.writeFileSync(idea.path, content, 'utf-8');
|
|
421
|
+
|
|
422
|
+
// If title changed, rename the file
|
|
423
|
+
let newFilename = idea.filename;
|
|
424
|
+
if (field === 'title') {
|
|
425
|
+
const idPadded = String(fm.id).padStart(3, '0');
|
|
426
|
+
const newSlug = generateSlugInternal(value);
|
|
427
|
+
newFilename = `${idPadded}-${newSlug}.md`;
|
|
428
|
+
if (newFilename !== idea.filename) {
|
|
429
|
+
const newPath = path.join(path.dirname(idea.path), newFilename);
|
|
430
|
+
fs.renameSync(idea.path, newPath);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
|
|
435
|
+
const result = {
|
|
436
|
+
id: fm.id,
|
|
437
|
+
filename: newFilename,
|
|
438
|
+
path: path.join(planRootRel, 'ideas', idea.state, newFilename),
|
|
439
|
+
field,
|
|
440
|
+
old_value: oldValue,
|
|
441
|
+
new_value: value,
|
|
442
|
+
};
|
|
443
|
+
output(result, raw);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Append a timestamped note to an idea.
|
|
448
|
+
*
|
|
449
|
+
* @param {string} cwd - Working directory
|
|
450
|
+
* @param {string} idOrFilename - Idea ID or filename
|
|
451
|
+
* @param {string} noteText - Note text
|
|
452
|
+
* @param {boolean} raw - Raw output mode
|
|
453
|
+
* @param {string} [author] - Author string. When provided, sets updated_by (never changes original author).
|
|
454
|
+
*/
|
|
455
|
+
function cmdIdeasAppendNote(cwd, idOrFilename, noteText, raw, author) {
|
|
456
|
+
if (!idOrFilename) {
|
|
457
|
+
error('id required for ideas append-note');
|
|
458
|
+
}
|
|
459
|
+
if (!noteText) {
|
|
460
|
+
error('note required for ideas append-note');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const idea = findIdeaFile(cwd, idOrFilename);
|
|
464
|
+
if (!idea) {
|
|
465
|
+
error(`idea not found: ${idOrFilename}`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const fm = idea.frontmatter;
|
|
469
|
+
fm.updated = new Date().toISOString();
|
|
470
|
+
if (author) {
|
|
471
|
+
fm.updated_by = author;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const now = new Date().toISOString().split('T')[0];
|
|
475
|
+
const noteEntry = `---\n**${now}:** ${noteText}\n`;
|
|
476
|
+
|
|
477
|
+
// Append new note to existing notes section
|
|
478
|
+
const updatedNotes = idea.notes
|
|
479
|
+
? idea.notes + noteEntry
|
|
480
|
+
: noteEntry;
|
|
481
|
+
|
|
482
|
+
const content = buildIdeaContent(fm, idea.body, updatedNotes, idea.discussionLog, idea.researchLog);
|
|
483
|
+
fs.writeFileSync(idea.path, content, 'utf-8');
|
|
484
|
+
|
|
485
|
+
const result = {
|
|
486
|
+
id: fm.id,
|
|
487
|
+
filename: idea.filename,
|
|
488
|
+
note_added: true,
|
|
489
|
+
};
|
|
490
|
+
output(result, raw);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* List ideas grouped by state with optional filtering.
|
|
495
|
+
*
|
|
496
|
+
* @param {string} cwd - Working directory
|
|
497
|
+
* @param {{ state: string|null, tag: string|null, include_orphan_check: boolean, include_consolidated: boolean, show_all: boolean }} options
|
|
498
|
+
* @param {boolean} raw - Raw output mode
|
|
499
|
+
*/
|
|
500
|
+
function cmdIdeasList(cwd, options, raw) {
|
|
501
|
+
const ideas = [];
|
|
502
|
+
const counts = { pending: 0, done: 0, rejected: 0, consolidated: 0, total: 0 };
|
|
503
|
+
const orphanedIds = [];
|
|
504
|
+
|
|
505
|
+
// Determine which states to iterate
|
|
506
|
+
let statesToList;
|
|
507
|
+
if (options.state) {
|
|
508
|
+
statesToList = [options.state];
|
|
509
|
+
} else if (options.include_consolidated) {
|
|
510
|
+
statesToList = ['consolidated'];
|
|
511
|
+
} else if (options.show_all) {
|
|
512
|
+
statesToList = IDEA_STATES;
|
|
513
|
+
} else {
|
|
514
|
+
statesToList = ['pending', 'done', 'rejected']; // default: hide consolidated
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
for (const state of statesToList) {
|
|
518
|
+
|
|
519
|
+
const dir = path.join(getPlanningRoot(cwd), 'ideas', state);
|
|
520
|
+
let files;
|
|
521
|
+
try {
|
|
522
|
+
files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
523
|
+
} catch {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
for (const file of files) {
|
|
528
|
+
const content = safeReadFile(path.join(dir, file));
|
|
529
|
+
if (!content) continue;
|
|
530
|
+
|
|
531
|
+
const { frontmatter, discussionLog, researchLog } = parseIdeaFrontmatter(content);
|
|
532
|
+
|
|
533
|
+
// Tag filter (case-insensitive)
|
|
534
|
+
if (options.tag) {
|
|
535
|
+
const tagLower = options.tag.toLowerCase();
|
|
536
|
+
const tags = (frontmatter.tags || []).map(t => t.toLowerCase());
|
|
537
|
+
if (!tags.includes(tagLower)) continue;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Author filter (case-insensitive substring match on name-only)
|
|
541
|
+
const authorName = extractNameFromAuthor(frontmatter.author || '');
|
|
542
|
+
if (options.author) {
|
|
543
|
+
const filterAuthor = options.author.toLowerCase();
|
|
544
|
+
if (!authorName.toLowerCase().includes(filterAuthor)) continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const ideaObj = {
|
|
548
|
+
id: frontmatter.id,
|
|
549
|
+
title: frontmatter.title || '',
|
|
550
|
+
tags: frontmatter.tags || [],
|
|
551
|
+
created: frontmatter.created || '',
|
|
552
|
+
updated: frontmatter.updated || '',
|
|
553
|
+
author: authorName,
|
|
554
|
+
state,
|
|
555
|
+
filename: file,
|
|
556
|
+
discussed: !!(discussionLog && discussionLog.trim().length > 0),
|
|
557
|
+
researched: !!(researchLog && researchLog.trim().length > 0),
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
if (state === 'consolidated' && frontmatter.consolidated_into) {
|
|
561
|
+
ideaObj.consolidated_into = frontmatter.consolidated_into;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
ideas.push(ideaObj);
|
|
565
|
+
|
|
566
|
+
counts[state]++;
|
|
567
|
+
counts.total++;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Orphan check: scan specs for references to done idea IDs
|
|
572
|
+
if (options.include_orphan_check) {
|
|
573
|
+
const doneIdeas = ideas.filter(i => i.state === 'done');
|
|
574
|
+
const specsDir = path.join(getPlanningRoot(cwd), 'specs');
|
|
575
|
+
|
|
576
|
+
let specContent = '';
|
|
577
|
+
try {
|
|
578
|
+
const specFiles = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
|
|
579
|
+
for (const sf of specFiles) {
|
|
580
|
+
specContent += safeReadFile(path.join(specsDir, sf)) || '';
|
|
581
|
+
}
|
|
582
|
+
} catch {
|
|
583
|
+
// No specs dir
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
for (const idea of doneIdeas) {
|
|
587
|
+
const idPadded = String(idea.id).padStart(3, '0');
|
|
588
|
+
// Check if any spec references this idea's ID
|
|
589
|
+
if (!specContent.includes(idPadded) && !specContent.includes(String(idea.id))) {
|
|
590
|
+
idea.orphaned = true;
|
|
591
|
+
orphanedIds.push(idea.id);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const result = {
|
|
597
|
+
ideas,
|
|
598
|
+
counts,
|
|
599
|
+
orphaned_ids: orphanedIds,
|
|
600
|
+
};
|
|
601
|
+
output(result, raw);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Reject an idea (move from pending to rejected).
|
|
606
|
+
*
|
|
607
|
+
* @param {string} cwd - Working directory
|
|
608
|
+
* @param {string} idOrFilename - Idea ID or filename
|
|
609
|
+
* @param {string} reason - Rejection reason (optional)
|
|
610
|
+
* @param {boolean} raw - Raw output mode
|
|
611
|
+
* @param {string} [author] - Author string. When provided, sets updated_by before move.
|
|
612
|
+
*/
|
|
613
|
+
function cmdIdeasReject(cwd, idOrFilename, reason, raw, author) {
|
|
614
|
+
if (!idOrFilename) {
|
|
615
|
+
error('id required for ideas reject');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const idea = findIdeaFile(cwd, idOrFilename);
|
|
619
|
+
if (!idea) {
|
|
620
|
+
error(`idea not found: ${idOrFilename}`);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (idea.state !== 'pending') {
|
|
624
|
+
if (idea.state === 'rejected') {
|
|
625
|
+
error(`idea ${idOrFilename} is already rejected`);
|
|
626
|
+
}
|
|
627
|
+
error(`idea ${idOrFilename} is in ${idea.state} state, only pending ideas can be rejected`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Re-parse file to get all sections
|
|
631
|
+
const currentContent = safeReadFile(idea.path) || '';
|
|
632
|
+
const { frontmatter: fm, body: bd, notes: nt, discussionLog: dl, researchLog: rl } = parseIdeaFrontmatter(currentContent);
|
|
633
|
+
|
|
634
|
+
// Append rejection reason to notes section if provided
|
|
635
|
+
let updatedNotes = nt;
|
|
636
|
+
if (reason) {
|
|
637
|
+
const now = new Date().toISOString().split('T')[0];
|
|
638
|
+
const reasonEntry = `---\n**Rejected (${now}):** ${reason}\n`;
|
|
639
|
+
updatedNotes = nt ? nt + reasonEntry : reasonEntry;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Set updated_by if author provided
|
|
643
|
+
if (author) {
|
|
644
|
+
fm.updated_by = author;
|
|
645
|
+
fm.updated = new Date().toISOString();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Rebuild with all sections preserved
|
|
649
|
+
const rebuilt = buildIdeaContent(fm, bd, updatedNotes, dl, rl);
|
|
650
|
+
fs.writeFileSync(idea.path, rebuilt, 'utf-8');
|
|
651
|
+
|
|
652
|
+
ensureIdeasDirs(cwd);
|
|
653
|
+
|
|
654
|
+
// Git mv from pending to rejected
|
|
655
|
+
const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
|
|
656
|
+
const fromRel = path.join(planRootRel, 'ideas', 'pending', idea.filename);
|
|
657
|
+
const toRel = path.join(planRootRel, 'ideas', 'rejected', idea.filename);
|
|
658
|
+
execGit(cwd, ['mv', fromRel, toRel]);
|
|
659
|
+
|
|
660
|
+
// Auto-commit
|
|
661
|
+
execGit(cwd, ['add', '-A']);
|
|
662
|
+
const title = idea.frontmatter.title || idea.filename;
|
|
663
|
+
const idNum = idea.frontmatter.id || idOrFilename;
|
|
664
|
+
execGit(cwd, ['commit', '-m', `ideas: reject #${idNum} - ${title}`]);
|
|
665
|
+
|
|
666
|
+
const result = {
|
|
667
|
+
id: idea.frontmatter.id,
|
|
668
|
+
filename: idea.filename,
|
|
669
|
+
from: idea.state,
|
|
670
|
+
to: 'rejected',
|
|
671
|
+
reason: reason || null,
|
|
672
|
+
};
|
|
673
|
+
output(result, raw);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Restore an idea to pending (from rejected or done).
|
|
678
|
+
*
|
|
679
|
+
* @param {string} cwd - Working directory
|
|
680
|
+
* @param {string} idOrFilename - Idea ID or filename
|
|
681
|
+
* @param {boolean} raw - Raw output mode
|
|
682
|
+
* @param {string} [author] - Author string. When provided, sets updated_by before move.
|
|
683
|
+
*/
|
|
684
|
+
function cmdIdeasRestore(cwd, idOrFilename, raw, author) {
|
|
685
|
+
if (!idOrFilename) {
|
|
686
|
+
error('id required for ideas restore');
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const idea = findIdeaFile(cwd, idOrFilename);
|
|
690
|
+
if (!idea) {
|
|
691
|
+
error(`idea not found: ${idOrFilename}`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (idea.state === 'pending') {
|
|
695
|
+
error(`idea ${idOrFilename} is already in pending`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Remove consolidated_into when restoring from consolidated state
|
|
699
|
+
if (idea.state === 'consolidated') {
|
|
700
|
+
const currentContent = safeReadFile(idea.path) || '';
|
|
701
|
+
const { frontmatter: fm, body: bd, notes: nt, discussionLog: dl, researchLog: rl } = parseIdeaFrontmatter(currentContent);
|
|
702
|
+
delete fm.consolidated_into;
|
|
703
|
+
fm.updated = new Date().toISOString();
|
|
704
|
+
if (author) {
|
|
705
|
+
fm.updated_by = author;
|
|
706
|
+
}
|
|
707
|
+
fs.writeFileSync(idea.path, buildIdeaContent(fm, bd, nt, dl, rl), 'utf-8');
|
|
708
|
+
} else if (author) {
|
|
709
|
+
// Set updated_by for non-consolidated restores
|
|
710
|
+
const currentContent = safeReadFile(idea.path) || '';
|
|
711
|
+
const { frontmatter: fm, body: bd, notes: nt, discussionLog: dl, researchLog: rl } = parseIdeaFrontmatter(currentContent);
|
|
712
|
+
fm.updated_by = author;
|
|
713
|
+
fm.updated = new Date().toISOString();
|
|
714
|
+
fs.writeFileSync(idea.path, buildIdeaContent(fm, bd, nt, dl, rl), 'utf-8');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
ensureIdeasDirs(cwd);
|
|
718
|
+
|
|
719
|
+
// Git mv to pending (keep rejection reason in file as history)
|
|
720
|
+
const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
|
|
721
|
+
const fromRel = path.join(planRootRel, 'ideas', idea.state, idea.filename);
|
|
722
|
+
const toRel = path.join(planRootRel, 'ideas', 'pending', idea.filename);
|
|
723
|
+
execGit(cwd, ['mv', fromRel, toRel]);
|
|
724
|
+
|
|
725
|
+
// Auto-commit
|
|
726
|
+
execGit(cwd, ['add', '-A']);
|
|
727
|
+
const title = idea.frontmatter.title || idea.filename;
|
|
728
|
+
const idNum = idea.frontmatter.id || idOrFilename;
|
|
729
|
+
execGit(cwd, ['commit', '-m', `ideas: restore #${idNum} - ${title} to pending`]);
|
|
730
|
+
|
|
731
|
+
const result = {
|
|
732
|
+
id: idea.frontmatter.id,
|
|
733
|
+
filename: idea.filename,
|
|
734
|
+
from: idea.state,
|
|
735
|
+
to: 'pending',
|
|
736
|
+
};
|
|
737
|
+
output(result, raw);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Move an idea to a target state (general-purpose state transition).
|
|
742
|
+
*
|
|
743
|
+
* @param {string} cwd - Working directory
|
|
744
|
+
* @param {string} idOrFilename - Idea ID or filename
|
|
745
|
+
* @param {string} targetState - Target state (pending, done, rejected)
|
|
746
|
+
* @param {boolean} raw - Raw output mode
|
|
747
|
+
* @param {string} [author] - Author string. When provided, sets updated_by before move.
|
|
748
|
+
*/
|
|
749
|
+
function cmdIdeasMoveState(cwd, idOrFilename, targetState, raw, author) {
|
|
750
|
+
if (!idOrFilename) {
|
|
751
|
+
error('id required for ideas move-state');
|
|
752
|
+
}
|
|
753
|
+
if (!targetState) {
|
|
754
|
+
error('target state required for ideas move-state');
|
|
755
|
+
}
|
|
756
|
+
if (!IDEA_STATES.includes(targetState)) {
|
|
757
|
+
error(`invalid state: ${targetState}. Use ${IDEA_STATES.join(', ')}`);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const idea = findIdeaFile(cwd, idOrFilename);
|
|
761
|
+
if (!idea) {
|
|
762
|
+
error(`idea not found: ${idOrFilename}`);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (idea.state === targetState) {
|
|
766
|
+
error(`idea ${idOrFilename} is already in ${targetState}`);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Set updated_by if author provided
|
|
770
|
+
if (author) {
|
|
771
|
+
const currentContent = safeReadFile(idea.path) || '';
|
|
772
|
+
const { frontmatter: fm, body: bd, notes: nt, discussionLog: dl, researchLog: rl } = parseIdeaFrontmatter(currentContent);
|
|
773
|
+
fm.updated_by = author;
|
|
774
|
+
fm.updated = new Date().toISOString();
|
|
775
|
+
fs.writeFileSync(idea.path, buildIdeaContent(fm, bd, nt, dl, rl), 'utf-8');
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
ensureIdeasDirs(cwd);
|
|
779
|
+
|
|
780
|
+
// Git mv
|
|
781
|
+
const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
|
|
782
|
+
const fromRel = path.join(planRootRel, 'ideas', idea.state, idea.filename);
|
|
783
|
+
const toRel = path.join(planRootRel, 'ideas', targetState, idea.filename);
|
|
784
|
+
execGit(cwd, ['mv', fromRel, toRel]);
|
|
785
|
+
|
|
786
|
+
// Auto-commit
|
|
787
|
+
execGit(cwd, ['add', '-A']);
|
|
788
|
+
const title = idea.frontmatter.title || idea.filename;
|
|
789
|
+
const idNum = idea.frontmatter.id || idOrFilename;
|
|
790
|
+
execGit(cwd, ['commit', '-m', `ideas: move #${idNum} - ${title} to ${targetState}`]);
|
|
791
|
+
|
|
792
|
+
const result = {
|
|
793
|
+
id: idea.frontmatter.id,
|
|
794
|
+
filename: idea.filename,
|
|
795
|
+
from: idea.state,
|
|
796
|
+
to: targetState,
|
|
797
|
+
};
|
|
798
|
+
output(result, raw);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ─── Section Entry Helpers ────────────────────────────────────────────────────
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Append a discussion entry to an idea's content string.
|
|
805
|
+
*
|
|
806
|
+
* Parses the content, formats a new H3 entry, prepends it to the existing
|
|
807
|
+
* discussionLog (newest first), rebuilds, and returns the full content.
|
|
808
|
+
*
|
|
809
|
+
* @param {string} content - Full idea file content string
|
|
810
|
+
* @param {{ date: string, keyInsights: string, refinedProblem: string, refinedApproach: string, openQuestions: string, decision: string }} entry
|
|
811
|
+
* @returns {string} Updated full idea content
|
|
812
|
+
*/
|
|
813
|
+
function appendDiscussionEntry(content, entry) {
|
|
814
|
+
const parsed = parseIdeaFrontmatter(content);
|
|
815
|
+
|
|
816
|
+
const entryBlock = [
|
|
817
|
+
`### Discussion — ${entry.date}`,
|
|
818
|
+
'',
|
|
819
|
+
`**Key Insights:** ${entry.keyInsights}`,
|
|
820
|
+
`**Refined Problem:** ${entry.refinedProblem}`,
|
|
821
|
+
`**Refined Approach:** ${entry.refinedApproach}`,
|
|
822
|
+
`**Open Questions:** ${entry.openQuestions}`,
|
|
823
|
+
`**Decision:** ${entry.decision}`,
|
|
824
|
+
'',
|
|
825
|
+
].join('\n');
|
|
826
|
+
|
|
827
|
+
// Prepend new entry to existing discussion log (newest first)
|
|
828
|
+
const updatedDiscussionLog = parsed.discussionLog
|
|
829
|
+
? entryBlock + parsed.discussionLog
|
|
830
|
+
: entryBlock;
|
|
831
|
+
|
|
832
|
+
return buildIdeaContent(
|
|
833
|
+
parsed.frontmatter,
|
|
834
|
+
parsed.body,
|
|
835
|
+
parsed.notes,
|
|
836
|
+
updatedDiscussionLog,
|
|
837
|
+
parsed.researchLog
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Append a research entry to an idea's content string.
|
|
843
|
+
*
|
|
844
|
+
* Parses the content, formats a new H3 entry, prepends it to the existing
|
|
845
|
+
* researchLog (newest first), rebuilds, and returns the full content.
|
|
846
|
+
*
|
|
847
|
+
* @param {string} content - Full idea file content string
|
|
848
|
+
* @param {{ date: string, summary: string, keyFindings: string, recommendation: string, documentLink: string, outcome: string }} entry
|
|
849
|
+
* @returns {string} Updated full idea content
|
|
850
|
+
*/
|
|
851
|
+
function appendResearchEntry(content, entry) {
|
|
852
|
+
const parsed = parseIdeaFrontmatter(content);
|
|
853
|
+
|
|
854
|
+
const entryBlock = [
|
|
855
|
+
`### Research — ${entry.date}`,
|
|
856
|
+
'',
|
|
857
|
+
`**Summary:** ${entry.summary}`,
|
|
858
|
+
`**Key Findings:** ${entry.keyFindings}`,
|
|
859
|
+
`**Recommendation:** ${entry.recommendation}`,
|
|
860
|
+
`**Document:** ${entry.documentLink}`,
|
|
861
|
+
`**Outcome:** ${entry.outcome}`,
|
|
862
|
+
'',
|
|
863
|
+
].join('\n');
|
|
864
|
+
|
|
865
|
+
// Prepend new entry to existing research log (newest first)
|
|
866
|
+
const updatedResearchLog = parsed.researchLog
|
|
867
|
+
? entryBlock + parsed.researchLog
|
|
868
|
+
: entryBlock;
|
|
869
|
+
|
|
870
|
+
return buildIdeaContent(
|
|
871
|
+
parsed.frontmatter,
|
|
872
|
+
parsed.body,
|
|
873
|
+
parsed.notes,
|
|
874
|
+
parsed.discussionLog,
|
|
875
|
+
updatedResearchLog
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// ─── Discussion Save Command ──────────────────────────────────────────────────
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Save a structured discussion entry to an idea file.
|
|
883
|
+
*
|
|
884
|
+
* Reads the idea, calls appendDiscussionEntry, updates the Updated timestamp,
|
|
885
|
+
* optionally sets updated_by, rebuilds all sections, writes the file, and
|
|
886
|
+
* returns JSON confirmation.
|
|
887
|
+
*
|
|
888
|
+
* @param {string} cwd - Working directory
|
|
889
|
+
* @param {string} idOrFilename - Idea ID or filename
|
|
890
|
+
* @param {string} entryJson - JSON string with { date, keyInsights, refinedProblem, refinedApproach, openQuestions, decision }
|
|
891
|
+
* @param {boolean} raw - Raw output mode
|
|
892
|
+
* @param {string} [author] - Author string. When provided, sets updated_by.
|
|
893
|
+
*/
|
|
894
|
+
function cmdIdeasDiscussSave(cwd, idOrFilename, entryJson, raw, author) {
|
|
895
|
+
if (!idOrFilename) {
|
|
896
|
+
error('id required for ideas discuss-save');
|
|
897
|
+
}
|
|
898
|
+
if (!entryJson) {
|
|
899
|
+
error('entry required for ideas discuss-save');
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
let entry;
|
|
903
|
+
try {
|
|
904
|
+
entry = JSON.parse(entryJson);
|
|
905
|
+
} catch (e) {
|
|
906
|
+
error('invalid JSON for --entry: ' + e.message);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const idea = findIdeaFile(cwd, idOrFilename);
|
|
910
|
+
if (!idea) {
|
|
911
|
+
error(`idea not found: ${idOrFilename}`);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Read full file content
|
|
915
|
+
const content = fs.readFileSync(idea.path, 'utf-8');
|
|
916
|
+
|
|
917
|
+
// Append the discussion entry (handles prepend/newest-first)
|
|
918
|
+
const withEntry = appendDiscussionEntry(content, entry);
|
|
919
|
+
|
|
920
|
+
// Re-parse to get updated sections and frontmatter
|
|
921
|
+
const parsed = parseIdeaFrontmatter(withEntry);
|
|
922
|
+
const fm = parsed.frontmatter;
|
|
923
|
+
|
|
924
|
+
// Update timestamp
|
|
925
|
+
fm.updated = new Date().toISOString();
|
|
926
|
+
if (author) {
|
|
927
|
+
fm.updated_by = author;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Rebuild with all sections preserved
|
|
931
|
+
const updatedContent = buildIdeaContent(fm, parsed.body, parsed.notes, parsed.discussionLog, parsed.researchLog);
|
|
932
|
+
fs.writeFileSync(idea.path, updatedContent, 'utf-8');
|
|
933
|
+
|
|
934
|
+
// Compute relative path for output
|
|
935
|
+
const relPath = path.relative(cwd, idea.path);
|
|
936
|
+
|
|
937
|
+
output({ id: fm.id, filename: idea.filename, path: relPath, state: idea.state }, raw);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ─── Research Save Command ─────────────────────────────────────────────────────
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Save a structured research entry to an idea file.
|
|
944
|
+
*
|
|
945
|
+
* Reads the idea, calls appendResearchEntry, updates the Updated timestamp,
|
|
946
|
+
* optionally sets updated_by, rebuilds all sections, writes the file, and
|
|
947
|
+
* returns JSON confirmation.
|
|
948
|
+
*
|
|
949
|
+
* @param {string} cwd - Working directory
|
|
950
|
+
* @param {string} idOrFilename - Idea ID or filename
|
|
951
|
+
* @param {string} entryJson - JSON string with { date, summary, keyFindings, recommendation, documentLink, outcome }
|
|
952
|
+
* @param {boolean} raw - Raw output mode
|
|
953
|
+
* @param {string} [author] - Author string. When provided, sets updated_by.
|
|
954
|
+
*/
|
|
955
|
+
function cmdIdeasResearchSave(cwd, idOrFilename, entryJson, raw, author) {
|
|
956
|
+
if (!idOrFilename) {
|
|
957
|
+
error('id required for ideas research-save');
|
|
958
|
+
}
|
|
959
|
+
if (!entryJson) {
|
|
960
|
+
error('entry required for ideas research-save');
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
let entry;
|
|
964
|
+
try {
|
|
965
|
+
entry = JSON.parse(entryJson);
|
|
966
|
+
} catch (e) {
|
|
967
|
+
error('invalid JSON for --entry: ' + e.message);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const idea = findIdeaFile(cwd, idOrFilename);
|
|
971
|
+
if (!idea) {
|
|
972
|
+
error(`idea not found: ${idOrFilename}`);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Read full file content
|
|
976
|
+
const content = fs.readFileSync(idea.path, 'utf-8');
|
|
977
|
+
|
|
978
|
+
// Append the research entry (handles prepend/newest-first)
|
|
979
|
+
const withEntry = appendResearchEntry(content, entry);
|
|
980
|
+
|
|
981
|
+
// Re-parse to get updated sections and frontmatter
|
|
982
|
+
const parsed = parseIdeaFrontmatter(withEntry);
|
|
983
|
+
const fm = parsed.frontmatter;
|
|
984
|
+
|
|
985
|
+
// Update timestamp
|
|
986
|
+
fm.updated = new Date().toISOString();
|
|
987
|
+
if (author) {
|
|
988
|
+
fm.updated_by = author;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Rebuild with all sections preserved
|
|
992
|
+
const updatedContent = buildIdeaContent(fm, parsed.body, parsed.notes, parsed.discussionLog, parsed.researchLog);
|
|
993
|
+
fs.writeFileSync(idea.path, updatedContent, 'utf-8');
|
|
994
|
+
|
|
995
|
+
// Compute relative path for output
|
|
996
|
+
const relPath = path.relative(cwd, idea.path);
|
|
997
|
+
|
|
998
|
+
output({ id: fm.id, filename: idea.filename, path: relPath, state: idea.state }, raw);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// ─── Consolidation Note Helper ────────────────────────────────────────────────
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Append a consolidation note to a source idea file.
|
|
1005
|
+
*
|
|
1006
|
+
* Records that this idea was consolidated into a new combined idea,
|
|
1007
|
+
* with the new idea's ID, title, and date. Called before moving
|
|
1008
|
+
* source ideas to consolidated/.
|
|
1009
|
+
*
|
|
1010
|
+
* @param {string} ideaPath - Absolute path to the idea file
|
|
1011
|
+
* @param {string} newIdeaId - The ID of the consolidated idea (e.g., "042")
|
|
1012
|
+
* @param {string} newIdeaTitle - The title of the consolidated idea
|
|
1013
|
+
* @param {string} date - Date string (e.g., "2026-03-10")
|
|
1014
|
+
* @returns {string} Updated file content
|
|
1015
|
+
*/
|
|
1016
|
+
function appendConsolidationNote(ideaPath, newIdeaId, newIdeaTitle, date) {
|
|
1017
|
+
const content = safeReadFile(ideaPath) || '';
|
|
1018
|
+
const { frontmatter, body, notes, discussionLog, researchLog } = parseIdeaFrontmatter(content);
|
|
1019
|
+
|
|
1020
|
+
const noteLine = `- Consolidated into #${newIdeaId} (${newIdeaTitle}) on ${date}\n`;
|
|
1021
|
+
|
|
1022
|
+
// Append to existing notes or start new notes section
|
|
1023
|
+
const updatedNotes = notes ? notes + noteLine : noteLine;
|
|
1024
|
+
|
|
1025
|
+
const updatedContent = buildIdeaContent(frontmatter, body, updatedNotes, discussionLog, researchLog);
|
|
1026
|
+
fs.writeFileSync(ideaPath, updatedContent, 'utf-8');
|
|
1027
|
+
|
|
1028
|
+
return updatedContent;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ─── Consolidate Command ──────────────────────────────────────────────────────
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Consolidate 2+ pending ideas into a single new idea.
|
|
1035
|
+
*
|
|
1036
|
+
* Validates all source IDs exist and are pending, creates a new idea with
|
|
1037
|
+
* consolidated_from, annotates each source with consolidated_into and a
|
|
1038
|
+
* consolidation note, moves sources to consolidated/, and commits atomically.
|
|
1039
|
+
*
|
|
1040
|
+
* @param {string} cwd - Working directory
|
|
1041
|
+
* @param {{ ids: string, title: string, body?: string, tags?: string, discussion?: string, research?: string }} options
|
|
1042
|
+
* @param {boolean} raw - Raw output mode
|
|
1043
|
+
* @param {string} [author] - Author string
|
|
1044
|
+
*/
|
|
1045
|
+
function cmdIdeasConsolidate(cwd, options, raw, author) {
|
|
1046
|
+
const { ids, title, body, tags, discussion, research } = options;
|
|
1047
|
+
|
|
1048
|
+
// Validate required inputs
|
|
1049
|
+
if (!title) error('title required for ideas consolidate');
|
|
1050
|
+
if (!ids) error('ids required for ideas consolidate');
|
|
1051
|
+
|
|
1052
|
+
const idList = ids.split(',').map(s => s.trim()).filter(Boolean);
|
|
1053
|
+
if (idList.length < 2) error('at least 2 idea IDs required for consolidation');
|
|
1054
|
+
|
|
1055
|
+
// Resolve and validate all source ideas BEFORE making any changes
|
|
1056
|
+
const sourceIdeas = [];
|
|
1057
|
+
for (const id of idList) {
|
|
1058
|
+
const idea = findIdeaFile(cwd, id);
|
|
1059
|
+
if (!idea) error(`idea not found: ${id}`);
|
|
1060
|
+
if (idea.state !== 'pending') error(`idea ${id} is not in pending state (current: ${idea.state})`);
|
|
1061
|
+
sourceIdeas.push(idea);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
ensureIdeasDirs(cwd);
|
|
1065
|
+
|
|
1066
|
+
// Collect tags: union of all source tags + provided tags (deduplicated)
|
|
1067
|
+
const allTags = new Set();
|
|
1068
|
+
for (const idea of sourceIdeas) {
|
|
1069
|
+
if (idea.frontmatter.tags && Array.isArray(idea.frontmatter.tags)) {
|
|
1070
|
+
idea.frontmatter.tags.forEach(t => allTags.add(t));
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
if (tags) {
|
|
1074
|
+
tags.split(',').map(t => t.trim()).filter(Boolean).forEach(t => allTags.add(t));
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Allocate new ID and prepare file paths
|
|
1078
|
+
const newId = allocateId(cwd);
|
|
1079
|
+
const slug = generateSlugInternal(title);
|
|
1080
|
+
const filename = `${newId}-${slug}.md`;
|
|
1081
|
+
const planRoot = getPlanningRoot(cwd);
|
|
1082
|
+
const planRootRel = path.relative(cwd, planRoot) || '.';
|
|
1083
|
+
const filePath = path.join(planRoot, 'ideas', 'pending', filename);
|
|
1084
|
+
const now = new Date().toISOString();
|
|
1085
|
+
const dateStr = now.split('T')[0];
|
|
1086
|
+
|
|
1087
|
+
// Build new idea frontmatter
|
|
1088
|
+
const frontmatter = {
|
|
1089
|
+
id: parseInt(newId, 10),
|
|
1090
|
+
title,
|
|
1091
|
+
tags: [...allTags],
|
|
1092
|
+
created: now,
|
|
1093
|
+
updated: now,
|
|
1094
|
+
};
|
|
1095
|
+
if (author) {
|
|
1096
|
+
frontmatter.author = author;
|
|
1097
|
+
frontmatter.updated_by = author;
|
|
1098
|
+
}
|
|
1099
|
+
frontmatter.consolidated_from = sourceIdeas.map(i => String(i.frontmatter.id).padStart(3, '0'));
|
|
1100
|
+
|
|
1101
|
+
// Write new consolidated idea to pending/
|
|
1102
|
+
const content = buildIdeaContent(frontmatter, body || '', '', discussion || '', research || '');
|
|
1103
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
1104
|
+
|
|
1105
|
+
// Annotate and move each source idea
|
|
1106
|
+
const movedFiles = [];
|
|
1107
|
+
for (const idea of sourceIdeas) {
|
|
1108
|
+
// Set consolidated_into on source idea
|
|
1109
|
+
const srcContent = safeReadFile(idea.path) || '';
|
|
1110
|
+
const parsed = parseIdeaFrontmatter(srcContent);
|
|
1111
|
+
parsed.frontmatter.consolidated_into = newId;
|
|
1112
|
+
parsed.frontmatter.updated = now;
|
|
1113
|
+
if (author) parsed.frontmatter.updated_by = author;
|
|
1114
|
+
fs.writeFileSync(idea.path, buildIdeaContent(parsed.frontmatter, parsed.body, parsed.notes, parsed.discussionLog, parsed.researchLog), 'utf-8');
|
|
1115
|
+
|
|
1116
|
+
// Append consolidation note
|
|
1117
|
+
appendConsolidationNote(idea.path, newId, title, dateStr);
|
|
1118
|
+
|
|
1119
|
+
// Git mv source from pending/ to consolidated/
|
|
1120
|
+
const fromRel = path.join(planRootRel, 'ideas', 'pending', idea.filename);
|
|
1121
|
+
const toRel = path.join(planRootRel, 'ideas', 'consolidated', idea.filename);
|
|
1122
|
+
execGit(cwd, ['mv', fromRel, toRel]);
|
|
1123
|
+
|
|
1124
|
+
movedFiles.push({
|
|
1125
|
+
id: idea.frontmatter.id,
|
|
1126
|
+
filename: idea.filename,
|
|
1127
|
+
from: 'pending',
|
|
1128
|
+
to: 'consolidated',
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Atomic git commit
|
|
1133
|
+
execGit(cwd, ['add', '-A']);
|
|
1134
|
+
const sourceIdsStr = sourceIdeas.map(i => `#${i.frontmatter.id}`).join(', ');
|
|
1135
|
+
execGit(cwd, ['commit', '-m', `ideas: consolidate ${sourceIdsStr} -> #${parseInt(newId, 10)} - ${title}`]);
|
|
1136
|
+
|
|
1137
|
+
// Return structured JSON
|
|
1138
|
+
const result = {
|
|
1139
|
+
id: parseInt(newId, 10),
|
|
1140
|
+
filename,
|
|
1141
|
+
path: path.join(planRootRel, 'ideas', 'pending', filename),
|
|
1142
|
+
title,
|
|
1143
|
+
consolidated_from: frontmatter.consolidated_from,
|
|
1144
|
+
moved_files: movedFiles,
|
|
1145
|
+
};
|
|
1146
|
+
output(result, raw);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// ─── Find Related Ideas ───────────────────────────────────────────────────────
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Find ideas related to an anchor idea by tag overlap (Jaccard similarity).
|
|
1153
|
+
*
|
|
1154
|
+
* Scores all other pending ideas against the anchor, groups by match level,
|
|
1155
|
+
* applies threshold filtering, and returns full content for downstream AI scoring.
|
|
1156
|
+
*
|
|
1157
|
+
* Match levels (Jaccard similarity ratio):
|
|
1158
|
+
* HIGH: ratio >= 0.5
|
|
1159
|
+
* MEDIUM: ratio >= 0.2
|
|
1160
|
+
* LOW: ratio > 0
|
|
1161
|
+
* NONE: ratio === 0 (excluded from results)
|
|
1162
|
+
*
|
|
1163
|
+
* @param {string} cwd - Working directory
|
|
1164
|
+
* @param {{ id: string, threshold: string }} options
|
|
1165
|
+
* @param {boolean} raw - Raw output mode
|
|
1166
|
+
*/
|
|
1167
|
+
function cmdIdeasFindRelated(cwd, options, raw) {
|
|
1168
|
+
const { id, threshold } = options;
|
|
1169
|
+
|
|
1170
|
+
if (!id) {
|
|
1171
|
+
error('id required for ideas find-related');
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// 1. Find and validate anchor idea
|
|
1175
|
+
const anchor = findIdeaFile(cwd, id);
|
|
1176
|
+
if (!anchor) {
|
|
1177
|
+
error('Idea #' + id + ' not found');
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const anchorTags = anchor.frontmatter.tags || [];
|
|
1181
|
+
|
|
1182
|
+
// 2. Load all other pending ideas
|
|
1183
|
+
const pendingDir = path.join(getPlanningRoot(cwd), 'ideas', 'pending');
|
|
1184
|
+
let pendingFiles;
|
|
1185
|
+
try {
|
|
1186
|
+
pendingFiles = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
|
|
1187
|
+
} catch {
|
|
1188
|
+
pendingFiles = [];
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// 3. Score each candidate for tag overlap
|
|
1192
|
+
const matchLevelOrder = { HIGH: 0, MEDIUM: 1, LOW: 2, NONE: 3 };
|
|
1193
|
+
const candidates = [];
|
|
1194
|
+
|
|
1195
|
+
for (const file of pendingFiles) {
|
|
1196
|
+
const content = safeReadFile(path.join(pendingDir, file));
|
|
1197
|
+
if (!content) continue;
|
|
1198
|
+
|
|
1199
|
+
const parsed = parseIdeaFrontmatter(content);
|
|
1200
|
+
const candidateId = parsed.frontmatter.id;
|
|
1201
|
+
|
|
1202
|
+
// Skip the anchor idea itself
|
|
1203
|
+
if (candidateId === anchor.frontmatter.id) continue;
|
|
1204
|
+
|
|
1205
|
+
const candidateTags = parsed.frontmatter.tags || [];
|
|
1206
|
+
|
|
1207
|
+
// Jaccard similarity: intersection / union
|
|
1208
|
+
const sharedSet = new Set(anchorTags.filter(t => candidateTags.includes(t)));
|
|
1209
|
+
const unionSet = new Set([...anchorTags, ...candidateTags]);
|
|
1210
|
+
const shared = [...sharedSet];
|
|
1211
|
+
const ratio = unionSet.size === 0 ? 0 : shared.length / unionSet.size;
|
|
1212
|
+
|
|
1213
|
+
// Round ratio to avoid floating point display issues
|
|
1214
|
+
const roundedRatio = Math.round(ratio * 100) / 100;
|
|
1215
|
+
|
|
1216
|
+
// 4. Assign match levels
|
|
1217
|
+
let matchLevel;
|
|
1218
|
+
if (ratio >= 0.5) {
|
|
1219
|
+
matchLevel = 'HIGH';
|
|
1220
|
+
} else if (ratio >= 0.2) {
|
|
1221
|
+
matchLevel = 'MEDIUM';
|
|
1222
|
+
} else if (ratio > 0) {
|
|
1223
|
+
matchLevel = 'LOW';
|
|
1224
|
+
} else {
|
|
1225
|
+
matchLevel = 'NONE';
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Exclude NONE (zero overlap) entirely
|
|
1229
|
+
if (matchLevel === 'NONE') continue;
|
|
1230
|
+
|
|
1231
|
+
candidates.push({
|
|
1232
|
+
id: candidateId,
|
|
1233
|
+
title: parsed.frontmatter.title || '',
|
|
1234
|
+
tags: candidateTags,
|
|
1235
|
+
body: parsed.body || '',
|
|
1236
|
+
notes: parsed.notes || '',
|
|
1237
|
+
discussion_log: parsed.discussionLog || '',
|
|
1238
|
+
research_log: parsed.researchLog || '',
|
|
1239
|
+
tag_overlap: {
|
|
1240
|
+
shared,
|
|
1241
|
+
ratio: roundedRatio,
|
|
1242
|
+
},
|
|
1243
|
+
match_level: matchLevel,
|
|
1244
|
+
_ratio: ratio, // internal for sorting
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// 5. Apply threshold filter
|
|
1249
|
+
const thresholdLevel = (threshold || 'medium').toLowerCase();
|
|
1250
|
+
let allowedLevels;
|
|
1251
|
+
if (thresholdLevel === 'high') {
|
|
1252
|
+
allowedLevels = new Set(['HIGH']);
|
|
1253
|
+
} else if (thresholdLevel === 'medium') {
|
|
1254
|
+
allowedLevels = new Set(['HIGH', 'MEDIUM']);
|
|
1255
|
+
} else {
|
|
1256
|
+
// 'low' — include HIGH, MEDIUM, LOW (still excludes NONE)
|
|
1257
|
+
allowedLevels = new Set(['HIGH', 'MEDIUM', 'LOW']);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const filtered = candidates.filter(c => allowedLevels.has(c.match_level));
|
|
1261
|
+
|
|
1262
|
+
// 6. Sort results: HIGH first, then MEDIUM, then LOW
|
|
1263
|
+
// Within same level, sort by ratio descending
|
|
1264
|
+
filtered.sort((a, b) => {
|
|
1265
|
+
const levelDiff = matchLevelOrder[a.match_level] - matchLevelOrder[b.match_level];
|
|
1266
|
+
if (levelDiff !== 0) return levelDiff;
|
|
1267
|
+
return b._ratio - a._ratio;
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
// 7. Build result — remove internal _ratio field
|
|
1271
|
+
const results = filtered.map(c => {
|
|
1272
|
+
const { _ratio, ...rest } = c;
|
|
1273
|
+
return rest;
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
// Counts
|
|
1277
|
+
const counts = { high: 0, medium: 0, low: 0, total: results.length };
|
|
1278
|
+
for (const r of results) {
|
|
1279
|
+
if (r.match_level === 'HIGH') counts.high++;
|
|
1280
|
+
else if (r.match_level === 'MEDIUM') counts.medium++;
|
|
1281
|
+
else if (r.match_level === 'LOW') counts.low++;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// 8. Output JSON
|
|
1285
|
+
const result = {
|
|
1286
|
+
anchor: {
|
|
1287
|
+
id: anchor.frontmatter.id,
|
|
1288
|
+
title: anchor.frontmatter.title || '',
|
|
1289
|
+
tags: anchorTags,
|
|
1290
|
+
},
|
|
1291
|
+
results,
|
|
1292
|
+
counts,
|
|
1293
|
+
threshold: thresholdLevel,
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
output(result, raw);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// ─── Undo Consolidation Command ───────────────────────────────────────────────
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Undo a consolidation: restore all source ideas to pending and delete the consolidated idea.
|
|
1303
|
+
*
|
|
1304
|
+
* Given the ID of the consolidated result idea (the one with consolidated_from),
|
|
1305
|
+
* this function:
|
|
1306
|
+
* 1. Finds the consolidated idea and reads its consolidated_from list
|
|
1307
|
+
* 2. For each source ID in consolidated_from:
|
|
1308
|
+
* a. Finds the source idea in consolidated/ state
|
|
1309
|
+
* b. Removes consolidated_into from its frontmatter
|
|
1310
|
+
* c. Git mv from consolidated/ to pending/
|
|
1311
|
+
* 3. Deletes the consolidated idea file from pending/ (git rm)
|
|
1312
|
+
* 4. Creates a single atomic git commit
|
|
1313
|
+
*
|
|
1314
|
+
* @param {string} cwd - Working directory
|
|
1315
|
+
* @param {string} idOrFilename - ID of the consolidated result idea
|
|
1316
|
+
* @param {boolean} raw - Raw output mode
|
|
1317
|
+
* @param {string} [author] - Author string
|
|
1318
|
+
*/
|
|
1319
|
+
function cmdIdeasUndoConsolidation(cwd, idOrFilename, raw, author) {
|
|
1320
|
+
if (!idOrFilename) error('id required for ideas undo-consolidation');
|
|
1321
|
+
|
|
1322
|
+
const idea = findIdeaFile(cwd, idOrFilename);
|
|
1323
|
+
if (!idea) error(`idea not found: ${idOrFilename}`);
|
|
1324
|
+
|
|
1325
|
+
// Validate that this is a consolidated idea (has consolidated_from)
|
|
1326
|
+
const consolidatedFrom = idea.frontmatter.consolidated_from;
|
|
1327
|
+
if (!consolidatedFrom || !Array.isArray(consolidatedFrom) || consolidatedFrom.length === 0) {
|
|
1328
|
+
error(`idea ${idOrFilename} is not a consolidated idea (no consolidated_from field)`);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const planRoot = getPlanningRoot(cwd);
|
|
1332
|
+
const planRootRel = path.relative(cwd, planRoot) || '.';
|
|
1333
|
+
const restoredIds = [];
|
|
1334
|
+
|
|
1335
|
+
// Restore each source idea from consolidated/ to pending/
|
|
1336
|
+
for (const sourceId of consolidatedFrom) {
|
|
1337
|
+
const sourceIdea = findIdeaFile(cwd, sourceId);
|
|
1338
|
+
if (!sourceIdea) {
|
|
1339
|
+
error(`source idea #${sourceId} not found during undo`);
|
|
1340
|
+
}
|
|
1341
|
+
if (sourceIdea.state !== 'consolidated') {
|
|
1342
|
+
error(`source idea #${sourceId} is in ${sourceIdea.state} state, expected consolidated`);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// Remove consolidated_into from frontmatter
|
|
1346
|
+
const srcContent = safeReadFile(sourceIdea.path) || '';
|
|
1347
|
+
const parsed = parseIdeaFrontmatter(srcContent);
|
|
1348
|
+
delete parsed.frontmatter.consolidated_into;
|
|
1349
|
+
parsed.frontmatter.updated = new Date().toISOString();
|
|
1350
|
+
if (author) parsed.frontmatter.updated_by = author;
|
|
1351
|
+
fs.writeFileSync(sourceIdea.path, buildIdeaContent(parsed.frontmatter, parsed.body, parsed.notes, parsed.discussionLog, parsed.researchLog), 'utf-8');
|
|
1352
|
+
|
|
1353
|
+
// Git mv from consolidated/ to pending/
|
|
1354
|
+
const fromRel = path.join(planRootRel, 'ideas', 'consolidated', sourceIdea.filename);
|
|
1355
|
+
const toRel = path.join(planRootRel, 'ideas', 'pending', sourceIdea.filename);
|
|
1356
|
+
execGit(cwd, ['mv', fromRel, toRel]);
|
|
1357
|
+
|
|
1358
|
+
restoredIds.push(parseInt(sourceId, 10));
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Delete the consolidated result idea
|
|
1362
|
+
const ideaRel = path.join(planRootRel, 'ideas', idea.state, idea.filename);
|
|
1363
|
+
execGit(cwd, ['rm', ideaRel]);
|
|
1364
|
+
|
|
1365
|
+
// Atomic git commit
|
|
1366
|
+
execGit(cwd, ['add', '-A']);
|
|
1367
|
+
const title = idea.frontmatter.title || idea.filename;
|
|
1368
|
+
const idNum = idea.frontmatter.id || idOrFilename;
|
|
1369
|
+
execGit(cwd, ['commit', '-m', `ideas: undo consolidation #${idNum} - ${title}`]);
|
|
1370
|
+
|
|
1371
|
+
const result = {
|
|
1372
|
+
undone_idea_id: idea.frontmatter.id,
|
|
1373
|
+
title,
|
|
1374
|
+
restored_ids: restoredIds,
|
|
1375
|
+
deleted_file: idea.filename,
|
|
1376
|
+
};
|
|
1377
|
+
output(result, raw);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// ─── Exports ──────────────────────────────────────────────────────────────────
|
|
1381
|
+
|
|
1382
|
+
module.exports = {
|
|
1383
|
+
IDEA_STATES,
|
|
1384
|
+
loadManifest,
|
|
1385
|
+
saveManifest,
|
|
1386
|
+
allocateId,
|
|
1387
|
+
ensureIdeasDirs,
|
|
1388
|
+
parseIdeaFrontmatter,
|
|
1389
|
+
buildIdeaContent,
|
|
1390
|
+
appendDiscussionEntry,
|
|
1391
|
+
appendResearchEntry,
|
|
1392
|
+
appendConsolidationNote,
|
|
1393
|
+
findIdeaFile,
|
|
1394
|
+
cmdIdeasCreate,
|
|
1395
|
+
cmdIdeasUpdate,
|
|
1396
|
+
cmdIdeasAppendNote,
|
|
1397
|
+
cmdIdeasList,
|
|
1398
|
+
cmdIdeasReject,
|
|
1399
|
+
cmdIdeasRestore,
|
|
1400
|
+
cmdIdeasMoveState,
|
|
1401
|
+
cmdIdeasDiscussSave,
|
|
1402
|
+
cmdIdeasResearchSave,
|
|
1403
|
+
cmdIdeasConsolidate,
|
|
1404
|
+
cmdIdeasFindRelated,
|
|
1405
|
+
cmdIdeasUndoConsolidation,
|
|
1406
|
+
};
|