@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,1417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ideas.cjs — findIdeaFile state detection, parseIdeaFrontmatter,
|
|
3
|
+
* and cmdIdeasReject guard behavior.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { describe, it, beforeEach, afterEach } = require('node:test');
|
|
7
|
+
const assert = require('node:assert');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { execSync } = require('child_process');
|
|
11
|
+
const { createTempDir, cleanupDir, createTempProject } = require('./test-helpers.cjs');
|
|
12
|
+
const { findIdeaFile, parseIdeaFrontmatter, ensureIdeasDirs, cmdIdeasCreate, loadManifest, IDEA_STATES, buildIdeaContent, cmdIdeasConsolidate, cmdIdeasFindRelated, cmdIdeasUndoConsolidation } = require('./ideas.cjs');
|
|
13
|
+
|
|
14
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates an idea file in a specific state directory.
|
|
18
|
+
* @param {string} cwd - Temp directory root
|
|
19
|
+
* @param {string} state - One of: pending, rejected, done
|
|
20
|
+
* @param {number} id - Idea numeric ID
|
|
21
|
+
* @param {string} title - Idea title
|
|
22
|
+
* @returns {string} The filename created
|
|
23
|
+
*/
|
|
24
|
+
function createIdeaInState(cwd, state, id, title) {
|
|
25
|
+
const slug = title.toLowerCase().replace(/\s+/g, '-');
|
|
26
|
+
const paddedId = String(id).padStart(3, '0');
|
|
27
|
+
const filename = `${paddedId}-${slug}.md`;
|
|
28
|
+
const content = `---\nid: ${id}\ntitle: "${title}"\n---\n\nIdea body.\n`;
|
|
29
|
+
const dir = path.join(cwd, '.planning', 'ideas', state);
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
+
fs.writeFileSync(path.join(dir, filename), content, 'utf-8');
|
|
32
|
+
return filename;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── findIdeaFile state detection ─────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe('findIdeaFile state detection', () => {
|
|
38
|
+
let tmpDir;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
tmpDir = createTempDir('ideas-test-');
|
|
42
|
+
ensureIdeasDirs(tmpDir);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => cleanupDir(tmpDir));
|
|
46
|
+
|
|
47
|
+
it('returns state "pending" for idea in pending directory', () => {
|
|
48
|
+
createIdeaInState(tmpDir, 'pending', 1, 'Test Pending');
|
|
49
|
+
const idea = findIdeaFile(tmpDir, '1');
|
|
50
|
+
assert.ok(idea, 'findIdeaFile should return a result');
|
|
51
|
+
assert.strictEqual(idea.state, 'pending');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns state "rejected" for idea in rejected directory', () => {
|
|
55
|
+
createIdeaInState(tmpDir, 'rejected', 2, 'Test Rejected');
|
|
56
|
+
const idea = findIdeaFile(tmpDir, '2');
|
|
57
|
+
assert.ok(idea, 'findIdeaFile should return a result');
|
|
58
|
+
assert.strictEqual(idea.state, 'rejected');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns state "done" for idea in done directory', () => {
|
|
62
|
+
createIdeaInState(tmpDir, 'done', 3, 'Test Done');
|
|
63
|
+
const idea = findIdeaFile(tmpDir, '3');
|
|
64
|
+
assert.ok(idea, 'findIdeaFile should return a result');
|
|
65
|
+
assert.strictEqual(idea.state, 'done');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns null for nonexistent idea', () => {
|
|
69
|
+
const idea = findIdeaFile(tmpDir, '999');
|
|
70
|
+
assert.strictEqual(idea, null);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ─── parseIdeaFrontmatter ────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe('parseIdeaFrontmatter', () => {
|
|
77
|
+
it('parses frontmatter with id and title', () => {
|
|
78
|
+
const content = '---\nid: 5\ntitle: "My Great Idea"\n---\n\nSome body text.\n';
|
|
79
|
+
const result = parseIdeaFrontmatter(content);
|
|
80
|
+
assert.strictEqual(result.frontmatter.id, 5);
|
|
81
|
+
assert.strictEqual(result.frontmatter.title, 'My Great Idea');
|
|
82
|
+
assert.ok(result.body.includes('Some body text'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns empty frontmatter for content without YAML', () => {
|
|
86
|
+
const content = 'Just plain text with no frontmatter at all.';
|
|
87
|
+
const result = parseIdeaFrontmatter(content);
|
|
88
|
+
assert.deepStrictEqual(result.frontmatter, {});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ─── consolidated state ──────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
describe('consolidated state', () => {
|
|
95
|
+
let tmpDir;
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
tmpDir = createTempDir('ideas-consolidated-test-');
|
|
99
|
+
ensureIdeasDirs(tmpDir);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => cleanupDir(tmpDir));
|
|
103
|
+
|
|
104
|
+
it('IDEA_STATES includes consolidated', () => {
|
|
105
|
+
assert.ok(IDEA_STATES.includes('consolidated'), 'IDEA_STATES should include "consolidated"');
|
|
106
|
+
assert.strictEqual(IDEA_STATES.length, 4, 'IDEA_STATES should have 4 entries');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('ensureIdeasDirs creates consolidated/ directory', () => {
|
|
110
|
+
const consolidatedDir = path.join(tmpDir, '.planning', 'ideas', 'consolidated');
|
|
111
|
+
assert.ok(fs.existsSync(consolidatedDir), 'consolidated/ directory should exist');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('findIdeaFile returns state "consolidated" for idea in consolidated/', () => {
|
|
115
|
+
createIdeaInState(tmpDir, 'consolidated', 10, 'Test Consolidated');
|
|
116
|
+
const idea = findIdeaFile(tmpDir, '10');
|
|
117
|
+
assert.ok(idea, 'findIdeaFile should return a result');
|
|
118
|
+
assert.strictEqual(idea.state, 'consolidated');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ─── buildIdeaContent consolidation fields ──────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe('buildIdeaContent consolidation fields', () => {
|
|
125
|
+
it('emits consolidated_from array in frontmatter', () => {
|
|
126
|
+
const fm = {
|
|
127
|
+
id: 5,
|
|
128
|
+
title: 'Consolidated Idea',
|
|
129
|
+
tags: ['infra'],
|
|
130
|
+
created: '2026-03-01T00:00:00Z',
|
|
131
|
+
updated: '2026-03-01T00:00:00Z',
|
|
132
|
+
consolidated_from: ['001', '042', '003'],
|
|
133
|
+
};
|
|
134
|
+
const content = buildIdeaContent(fm, 'Body text.\n');
|
|
135
|
+
assert.ok(content.includes('consolidated_from: ["001", "042", "003"]'),
|
|
136
|
+
'Should emit consolidated_from array');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('emits consolidated_into string in frontmatter', () => {
|
|
140
|
+
const fm = {
|
|
141
|
+
id: 1,
|
|
142
|
+
title: 'Source Idea',
|
|
143
|
+
tags: [],
|
|
144
|
+
created: '2026-03-01T00:00:00Z',
|
|
145
|
+
updated: '2026-03-01T00:00:00Z',
|
|
146
|
+
consolidated_into: '005',
|
|
147
|
+
};
|
|
148
|
+
const content = buildIdeaContent(fm, 'Body text.\n');
|
|
149
|
+
assert.ok(content.includes('consolidated_into: "005"'),
|
|
150
|
+
'Should emit consolidated_into string');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('omits consolidation fields when not present', () => {
|
|
154
|
+
const fm = {
|
|
155
|
+
id: 1,
|
|
156
|
+
title: 'Normal Idea',
|
|
157
|
+
tags: ['feature'],
|
|
158
|
+
created: '2026-03-01T00:00:00Z',
|
|
159
|
+
updated: '2026-03-01T00:00:00Z',
|
|
160
|
+
};
|
|
161
|
+
const content = buildIdeaContent(fm, 'Body text.\n');
|
|
162
|
+
assert.ok(!content.includes('consolidated_from'),
|
|
163
|
+
'Should not include consolidated_from');
|
|
164
|
+
assert.ok(!content.includes('consolidated_into'),
|
|
165
|
+
'Should not include consolidated_into');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('emits both consolidation fields when both present', () => {
|
|
169
|
+
const fm = {
|
|
170
|
+
id: 5,
|
|
171
|
+
title: 'Both Fields Idea',
|
|
172
|
+
tags: [],
|
|
173
|
+
created: '2026-03-01T00:00:00Z',
|
|
174
|
+
updated: '2026-03-01T00:00:00Z',
|
|
175
|
+
consolidated_from: ['001', '002'],
|
|
176
|
+
consolidated_into: '010',
|
|
177
|
+
};
|
|
178
|
+
const content = buildIdeaContent(fm, 'Body.\n');
|
|
179
|
+
assert.ok(content.includes('consolidated_from: ["001", "002"]'),
|
|
180
|
+
'Should emit consolidated_from');
|
|
181
|
+
assert.ok(content.includes('consolidated_into: "010"'),
|
|
182
|
+
'Should emit consolidated_into');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('omits consolidated_from when array is empty', () => {
|
|
186
|
+
const fm = {
|
|
187
|
+
id: 1,
|
|
188
|
+
title: 'Empty Array',
|
|
189
|
+
tags: [],
|
|
190
|
+
created: '2026-03-01T00:00:00Z',
|
|
191
|
+
updated: '2026-03-01T00:00:00Z',
|
|
192
|
+
consolidated_from: [],
|
|
193
|
+
};
|
|
194
|
+
const content = buildIdeaContent(fm, 'Body.\n');
|
|
195
|
+
assert.ok(!content.includes('consolidated_from'),
|
|
196
|
+
'Should not emit consolidated_from for empty array');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ─── parseIdeaFrontmatter consolidation fields ──────────────────────────────
|
|
201
|
+
|
|
202
|
+
describe('parseIdeaFrontmatter consolidation fields', () => {
|
|
203
|
+
it('parses consolidated_from array', () => {
|
|
204
|
+
const content = [
|
|
205
|
+
'---',
|
|
206
|
+
'id: 5',
|
|
207
|
+
'title: "Consolidated Idea"',
|
|
208
|
+
'tags: ["infra"]',
|
|
209
|
+
'created: 2026-03-01T00:00:00Z',
|
|
210
|
+
'updated: 2026-03-01T00:00:00Z',
|
|
211
|
+
'consolidated_from: ["001", "042", "003"]',
|
|
212
|
+
'---',
|
|
213
|
+
'',
|
|
214
|
+
'Body.',
|
|
215
|
+
''
|
|
216
|
+
].join('\n');
|
|
217
|
+
const result = parseIdeaFrontmatter(content);
|
|
218
|
+
assert.deepStrictEqual(result.frontmatter.consolidated_from, ['001', '042', '003']);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('parses consolidated_into string', () => {
|
|
222
|
+
const content = [
|
|
223
|
+
'---',
|
|
224
|
+
'id: 1',
|
|
225
|
+
'title: "Source Idea"',
|
|
226
|
+
'tags: []',
|
|
227
|
+
'created: 2026-03-01T00:00:00Z',
|
|
228
|
+
'updated: 2026-03-01T00:00:00Z',
|
|
229
|
+
'consolidated_into: "005"',
|
|
230
|
+
'---',
|
|
231
|
+
'',
|
|
232
|
+
'Body.',
|
|
233
|
+
''
|
|
234
|
+
].join('\n');
|
|
235
|
+
const result = parseIdeaFrontmatter(content);
|
|
236
|
+
assert.strictEqual(result.frontmatter.consolidated_into, '005');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('parses idea with both consolidation fields', () => {
|
|
240
|
+
const content = [
|
|
241
|
+
'---',
|
|
242
|
+
'id: 5',
|
|
243
|
+
'title: "Both Fields"',
|
|
244
|
+
'tags: []',
|
|
245
|
+
'created: 2026-03-01T00:00:00Z',
|
|
246
|
+
'updated: 2026-03-01T00:00:00Z',
|
|
247
|
+
'consolidated_from: ["001", "002"]',
|
|
248
|
+
'consolidated_into: "010"',
|
|
249
|
+
'---',
|
|
250
|
+
'',
|
|
251
|
+
'Body.',
|
|
252
|
+
''
|
|
253
|
+
].join('\n');
|
|
254
|
+
const result = parseIdeaFrontmatter(content);
|
|
255
|
+
assert.deepStrictEqual(result.frontmatter.consolidated_from, ['001', '002']);
|
|
256
|
+
assert.strictEqual(result.frontmatter.consolidated_into, '010');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('handles idea with neither consolidation field (backward compat)', () => {
|
|
260
|
+
const content = [
|
|
261
|
+
'---',
|
|
262
|
+
'id: 1',
|
|
263
|
+
'title: "Normal Idea"',
|
|
264
|
+
'tags: ["feature"]',
|
|
265
|
+
'created: 2026-03-01T00:00:00Z',
|
|
266
|
+
'updated: 2026-03-01T00:00:00Z',
|
|
267
|
+
'---',
|
|
268
|
+
'',
|
|
269
|
+
'Body.',
|
|
270
|
+
''
|
|
271
|
+
].join('\n');
|
|
272
|
+
const result = parseIdeaFrontmatter(content);
|
|
273
|
+
assert.strictEqual(result.frontmatter.consolidated_from, undefined);
|
|
274
|
+
assert.strictEqual(result.frontmatter.consolidated_into, undefined);
|
|
275
|
+
assert.strictEqual(result.frontmatter.title, 'Normal Idea');
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ─── Round-trip tests ────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
describe('consolidation round-trip', () => {
|
|
282
|
+
it('consolidated idea round-trips through parse+build without data loss', () => {
|
|
283
|
+
const original = [
|
|
284
|
+
'---',
|
|
285
|
+
'id: 5',
|
|
286
|
+
'title: "Unified Monitoring"',
|
|
287
|
+
'tags: ["infra", "monitoring"]',
|
|
288
|
+
'created: 2026-02-15T00:00:00Z',
|
|
289
|
+
'updated: 2026-03-01T00:00:00Z',
|
|
290
|
+
'author: "Alice <alice@example.com>"',
|
|
291
|
+
'consolidated_from: ["001", "042", "003"]',
|
|
292
|
+
'---',
|
|
293
|
+
'',
|
|
294
|
+
'*Consolidated from ideas: #001 (Logging Pipeline), #042 (Alert System), #003 (Metrics Dashboard)*',
|
|
295
|
+
'',
|
|
296
|
+
'A unified monitoring approach combining logging, alerting, and dashboards.',
|
|
297
|
+
'',
|
|
298
|
+
'## Discussion Log',
|
|
299
|
+
'',
|
|
300
|
+
'### [from #042] Discussion — 2026-02-15',
|
|
301
|
+
'',
|
|
302
|
+
'**Key Insights:** Alerts need correlation',
|
|
303
|
+
'**Refined Problem:** Alert fatigue from uncorrelated events',
|
|
304
|
+
'**Refined Approach:** Correlation engine with ML',
|
|
305
|
+
'**Open Questions:** Budget for ML infrastructure?',
|
|
306
|
+
'**Decision:** Proceed with rule-based first',
|
|
307
|
+
'',
|
|
308
|
+
'## Research Log',
|
|
309
|
+
'',
|
|
310
|
+
'### [from #001] Research — 2026-02-10',
|
|
311
|
+
'',
|
|
312
|
+
'**Summary:** Evaluated ELK stack vs Loki',
|
|
313
|
+
'**Key Findings:** Loki is cheaper at scale',
|
|
314
|
+
'**Recommendation:** Start with Loki',
|
|
315
|
+
'**Document:** research-logging-2026.md',
|
|
316
|
+
'**Outcome:** Adopted Loki for logging',
|
|
317
|
+
''
|
|
318
|
+
].join('\n');
|
|
319
|
+
|
|
320
|
+
const parsed = parseIdeaFrontmatter(original);
|
|
321
|
+
|
|
322
|
+
// Verify key fields parsed correctly
|
|
323
|
+
assert.strictEqual(parsed.frontmatter.id, 5);
|
|
324
|
+
assert.strictEqual(parsed.frontmatter.title, 'Unified Monitoring');
|
|
325
|
+
assert.deepStrictEqual(parsed.frontmatter.consolidated_from, ['001', '042', '003']);
|
|
326
|
+
assert.ok(parsed.body.includes('Consolidated from ideas'));
|
|
327
|
+
assert.ok(parsed.discussionLog.includes('[from #042]'));
|
|
328
|
+
assert.ok(parsed.researchLog.includes('[from #001]'));
|
|
329
|
+
|
|
330
|
+
// Round-trip: build from parsed parts
|
|
331
|
+
const rebuilt = buildIdeaContent(
|
|
332
|
+
parsed.frontmatter, parsed.body, parsed.notes, parsed.discussionLog, parsed.researchLog
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// Parse again from rebuilt content
|
|
336
|
+
const reparsed = parseIdeaFrontmatter(rebuilt);
|
|
337
|
+
|
|
338
|
+
// Verify all fields survive the round-trip
|
|
339
|
+
assert.strictEqual(reparsed.frontmatter.id, 5);
|
|
340
|
+
assert.strictEqual(reparsed.frontmatter.title, 'Unified Monitoring');
|
|
341
|
+
assert.deepStrictEqual(reparsed.frontmatter.tags, ['infra', 'monitoring']);
|
|
342
|
+
assert.deepStrictEqual(reparsed.frontmatter.consolidated_from, ['001', '042', '003']);
|
|
343
|
+
assert.strictEqual(reparsed.frontmatter.author, 'Alice <alice@example.com>');
|
|
344
|
+
assert.ok(reparsed.body.includes('Consolidated from ideas'));
|
|
345
|
+
assert.ok(reparsed.discussionLog.includes('[from #042]'));
|
|
346
|
+
assert.ok(reparsed.researchLog.includes('[from #001]'));
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('source idea round-trips with consolidated_into field', () => {
|
|
350
|
+
const original = [
|
|
351
|
+
'---',
|
|
352
|
+
'id: 1',
|
|
353
|
+
'title: "Logging Pipeline"',
|
|
354
|
+
'tags: ["infra"]',
|
|
355
|
+
'created: 2026-01-01T00:00:00Z',
|
|
356
|
+
'updated: 2026-03-01T00:00:00Z',
|
|
357
|
+
'consolidated_into: "005"',
|
|
358
|
+
'---',
|
|
359
|
+
'',
|
|
360
|
+
'Original logging idea, now consolidated into #005.',
|
|
361
|
+
''
|
|
362
|
+
].join('\n');
|
|
363
|
+
|
|
364
|
+
const parsed = parseIdeaFrontmatter(original);
|
|
365
|
+
assert.strictEqual(parsed.frontmatter.consolidated_into, '005');
|
|
366
|
+
|
|
367
|
+
const rebuilt = buildIdeaContent(
|
|
368
|
+
parsed.frontmatter, parsed.body, parsed.notes, parsed.discussionLog, parsed.researchLog
|
|
369
|
+
);
|
|
370
|
+
const reparsed = parseIdeaFrontmatter(rebuilt);
|
|
371
|
+
|
|
372
|
+
assert.strictEqual(reparsed.frontmatter.consolidated_into, '005');
|
|
373
|
+
assert.strictEqual(reparsed.frontmatter.title, 'Logging Pipeline');
|
|
374
|
+
assert.ok(reparsed.body.includes('consolidated into #005'));
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('existing idea without consolidation fields round-trips unchanged', () => {
|
|
378
|
+
const original = [
|
|
379
|
+
'---',
|
|
380
|
+
'id: 10',
|
|
381
|
+
'title: "Regular Feature"',
|
|
382
|
+
'tags: ["feature", "ui"]',
|
|
383
|
+
'created: 2026-01-15T00:00:00Z',
|
|
384
|
+
'updated: 2026-02-20T00:00:00Z',
|
|
385
|
+
'author: "Bob <bob@example.com>"',
|
|
386
|
+
'updated_by: "Carol <carol@example.com>"',
|
|
387
|
+
'---',
|
|
388
|
+
'',
|
|
389
|
+
'A regular feature idea with no consolidation.',
|
|
390
|
+
'',
|
|
391
|
+
'## Notes',
|
|
392
|
+
'',
|
|
393
|
+
'---',
|
|
394
|
+
'**2026-02-20:** Added implementation details',
|
|
395
|
+
''
|
|
396
|
+
].join('\n');
|
|
397
|
+
|
|
398
|
+
const parsed = parseIdeaFrontmatter(original);
|
|
399
|
+
|
|
400
|
+
// Verify no consolidation fields
|
|
401
|
+
assert.strictEqual(parsed.frontmatter.consolidated_from, undefined);
|
|
402
|
+
assert.strictEqual(parsed.frontmatter.consolidated_into, undefined);
|
|
403
|
+
|
|
404
|
+
const rebuilt = buildIdeaContent(
|
|
405
|
+
parsed.frontmatter, parsed.body, parsed.notes, parsed.discussionLog, parsed.researchLog
|
|
406
|
+
);
|
|
407
|
+
const reparsed = parseIdeaFrontmatter(rebuilt);
|
|
408
|
+
|
|
409
|
+
// All original fields preserved
|
|
410
|
+
assert.strictEqual(reparsed.frontmatter.id, 10);
|
|
411
|
+
assert.strictEqual(reparsed.frontmatter.title, 'Regular Feature');
|
|
412
|
+
assert.deepStrictEqual(reparsed.frontmatter.tags, ['feature', 'ui']);
|
|
413
|
+
assert.strictEqual(reparsed.frontmatter.author, 'Bob <bob@example.com>');
|
|
414
|
+
assert.strictEqual(reparsed.frontmatter.updated_by, 'Carol <carol@example.com>');
|
|
415
|
+
assert.ok(reparsed.body.includes('regular feature idea'));
|
|
416
|
+
assert.ok(reparsed.notes.includes('Added implementation details'));
|
|
417
|
+
|
|
418
|
+
// No consolidation fields appeared
|
|
419
|
+
assert.strictEqual(reparsed.frontmatter.consolidated_from, undefined);
|
|
420
|
+
assert.strictEqual(reparsed.frontmatter.consolidated_into, undefined);
|
|
421
|
+
assert.ok(!rebuilt.includes('consolidated_from'));
|
|
422
|
+
assert.ok(!rebuilt.includes('consolidated_into'));
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('body with provenance line survives round-trip', () => {
|
|
426
|
+
const fm = {
|
|
427
|
+
id: 5,
|
|
428
|
+
title: 'Consolidated Idea',
|
|
429
|
+
tags: [],
|
|
430
|
+
created: '2026-03-01T00:00:00Z',
|
|
431
|
+
updated: '2026-03-01T00:00:00Z',
|
|
432
|
+
consolidated_from: ['001', '042'],
|
|
433
|
+
};
|
|
434
|
+
const body = '*Consolidated from ideas: #001 (Logging Pipeline), #042 (Alert System)*\n\nMerged approach.\n';
|
|
435
|
+
const content = buildIdeaContent(fm, body);
|
|
436
|
+
const parsed = parseIdeaFrontmatter(content);
|
|
437
|
+
assert.ok(parsed.body.includes('Consolidated from ideas: #001'));
|
|
438
|
+
assert.ok(parsed.body.includes('#042 (Alert System)'));
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('discussion log with [from #ID] headings survives round-trip', () => {
|
|
442
|
+
const fm = {
|
|
443
|
+
id: 5,
|
|
444
|
+
title: 'Consolidated Idea',
|
|
445
|
+
tags: [],
|
|
446
|
+
created: '2026-03-01T00:00:00Z',
|
|
447
|
+
updated: '2026-03-01T00:00:00Z',
|
|
448
|
+
consolidated_from: ['042'],
|
|
449
|
+
};
|
|
450
|
+
const discussionLog = '### [from #042] Discussion — 2026-02-15\n\n**Key Insights:** Important finding\n**Refined Problem:** Updated problem\n**Refined Approach:** New approach\n**Open Questions:** None\n**Decision:** Go ahead\n\n';
|
|
451
|
+
const content = buildIdeaContent(fm, 'Body.\n', '', discussionLog, '');
|
|
452
|
+
const parsed = parseIdeaFrontmatter(content);
|
|
453
|
+
assert.ok(parsed.discussionLog.includes('[from #042] Discussion'));
|
|
454
|
+
assert.ok(parsed.discussionLog.includes('Important finding'));
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('research log with [from #ID] headings survives round-trip', () => {
|
|
458
|
+
const fm = {
|
|
459
|
+
id: 5,
|
|
460
|
+
title: 'Consolidated Idea',
|
|
461
|
+
tags: [],
|
|
462
|
+
created: '2026-03-01T00:00:00Z',
|
|
463
|
+
updated: '2026-03-01T00:00:00Z',
|
|
464
|
+
consolidated_from: ['001'],
|
|
465
|
+
};
|
|
466
|
+
const researchLog = '### [from #001] Research — 2026-02-10\n\n**Summary:** Research summary\n**Key Findings:** Key finding\n**Recommendation:** Do this\n**Document:** doc.md\n**Outcome:** Done\n\n';
|
|
467
|
+
const content = buildIdeaContent(fm, 'Body.\n', '', '', researchLog);
|
|
468
|
+
const parsed = parseIdeaFrontmatter(content);
|
|
469
|
+
assert.ok(parsed.researchLog.includes('[from #001] Research'));
|
|
470
|
+
assert.ok(parsed.researchLog.includes('Research summary'));
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// ─── cmdIdeasReject guard messages ───────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
describe('cmdIdeasReject guard messages', () => {
|
|
477
|
+
let tmpDir;
|
|
478
|
+
|
|
479
|
+
beforeEach(() => {
|
|
480
|
+
tmpDir = createTempDir('ideas-reject-test-');
|
|
481
|
+
ensureIdeasDirs(tmpDir);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
afterEach(() => cleanupDir(tmpDir));
|
|
485
|
+
|
|
486
|
+
it('errors with "already rejected" when rejecting a rejected idea', () => {
|
|
487
|
+
createIdeaInState(tmpDir, 'rejected', 1, 'Already Rejected');
|
|
488
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
489
|
+
try {
|
|
490
|
+
execSync(
|
|
491
|
+
`node -e "require('./ideas.cjs').cmdIdeasReject('${escapedCwd}', '1', null, false)"`,
|
|
492
|
+
{ cwd: path.join(__dirname), stderr: 'pipe', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
493
|
+
);
|
|
494
|
+
assert.fail('should have exited with error');
|
|
495
|
+
} catch (err) {
|
|
496
|
+
const stderr = err.stderr.toString();
|
|
497
|
+
assert.ok(
|
|
498
|
+
stderr.includes('already rejected'),
|
|
499
|
+
`Expected "already rejected" in stderr, got: ${stderr}`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('errors with "only pending ideas can be rejected" when rejecting a done idea', () => {
|
|
505
|
+
createIdeaInState(tmpDir, 'done', 2, 'Done Idea');
|
|
506
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
507
|
+
try {
|
|
508
|
+
execSync(
|
|
509
|
+
`node -e "require('./ideas.cjs').cmdIdeasReject('${escapedCwd}', '2', null, false)"`,
|
|
510
|
+
{ cwd: path.join(__dirname), stderr: 'pipe', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
511
|
+
);
|
|
512
|
+
assert.fail('should have exited with error');
|
|
513
|
+
} catch (err) {
|
|
514
|
+
const stderr = err.stderr.toString();
|
|
515
|
+
assert.ok(
|
|
516
|
+
stderr.includes('only pending ideas can be rejected'),
|
|
517
|
+
`Expected "only pending ideas can be rejected" in stderr, got: ${stderr}`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('errors with "idea not found" for nonexistent idea', () => {
|
|
523
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
524
|
+
try {
|
|
525
|
+
execSync(
|
|
526
|
+
`node -e "require('./ideas.cjs').cmdIdeasReject('${escapedCwd}', '999', null, false)"`,
|
|
527
|
+
{ cwd: path.join(__dirname), stderr: 'pipe', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
528
|
+
);
|
|
529
|
+
assert.fail('should have exited with error');
|
|
530
|
+
} catch (err) {
|
|
531
|
+
const stderr = err.stderr.toString();
|
|
532
|
+
assert.ok(
|
|
533
|
+
stderr.includes('idea not found'),
|
|
534
|
+
`Expected "idea not found" in stderr, got: ${stderr}`
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// ─── Root Layout ───────────────────────────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
describe('ideas root layout', () => {
|
|
543
|
+
let fixture;
|
|
544
|
+
afterEach(() => { if (fixture) fixture.cleanup(); });
|
|
545
|
+
|
|
546
|
+
it('ensureIdeasDirs creates directories at root (not under .planning/)', () => {
|
|
547
|
+
fixture = createTempProject({ layout: 'root' });
|
|
548
|
+
ensureIdeasDirs(fixture.cwd);
|
|
549
|
+
assert.ok(fs.existsSync(path.join(fixture.cwd, 'ideas', 'pending')),
|
|
550
|
+
'ideas/pending should exist at root');
|
|
551
|
+
assert.ok(fs.existsSync(path.join(fixture.cwd, 'ideas', 'done')),
|
|
552
|
+
'ideas/done should exist at root');
|
|
553
|
+
assert.ok(fs.existsSync(path.join(fixture.cwd, 'ideas', 'rejected')),
|
|
554
|
+
'ideas/rejected should exist at root');
|
|
555
|
+
assert.ok(fs.existsSync(path.join(fixture.cwd, 'ideas', 'consolidated')),
|
|
556
|
+
'ideas/consolidated should exist at root');
|
|
557
|
+
assert.ok(!fs.existsSync(path.join(fixture.cwd, '.planning', 'ideas')),
|
|
558
|
+
'.planning/ideas should NOT exist in root layout');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('findIdeaFile resolves ideas at root in root layout', () => {
|
|
562
|
+
fixture = createTempProject({ layout: 'root' });
|
|
563
|
+
// Create an idea directly at root/ideas/pending/
|
|
564
|
+
const ideasDir = path.join(fixture.cwd, 'ideas', 'pending');
|
|
565
|
+
fs.mkdirSync(ideasDir, { recursive: true });
|
|
566
|
+
const content = '---\nid: 1\ntitle: "Root Idea"\n---\n\nBody.\n';
|
|
567
|
+
fs.writeFileSync(path.join(ideasDir, '001-root-idea.md'), content, 'utf-8');
|
|
568
|
+
|
|
569
|
+
const idea = findIdeaFile(fixture.cwd, '1');
|
|
570
|
+
assert.ok(idea, 'findIdeaFile should find the idea in root layout');
|
|
571
|
+
assert.strictEqual(idea.state, 'pending');
|
|
572
|
+
assert.strictEqual(idea.filename, '001-root-idea.md');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('loadManifest reads from root/ideas/manifest.json in root layout', () => {
|
|
576
|
+
fixture = createTempProject({ layout: 'root' });
|
|
577
|
+
const ideasDir = path.join(fixture.cwd, 'ideas');
|
|
578
|
+
fs.mkdirSync(ideasDir, { recursive: true });
|
|
579
|
+
fs.writeFileSync(path.join(ideasDir, 'manifest.json'), '{"next_id": 5}\n', 'utf-8');
|
|
580
|
+
|
|
581
|
+
const manifest = loadManifest(fixture.cwd);
|
|
582
|
+
assert.strictEqual(manifest.next_id, 5);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// ─── cmdIdeasConsolidate ──────────────────────────────────────────────────────
|
|
587
|
+
|
|
588
|
+
describe('cmdIdeasConsolidate', () => {
|
|
589
|
+
let tmpDir;
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Creates a pending idea file with proper frontmatter and updates the manifest.
|
|
593
|
+
*/
|
|
594
|
+
function createPendingIdea(dir, id, title, tags = []) {
|
|
595
|
+
const paddedId = String(id).padStart(3, '0');
|
|
596
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
597
|
+
const filename = `${paddedId}-${slug}.md`;
|
|
598
|
+
const now = new Date().toISOString();
|
|
599
|
+
const tagsStr = tags.length > 0 ? `[${tags.map(t => `"${t}"`).join(', ')}]` : '[]';
|
|
600
|
+
const content = `---\nid: ${id}\ntitle: "${title}"\ntags: ${tagsStr}\ncreated: ${now}\nupdated: ${now}\n---\n\n${title} body.\n`;
|
|
601
|
+
const dir2 = path.join(dir, '.planning', 'ideas', 'pending');
|
|
602
|
+
fs.mkdirSync(dir2, { recursive: true });
|
|
603
|
+
fs.writeFileSync(path.join(dir2, filename), content, 'utf-8');
|
|
604
|
+
// Update manifest so allocateId returns next ID correctly
|
|
605
|
+
const manifestDir = path.join(dir, '.planning', 'ideas');
|
|
606
|
+
const manifestPath = path.join(manifestDir, 'manifest.json');
|
|
607
|
+
let manifest;
|
|
608
|
+
try {
|
|
609
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
610
|
+
} catch {
|
|
611
|
+
manifest = { next_id: 1 };
|
|
612
|
+
}
|
|
613
|
+
if (id >= manifest.next_id) {
|
|
614
|
+
manifest.next_id = id + 1;
|
|
615
|
+
}
|
|
616
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest), 'utf-8');
|
|
617
|
+
return filename;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Creates an idea in a specified state directory with manifest update.
|
|
622
|
+
*/
|
|
623
|
+
function createIdeaInStateCons(dir, id, title, state) {
|
|
624
|
+
const paddedId = String(id).padStart(3, '0');
|
|
625
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
626
|
+
const filename = `${paddedId}-${slug}.md`;
|
|
627
|
+
const now = new Date().toISOString();
|
|
628
|
+
const content = `---\nid: ${id}\ntitle: "${title}"\ntags: []\ncreated: ${now}\nupdated: ${now}\n---\n\n${title} body.\n`;
|
|
629
|
+
const stateDir = path.join(dir, '.planning', 'ideas', state);
|
|
630
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
631
|
+
fs.writeFileSync(path.join(stateDir, filename), content, 'utf-8');
|
|
632
|
+
const manifestDir = path.join(dir, '.planning', 'ideas');
|
|
633
|
+
const manifestPath = path.join(manifestDir, 'manifest.json');
|
|
634
|
+
let manifest;
|
|
635
|
+
try {
|
|
636
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
637
|
+
} catch {
|
|
638
|
+
manifest = { next_id: 1 };
|
|
639
|
+
}
|
|
640
|
+
if (id >= manifest.next_id) {
|
|
641
|
+
manifest.next_id = id + 1;
|
|
642
|
+
}
|
|
643
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest), 'utf-8');
|
|
644
|
+
return filename;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Initializes a git repo with all IDEA_STATES directories and initial commit.
|
|
649
|
+
*/
|
|
650
|
+
function initLocalGitRepo(dir) {
|
|
651
|
+
execSync('git init', { cwd: dir, stdio: 'pipe' });
|
|
652
|
+
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
|
|
653
|
+
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
|
|
654
|
+
for (const state of ['pending', 'done', 'rejected', 'consolidated']) {
|
|
655
|
+
const stateDir = path.join(dir, '.planning', 'ideas', state);
|
|
656
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
657
|
+
fs.writeFileSync(path.join(stateDir, '.gitkeep'), '', 'utf-8');
|
|
658
|
+
}
|
|
659
|
+
execSync('git add -A && git commit -m "init"', { cwd: dir, stdio: 'pipe' });
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
beforeEach(() => {
|
|
663
|
+
tmpDir = createTempDir('ideas-consolidate-test-');
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
afterEach(() => cleanupDir(tmpDir));
|
|
667
|
+
|
|
668
|
+
it('consolidates 2 pending ideas into new idea', () => {
|
|
669
|
+
initLocalGitRepo(tmpDir);
|
|
670
|
+
createPendingIdea(tmpDir, 1, 'Add retry logic', ['api']);
|
|
671
|
+
createPendingIdea(tmpDir, 2, 'Add timeout handling', ['api', 'reliability']);
|
|
672
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
673
|
+
|
|
674
|
+
// Call cmdIdeasConsolidate via subprocess to handle process.exit
|
|
675
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
676
|
+
const result = execSync(
|
|
677
|
+
`node -e "require('./ideas.cjs').cmdIdeasConsolidate('${escapedCwd}', { ids: '1,2', title: 'Resilient API calls' }, false, null)"`,
|
|
678
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
|
|
679
|
+
);
|
|
680
|
+
const json = JSON.parse(result);
|
|
681
|
+
|
|
682
|
+
// New idea created in pending/
|
|
683
|
+
assert.strictEqual(json.id, 3);
|
|
684
|
+
assert.ok(json.filename.includes('resilient-api-calls'));
|
|
685
|
+
assert.deepStrictEqual(json.consolidated_from, ['001', '002']);
|
|
686
|
+
assert.strictEqual(json.moved_files.length, 2);
|
|
687
|
+
|
|
688
|
+
// Source ideas moved to consolidated/
|
|
689
|
+
const consolidatedDir = path.join(tmpDir, '.planning', 'ideas', 'consolidated');
|
|
690
|
+
const pendingDir = path.join(tmpDir, '.planning', 'ideas', 'pending');
|
|
691
|
+
const consolidatedFiles = fs.readdirSync(consolidatedDir).filter(f => f.endsWith('.md'));
|
|
692
|
+
assert.strictEqual(consolidatedFiles.length, 2);
|
|
693
|
+
|
|
694
|
+
// New idea still in pending/
|
|
695
|
+
const pendingFiles = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
|
|
696
|
+
assert.strictEqual(pendingFiles.length, 1);
|
|
697
|
+
assert.ok(pendingFiles[0].includes('resilient-api-calls'));
|
|
698
|
+
|
|
699
|
+
// Source ideas have consolidated_into set
|
|
700
|
+
for (const f of consolidatedFiles) {
|
|
701
|
+
const content = fs.readFileSync(path.join(consolidatedDir, f), 'utf-8');
|
|
702
|
+
assert.ok(content.includes('consolidated_into'), `${f} should have consolidated_into`);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('rejects when source ID not found', () => {
|
|
707
|
+
initLocalGitRepo(tmpDir);
|
|
708
|
+
createPendingIdea(tmpDir, 1, 'Idea one');
|
|
709
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
710
|
+
|
|
711
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
712
|
+
try {
|
|
713
|
+
execSync(
|
|
714
|
+
`node -e "require('./ideas.cjs').cmdIdeasConsolidate('${escapedCwd}', { ids: '1,99', title: 'Combined' }, false, null)"`,
|
|
715
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'] }
|
|
716
|
+
);
|
|
717
|
+
assert.fail('should have exited with error');
|
|
718
|
+
} catch (err) {
|
|
719
|
+
const stderr = err.stderr.toString();
|
|
720
|
+
assert.ok(stderr.includes('idea not found: 99'), `Expected "idea not found: 99" in stderr, got: ${stderr}`);
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('rejects when source idea not in pending state', () => {
|
|
725
|
+
initLocalGitRepo(tmpDir);
|
|
726
|
+
createPendingIdea(tmpDir, 1, 'Idea one');
|
|
727
|
+
createIdeaInStateCons(tmpDir, 2, 'Idea two', 'done');
|
|
728
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
729
|
+
|
|
730
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
731
|
+
try {
|
|
732
|
+
execSync(
|
|
733
|
+
`node -e "require('./ideas.cjs').cmdIdeasConsolidate('${escapedCwd}', { ids: '1,2', title: 'Combined' }, false, null)"`,
|
|
734
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'] }
|
|
735
|
+
);
|
|
736
|
+
assert.fail('should have exited with error');
|
|
737
|
+
} catch (err) {
|
|
738
|
+
const stderr = err.stderr.toString();
|
|
739
|
+
assert.ok(
|
|
740
|
+
stderr.includes('idea 2 is not in pending state (current: done)'),
|
|
741
|
+
`Expected state error in stderr, got: ${stderr}`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it('rejects when fewer than 2 IDs provided', () => {
|
|
747
|
+
initLocalGitRepo(tmpDir);
|
|
748
|
+
createPendingIdea(tmpDir, 1, 'Solo idea');
|
|
749
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
750
|
+
|
|
751
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
752
|
+
try {
|
|
753
|
+
execSync(
|
|
754
|
+
`node -e "require('./ideas.cjs').cmdIdeasConsolidate('${escapedCwd}', { ids: '1', title: 'Solo' }, false, null)"`,
|
|
755
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'] }
|
|
756
|
+
);
|
|
757
|
+
assert.fail('should have exited with error');
|
|
758
|
+
} catch (err) {
|
|
759
|
+
const stderr = err.stderr.toString();
|
|
760
|
+
assert.ok(
|
|
761
|
+
stderr.includes('at least 2 idea IDs required'),
|
|
762
|
+
`Expected IDs error in stderr, got: ${stderr}`
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it('rejects when title missing', () => {
|
|
768
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
769
|
+
try {
|
|
770
|
+
execSync(
|
|
771
|
+
`node -e "require('./ideas.cjs').cmdIdeasConsolidate('${escapedCwd}', { ids: '1,2' }, false, null)"`,
|
|
772
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'] }
|
|
773
|
+
);
|
|
774
|
+
assert.fail('should have exited with error');
|
|
775
|
+
} catch (err) {
|
|
776
|
+
const stderr = err.stderr.toString();
|
|
777
|
+
assert.ok(
|
|
778
|
+
stderr.includes('title required'),
|
|
779
|
+
`Expected title error in stderr, got: ${stderr}`
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('merges tags from all sources with deduplication', () => {
|
|
785
|
+
initLocalGitRepo(tmpDir);
|
|
786
|
+
createPendingIdea(tmpDir, 1, 'Idea A', ['api', 'auth']);
|
|
787
|
+
createPendingIdea(tmpDir, 2, 'Idea B', ['api', 'ui']);
|
|
788
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
789
|
+
|
|
790
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
791
|
+
const result = execSync(
|
|
792
|
+
`node -e "require('./ideas.cjs').cmdIdeasConsolidate('${escapedCwd}', { ids: '1,2', title: 'Unified', tags: 'performance' }, false, null)"`,
|
|
793
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
|
|
794
|
+
);
|
|
795
|
+
const json = JSON.parse(result);
|
|
796
|
+
|
|
797
|
+
// Read the new idea file to check tags
|
|
798
|
+
const newIdeaPath = path.join(tmpDir, '.planning', 'ideas', 'pending', json.filename);
|
|
799
|
+
const content = fs.readFileSync(newIdeaPath, 'utf-8');
|
|
800
|
+
const parsed = parseIdeaFrontmatter(content);
|
|
801
|
+
const tags = parsed.frontmatter.tags;
|
|
802
|
+
|
|
803
|
+
assert.ok(tags.includes('api'), 'should include api');
|
|
804
|
+
assert.ok(tags.includes('auth'), 'should include auth');
|
|
805
|
+
assert.ok(tags.includes('ui'), 'should include ui');
|
|
806
|
+
assert.ok(tags.includes('performance'), 'should include performance');
|
|
807
|
+
// Deduplication: api should appear only once
|
|
808
|
+
assert.strictEqual(tags.filter(t => t === 'api').length, 1, 'api should not be duplicated');
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it('creates atomic git commit with correct message format', () => {
|
|
812
|
+
initLocalGitRepo(tmpDir);
|
|
813
|
+
createPendingIdea(tmpDir, 1, 'First idea');
|
|
814
|
+
createPendingIdea(tmpDir, 2, 'Second idea');
|
|
815
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
816
|
+
|
|
817
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
818
|
+
execSync(
|
|
819
|
+
`node -e "require('./ideas.cjs').cmdIdeasConsolidate('${escapedCwd}', { ids: '1,2', title: 'Combined ideas' }, false, null)"`,
|
|
820
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'] }
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
const log = execSync('git log -1 --oneline', { cwd: tmpDir, encoding: 'utf-8' }).trim();
|
|
824
|
+
assert.ok(
|
|
825
|
+
log.includes('ideas: consolidate #1, #2 -> #3 - Combined ideas'),
|
|
826
|
+
`Expected commit message format, got: ${log}`
|
|
827
|
+
);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('passes discussion and research through to new idea', () => {
|
|
831
|
+
initLocalGitRepo(tmpDir);
|
|
832
|
+
createPendingIdea(tmpDir, 1, 'Discuss idea');
|
|
833
|
+
createPendingIdea(tmpDir, 2, 'Research idea');
|
|
834
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
835
|
+
|
|
836
|
+
const discussion = '### [from #1] Discussion -- 2026-03-10\\n\\n**Key Insights:** Important\\n';
|
|
837
|
+
const research = '### [from #2] Research -- 2026-03-10\\n\\n**Summary:** Found stuff\\n';
|
|
838
|
+
|
|
839
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
840
|
+
const result = execSync(
|
|
841
|
+
`node -e "require('./ideas.cjs').cmdIdeasConsolidate('${escapedCwd}', { ids: '1,2', title: 'With logs', discussion: '${discussion}', research: '${research}' }, false, null)"`,
|
|
842
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
|
|
843
|
+
);
|
|
844
|
+
const json = JSON.parse(result);
|
|
845
|
+
|
|
846
|
+
const newIdeaPath = path.join(tmpDir, '.planning', 'ideas', 'pending', json.filename);
|
|
847
|
+
const content = fs.readFileSync(newIdeaPath, 'utf-8');
|
|
848
|
+
assert.ok(content.includes('## Discussion Log'), 'should have Discussion Log section');
|
|
849
|
+
assert.ok(content.includes('## Research Log'), 'should have Research Log section');
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it('appends consolidation note to each source before moving', () => {
|
|
853
|
+
initLocalGitRepo(tmpDir);
|
|
854
|
+
createPendingIdea(tmpDir, 1, 'Source one');
|
|
855
|
+
createPendingIdea(tmpDir, 2, 'Source two');
|
|
856
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
857
|
+
|
|
858
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
859
|
+
execSync(
|
|
860
|
+
`node -e "require('./ideas.cjs').cmdIdeasConsolidate('${escapedCwd}', { ids: '1,2', title: 'Merged result' }, false, null)"`,
|
|
861
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'] }
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
// Check sources in consolidated/ have consolidation notes
|
|
865
|
+
const consolidatedDir = path.join(tmpDir, '.planning', 'ideas', 'consolidated');
|
|
866
|
+
const files = fs.readdirSync(consolidatedDir).filter(f => f.endsWith('.md'));
|
|
867
|
+
for (const f of files) {
|
|
868
|
+
const content = fs.readFileSync(path.join(consolidatedDir, f), 'utf-8');
|
|
869
|
+
assert.ok(
|
|
870
|
+
content.includes('Consolidated into #'),
|
|
871
|
+
`${f} should have consolidation note`
|
|
872
|
+
);
|
|
873
|
+
assert.ok(
|
|
874
|
+
content.includes('Merged result'),
|
|
875
|
+
`${f} consolidation note should mention new idea title`
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it('returns structured JSON with new ID, path, and moved files', () => {
|
|
881
|
+
initLocalGitRepo(tmpDir);
|
|
882
|
+
createPendingIdea(tmpDir, 1, 'Alpha');
|
|
883
|
+
createPendingIdea(tmpDir, 2, 'Beta');
|
|
884
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
885
|
+
|
|
886
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
887
|
+
const result = execSync(
|
|
888
|
+
`node -e "require('./ideas.cjs').cmdIdeasConsolidate('${escapedCwd}', { ids: '1,2', title: 'Combined' }, false, null)"`,
|
|
889
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
|
|
890
|
+
);
|
|
891
|
+
const json = JSON.parse(result);
|
|
892
|
+
|
|
893
|
+
assert.strictEqual(typeof json.id, 'number');
|
|
894
|
+
assert.ok(json.filename);
|
|
895
|
+
assert.ok(json.path);
|
|
896
|
+
assert.ok(json.title);
|
|
897
|
+
assert.ok(Array.isArray(json.consolidated_from));
|
|
898
|
+
assert.ok(Array.isArray(json.moved_files));
|
|
899
|
+
assert.strictEqual(json.moved_files.length, 2);
|
|
900
|
+
for (const m of json.moved_files) {
|
|
901
|
+
assert.strictEqual(m.from, 'pending');
|
|
902
|
+
assert.strictEqual(m.to, 'consolidated');
|
|
903
|
+
assert.ok(m.filename);
|
|
904
|
+
assert.ok(typeof m.id === 'number');
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it('consolidates 3 pending ideas with tag union', () => {
|
|
909
|
+
initLocalGitRepo(tmpDir);
|
|
910
|
+
createPendingIdea(tmpDir, 1, 'Idea X', ['api']);
|
|
911
|
+
createPendingIdea(tmpDir, 2, 'Idea Y', ['api', 'db']);
|
|
912
|
+
createPendingIdea(tmpDir, 3, 'Idea Z', ['ui']);
|
|
913
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
914
|
+
|
|
915
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
916
|
+
const result = execSync(
|
|
917
|
+
`node -e "require('./ideas.cjs').cmdIdeasConsolidate('${escapedCwd}', { ids: '1,2,3', title: 'Triple merge' }, false, null)"`,
|
|
918
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
|
|
919
|
+
);
|
|
920
|
+
const json = JSON.parse(result);
|
|
921
|
+
|
|
922
|
+
assert.strictEqual(json.id, 4);
|
|
923
|
+
assert.deepStrictEqual(json.consolidated_from, ['001', '002', '003']);
|
|
924
|
+
assert.strictEqual(json.moved_files.length, 3);
|
|
925
|
+
|
|
926
|
+
// Check tags on new idea
|
|
927
|
+
const newIdeaPath = path.join(tmpDir, '.planning', 'ideas', 'pending', json.filename);
|
|
928
|
+
const content = fs.readFileSync(newIdeaPath, 'utf-8');
|
|
929
|
+
const parsed = parseIdeaFrontmatter(content);
|
|
930
|
+
assert.ok(parsed.frontmatter.tags.includes('api'));
|
|
931
|
+
assert.ok(parsed.frontmatter.tags.includes('db'));
|
|
932
|
+
assert.ok(parsed.frontmatter.tags.includes('ui'));
|
|
933
|
+
assert.strictEqual(parsed.frontmatter.tags.filter(t => t === 'api').length, 1);
|
|
934
|
+
});
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
// ─── cmdIdeasFindRelated ──────────────────────────────────────────────────────
|
|
938
|
+
|
|
939
|
+
describe('cmdIdeasFindRelated', () => {
|
|
940
|
+
let tmpDir;
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Creates a pending idea file with proper frontmatter and updates the manifest.
|
|
944
|
+
*/
|
|
945
|
+
function createPendingIdea(dir, id, title, tags = [], body = '') {
|
|
946
|
+
const paddedId = String(id).padStart(3, '0');
|
|
947
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
948
|
+
const filename = `${paddedId}-${slug}.md`;
|
|
949
|
+
const now = new Date().toISOString();
|
|
950
|
+
const tagsStr = tags.length > 0 ? `[${tags.map(t => `"${t}"`).join(', ')}]` : '[]';
|
|
951
|
+
const content = `---\nid: ${id}\ntitle: "${title}"\ntags: ${tagsStr}\ncreated: ${now}\nupdated: ${now}\n---\n\n${body || title + ' body.'}\n`;
|
|
952
|
+
const dir2 = path.join(dir, '.planning', 'ideas', 'pending');
|
|
953
|
+
fs.mkdirSync(dir2, { recursive: true });
|
|
954
|
+
fs.writeFileSync(path.join(dir2, filename), content, 'utf-8');
|
|
955
|
+
// Update manifest so allocateId returns next ID correctly
|
|
956
|
+
const manifestDir = path.join(dir, '.planning', 'ideas');
|
|
957
|
+
const manifestPath = path.join(manifestDir, 'manifest.json');
|
|
958
|
+
let manifest;
|
|
959
|
+
try {
|
|
960
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
961
|
+
} catch {
|
|
962
|
+
manifest = { next_id: 1 };
|
|
963
|
+
}
|
|
964
|
+
if (id >= manifest.next_id) {
|
|
965
|
+
manifest.next_id = id + 1;
|
|
966
|
+
}
|
|
967
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest), 'utf-8');
|
|
968
|
+
return filename;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Creates an idea in a specified state directory with manifest update.
|
|
973
|
+
*/
|
|
974
|
+
function createIdeaInStateHelper(dir, id, title, state, tags = []) {
|
|
975
|
+
const paddedId = String(id).padStart(3, '0');
|
|
976
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
977
|
+
const filename = `${paddedId}-${slug}.md`;
|
|
978
|
+
const now = new Date().toISOString();
|
|
979
|
+
const tagsStr = tags.length > 0 ? `[${tags.map(t => `"${t}"`).join(', ')}]` : '[]';
|
|
980
|
+
const content = `---\nid: ${id}\ntitle: "${title}"\ntags: ${tagsStr}\ncreated: ${now}\nupdated: ${now}\n---\n\n${title} body.\n`;
|
|
981
|
+
const stateDir = path.join(dir, '.planning', 'ideas', state);
|
|
982
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
983
|
+
fs.writeFileSync(path.join(stateDir, filename), content, 'utf-8');
|
|
984
|
+
const manifestDir = path.join(dir, '.planning', 'ideas');
|
|
985
|
+
const manifestPath = path.join(manifestDir, 'manifest.json');
|
|
986
|
+
let manifest;
|
|
987
|
+
try {
|
|
988
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
989
|
+
} catch {
|
|
990
|
+
manifest = { next_id: 1 };
|
|
991
|
+
}
|
|
992
|
+
if (id >= manifest.next_id) {
|
|
993
|
+
manifest.next_id = id + 1;
|
|
994
|
+
}
|
|
995
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest), 'utf-8');
|
|
996
|
+
return filename;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Captures stdout from a function call (for raw=true JSON output).
|
|
1001
|
+
*/
|
|
1002
|
+
function captureOutput(fn) {
|
|
1003
|
+
const orig = process.stdout.write;
|
|
1004
|
+
let captured = '';
|
|
1005
|
+
process.stdout.write = function (chunk) {
|
|
1006
|
+
captured += chunk;
|
|
1007
|
+
};
|
|
1008
|
+
try {
|
|
1009
|
+
fn();
|
|
1010
|
+
} finally {
|
|
1011
|
+
process.stdout.write = orig;
|
|
1012
|
+
}
|
|
1013
|
+
return captured.trim();
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
beforeEach(() => {
|
|
1017
|
+
tmpDir = createTempDir('ideas-find-related-test-');
|
|
1018
|
+
ensureIdeasDirs(tmpDir);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
afterEach(() => cleanupDir(tmpDir));
|
|
1022
|
+
|
|
1023
|
+
it('returns empty results when only the anchor idea exists', () => {
|
|
1024
|
+
createPendingIdea(tmpDir, 1, 'Solo idea', ['api', 'reliability']);
|
|
1025
|
+
const output = captureOutput(() => {
|
|
1026
|
+
cmdIdeasFindRelated(tmpDir, { id: '1', threshold: 'low' }, true);
|
|
1027
|
+
});
|
|
1028
|
+
const result = JSON.parse(output);
|
|
1029
|
+
assert.strictEqual(result.anchor.id, 1);
|
|
1030
|
+
assert.strictEqual(result.results.length, 0);
|
|
1031
|
+
assert.deepStrictEqual(result.counts, { high: 0, medium: 0, low: 0, total: 0 });
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it('scores HIGH for ideas sharing all tags', () => {
|
|
1035
|
+
createPendingIdea(tmpDir, 1, 'Anchor idea', ['api', 'reliability']);
|
|
1036
|
+
createPendingIdea(tmpDir, 2, 'Related idea', ['api', 'reliability']);
|
|
1037
|
+
const output = captureOutput(() => {
|
|
1038
|
+
cmdIdeasFindRelated(tmpDir, { id: '1', threshold: 'low' }, true);
|
|
1039
|
+
});
|
|
1040
|
+
const result = JSON.parse(output);
|
|
1041
|
+
assert.strictEqual(result.results.length, 1);
|
|
1042
|
+
assert.strictEqual(result.results[0].id, 2);
|
|
1043
|
+
assert.strictEqual(result.results[0].match_level, 'HIGH');
|
|
1044
|
+
assert.deepStrictEqual(result.results[0].tag_overlap.shared.sort(), ['api', 'reliability']);
|
|
1045
|
+
assert.strictEqual(result.results[0].tag_overlap.ratio, 1.0);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it('scores MEDIUM for ideas sharing some tags', () => {
|
|
1049
|
+
createPendingIdea(tmpDir, 1, 'Anchor idea', ['api', 'reliability', 'error-handling']);
|
|
1050
|
+
createPendingIdea(tmpDir, 2, 'Partial match', ['api', 'frontend']);
|
|
1051
|
+
const output = captureOutput(() => {
|
|
1052
|
+
cmdIdeasFindRelated(tmpDir, { id: '1', threshold: 'low' }, true);
|
|
1053
|
+
});
|
|
1054
|
+
const result = JSON.parse(output);
|
|
1055
|
+
assert.strictEqual(result.results.length, 1);
|
|
1056
|
+
assert.strictEqual(result.results[0].match_level, 'MEDIUM');
|
|
1057
|
+
// Jaccard: 1 shared (api) / 4 union (api, reliability, error-handling, frontend) = 0.25
|
|
1058
|
+
const ratio = result.results[0].tag_overlap.ratio;
|
|
1059
|
+
assert.ok(ratio >= 0.2 && ratio < 0.5, `Expected MEDIUM ratio between 0.2 and 0.5, got ${ratio}`);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it('scores LOW for ideas sharing one tag out of many', () => {
|
|
1063
|
+
createPendingIdea(tmpDir, 1, 'Anchor idea', ['api', 'reliability', 'error-handling', 'monitoring']);
|
|
1064
|
+
createPendingIdea(tmpDir, 2, 'Barely matching', ['api', 'frontend', 'design', 'ux']);
|
|
1065
|
+
const output = captureOutput(() => {
|
|
1066
|
+
cmdIdeasFindRelated(tmpDir, { id: '1', threshold: 'low' }, true);
|
|
1067
|
+
});
|
|
1068
|
+
const result = JSON.parse(output);
|
|
1069
|
+
assert.strictEqual(result.results.length, 1);
|
|
1070
|
+
assert.strictEqual(result.results[0].match_level, 'LOW');
|
|
1071
|
+
// Jaccard: 1 shared (api) / 7 union = ~0.143
|
|
1072
|
+
const ratio = result.results[0].tag_overlap.ratio;
|
|
1073
|
+
assert.ok(ratio > 0 && ratio < 0.2, `Expected LOW ratio between 0 and 0.2, got ${ratio}`);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it('threshold=medium filters out LOW matches', () => {
|
|
1077
|
+
createPendingIdea(tmpDir, 1, 'Anchor idea', ['api', 'reliability']);
|
|
1078
|
+
createPendingIdea(tmpDir, 2, 'No overlap', ['frontend']);
|
|
1079
|
+
const output = captureOutput(() => {
|
|
1080
|
+
cmdIdeasFindRelated(tmpDir, { id: '1', threshold: 'medium' }, true);
|
|
1081
|
+
});
|
|
1082
|
+
const result = JSON.parse(output);
|
|
1083
|
+
// No shared tags -> NONE level -> filtered out even at 'low' threshold,
|
|
1084
|
+
// but certainly at 'medium'
|
|
1085
|
+
assert.strictEqual(result.results.length, 0);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
it('threshold=high filters out MEDIUM and LOW matches', () => {
|
|
1089
|
+
createPendingIdea(tmpDir, 1, 'Anchor idea', ['api', 'reliability', 'error-handling']);
|
|
1090
|
+
createPendingIdea(tmpDir, 2, 'Medium match', ['api', 'frontend']);
|
|
1091
|
+
const output = captureOutput(() => {
|
|
1092
|
+
cmdIdeasFindRelated(tmpDir, { id: '1', threshold: 'high' }, true);
|
|
1093
|
+
});
|
|
1094
|
+
const result = JSON.parse(output);
|
|
1095
|
+
// Jaccard: 1/4 = 0.25 -> MEDIUM, should be filtered at threshold=high
|
|
1096
|
+
assert.strictEqual(result.results.length, 0);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
it('includes idea content in results for AI scoring', () => {
|
|
1100
|
+
createPendingIdea(tmpDir, 1, 'Anchor idea', ['api']);
|
|
1101
|
+
createPendingIdea(tmpDir, 2, 'Content idea', ['api'], 'Detailed body about the API.');
|
|
1102
|
+
const output = captureOutput(() => {
|
|
1103
|
+
cmdIdeasFindRelated(tmpDir, { id: '1', threshold: 'low' }, true);
|
|
1104
|
+
});
|
|
1105
|
+
const result = JSON.parse(output);
|
|
1106
|
+
assert.strictEqual(result.results.length, 1);
|
|
1107
|
+
const item = result.results[0];
|
|
1108
|
+
assert.ok('title' in item, 'result should include title');
|
|
1109
|
+
assert.ok('tags' in item, 'result should include tags');
|
|
1110
|
+
assert.ok('body' in item, 'result should include body');
|
|
1111
|
+
assert.ok('notes' in item, 'result should include notes');
|
|
1112
|
+
assert.ok('discussion_log' in item, 'result should include discussion_log');
|
|
1113
|
+
assert.ok('research_log' in item, 'result should include research_log');
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it('excludes non-pending ideas', () => {
|
|
1117
|
+
createPendingIdea(tmpDir, 1, 'Anchor idea', ['api']);
|
|
1118
|
+
createIdeaInStateHelper(tmpDir, 2, 'Done idea', 'done', ['api']);
|
|
1119
|
+
createPendingIdea(tmpDir, 3, 'Another pending', ['api']);
|
|
1120
|
+
const output = captureOutput(() => {
|
|
1121
|
+
cmdIdeasFindRelated(tmpDir, { id: '1', threshold: 'low' }, true);
|
|
1122
|
+
});
|
|
1123
|
+
const result = JSON.parse(output);
|
|
1124
|
+
// Only idea 3 should appear (idea 2 is done, excluded)
|
|
1125
|
+
assert.strictEqual(result.results.length, 1);
|
|
1126
|
+
assert.strictEqual(result.results[0].id, 3);
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
it('errors when anchor idea not found', () => {
|
|
1130
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
1131
|
+
try {
|
|
1132
|
+
execSync(
|
|
1133
|
+
`node -e "require('./ideas.cjs').cmdIdeasFindRelated('${escapedCwd}', { id: '999', threshold: 'low' }, true)"`,
|
|
1134
|
+
{ cwd: path.join(__dirname), stderr: 'pipe', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
1135
|
+
);
|
|
1136
|
+
assert.fail('should have exited with error');
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
const stderr = err.stderr.toString();
|
|
1139
|
+
assert.ok(
|
|
1140
|
+
stderr.includes('not found'),
|
|
1141
|
+
`Expected "not found" in stderr, got: ${stderr}`
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it('groups results by match level in output sorted HIGH first', () => {
|
|
1147
|
+
createPendingIdea(tmpDir, 1, 'Anchor idea', ['api', 'reliability']);
|
|
1148
|
+
createPendingIdea(tmpDir, 2, 'High match', ['api', 'reliability']);
|
|
1149
|
+
createPendingIdea(tmpDir, 3, 'Medium match', ['api', 'frontend', 'design']);
|
|
1150
|
+
createPendingIdea(tmpDir, 4, 'Low match', ['frontend']);
|
|
1151
|
+
const output = captureOutput(() => {
|
|
1152
|
+
cmdIdeasFindRelated(tmpDir, { id: '1', threshold: 'low' }, true);
|
|
1153
|
+
});
|
|
1154
|
+
const result = JSON.parse(output);
|
|
1155
|
+
// Idea 2: HIGH (2/2 = 1.0), Idea 3: MEDIUM (1/4 = 0.25), Idea 4: no overlap -> excluded
|
|
1156
|
+
// Only ideas with overlap > 0 are included
|
|
1157
|
+
const levels = result.results.map(r => r.match_level);
|
|
1158
|
+
// HIGH should come before MEDIUM
|
|
1159
|
+
const highIdx = levels.indexOf('HIGH');
|
|
1160
|
+
const medIdx = levels.indexOf('MEDIUM');
|
|
1161
|
+
if (highIdx !== -1 && medIdx !== -1) {
|
|
1162
|
+
assert.ok(highIdx < medIdx, `HIGH (${highIdx}) should come before MEDIUM (${medIdx})`);
|
|
1163
|
+
}
|
|
1164
|
+
// Every result should have a match_level field
|
|
1165
|
+
for (const r of result.results) {
|
|
1166
|
+
assert.ok(['HIGH', 'MEDIUM', 'LOW'].includes(r.match_level),
|
|
1167
|
+
`match_level should be HIGH, MEDIUM, or LOW, got: ${r.match_level}`);
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
// ─── cmdIdeasUndoConsolidation ────────────────────────────────────────────────
|
|
1173
|
+
|
|
1174
|
+
describe('cmdIdeasUndoConsolidation', () => {
|
|
1175
|
+
let tmpDir;
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Creates a pending idea file with proper frontmatter and updates the manifest.
|
|
1179
|
+
*/
|
|
1180
|
+
function createPendingIdea(dir, id, title, tags = []) {
|
|
1181
|
+
const paddedId = String(id).padStart(3, '0');
|
|
1182
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
1183
|
+
const filename = `${paddedId}-${slug}.md`;
|
|
1184
|
+
const now = new Date().toISOString();
|
|
1185
|
+
const tagsStr = tags.length > 0 ? `[${tags.map(t => `"${t}"`).join(', ')}]` : '[]';
|
|
1186
|
+
const content = `---\nid: ${id}\ntitle: "${title}"\ntags: ${tagsStr}\ncreated: ${now}\nupdated: ${now}\n---\n\n${title} body.\n`;
|
|
1187
|
+
const dir2 = path.join(dir, '.planning', 'ideas', 'pending');
|
|
1188
|
+
fs.mkdirSync(dir2, { recursive: true });
|
|
1189
|
+
fs.writeFileSync(path.join(dir2, filename), content, 'utf-8');
|
|
1190
|
+
// Update manifest so allocateId returns next ID correctly
|
|
1191
|
+
const manifestDir = path.join(dir, '.planning', 'ideas');
|
|
1192
|
+
const manifestPath = path.join(manifestDir, 'manifest.json');
|
|
1193
|
+
let manifest;
|
|
1194
|
+
try {
|
|
1195
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
1196
|
+
} catch {
|
|
1197
|
+
manifest = { next_id: 1 };
|
|
1198
|
+
}
|
|
1199
|
+
if (id >= manifest.next_id) {
|
|
1200
|
+
manifest.next_id = id + 1;
|
|
1201
|
+
}
|
|
1202
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest), 'utf-8');
|
|
1203
|
+
return filename;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Initializes a git repo with all IDEA_STATES directories and initial commit.
|
|
1208
|
+
*/
|
|
1209
|
+
function initLocalGitRepo(dir) {
|
|
1210
|
+
execSync('git init', { cwd: dir, stdio: 'pipe' });
|
|
1211
|
+
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
|
|
1212
|
+
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
|
|
1213
|
+
for (const state of ['pending', 'done', 'rejected', 'consolidated']) {
|
|
1214
|
+
const stateDir = path.join(dir, '.planning', 'ideas', state);
|
|
1215
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
1216
|
+
fs.writeFileSync(path.join(stateDir, '.gitkeep'), '', 'utf-8');
|
|
1217
|
+
}
|
|
1218
|
+
execSync('git add -A && git commit -m "init"', { cwd: dir, stdio: 'pipe' });
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Consolidates ideas via subprocess (since it calls process.exit) and returns parsed JSON.
|
|
1223
|
+
*/
|
|
1224
|
+
function consolidateViaSubprocess(dir, ids, title) {
|
|
1225
|
+
const escapedCwd = dir.replace(/'/g, "\\'");
|
|
1226
|
+
const result = execSync(
|
|
1227
|
+
`node -e "require('./ideas.cjs').cmdIdeasConsolidate('${escapedCwd}', { ids: '${ids}', title: '${title}' }, false, null)"`,
|
|
1228
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
|
|
1229
|
+
);
|
|
1230
|
+
return JSON.parse(result);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Calls cmdIdeasUndoConsolidation via subprocess (since it calls process.exit).
|
|
1235
|
+
*/
|
|
1236
|
+
function undoViaSubprocess(dir, id) {
|
|
1237
|
+
const escapedCwd = dir.replace(/'/g, "\\'");
|
|
1238
|
+
return execSync(
|
|
1239
|
+
`node -e "require('./ideas.cjs').cmdIdeasUndoConsolidation('${escapedCwd}', '${id}', false, null)"`,
|
|
1240
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
beforeEach(() => {
|
|
1245
|
+
tmpDir = createTempDir('ideas-undo-consolidation-test-');
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
afterEach(() => cleanupDir(tmpDir));
|
|
1249
|
+
|
|
1250
|
+
it('undoes consolidation and restores source ideas to pending', () => {
|
|
1251
|
+
initLocalGitRepo(tmpDir);
|
|
1252
|
+
createPendingIdea(tmpDir, 1, 'Idea Alpha', ['api']);
|
|
1253
|
+
createPendingIdea(tmpDir, 2, 'Idea Beta', ['api', 'ui']);
|
|
1254
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
1255
|
+
|
|
1256
|
+
// Consolidate ideas 1 and 2 into idea 3
|
|
1257
|
+
consolidateViaSubprocess(tmpDir, '1,2', 'Merged Alpha Beta');
|
|
1258
|
+
|
|
1259
|
+
// Undo the consolidation
|
|
1260
|
+
undoViaSubprocess(tmpDir, '3');
|
|
1261
|
+
|
|
1262
|
+
// Verify: idea 3 no longer exists in pending/
|
|
1263
|
+
const pendingDir = path.join(tmpDir, '.planning', 'ideas', 'pending');
|
|
1264
|
+
const pendingFiles = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
|
|
1265
|
+
assert.ok(!pendingFiles.some(f => f.startsWith('003')), 'Consolidated idea 3 should be deleted from pending');
|
|
1266
|
+
|
|
1267
|
+
// Verify: ideas 1 and 2 are back in pending/
|
|
1268
|
+
assert.ok(pendingFiles.some(f => f.startsWith('001')), 'Idea 1 should be restored to pending');
|
|
1269
|
+
assert.ok(pendingFiles.some(f => f.startsWith('002')), 'Idea 2 should be restored to pending');
|
|
1270
|
+
|
|
1271
|
+
// Verify: consolidated/ has no idea files
|
|
1272
|
+
const consolidatedDir = path.join(tmpDir, '.planning', 'ideas', 'consolidated');
|
|
1273
|
+
const consolidatedFiles = fs.readdirSync(consolidatedDir).filter(f => f.endsWith('.md'));
|
|
1274
|
+
assert.strictEqual(consolidatedFiles.length, 0, 'consolidated/ should have no idea files');
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
it('removes consolidated_into from restored source ideas', () => {
|
|
1278
|
+
initLocalGitRepo(tmpDir);
|
|
1279
|
+
createPendingIdea(tmpDir, 1, 'Idea One', ['api']);
|
|
1280
|
+
createPendingIdea(tmpDir, 2, 'Idea Two', ['db']);
|
|
1281
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
1282
|
+
|
|
1283
|
+
consolidateViaSubprocess(tmpDir, '1,2', 'Combined One Two');
|
|
1284
|
+
undoViaSubprocess(tmpDir, '3');
|
|
1285
|
+
|
|
1286
|
+
// Read each restored source idea and check consolidated_into is gone
|
|
1287
|
+
const pendingDir = path.join(tmpDir, '.planning', 'ideas', 'pending');
|
|
1288
|
+
const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
|
|
1289
|
+
for (const f of files) {
|
|
1290
|
+
const content = fs.readFileSync(path.join(pendingDir, f), 'utf-8');
|
|
1291
|
+
const parsed = parseIdeaFrontmatter(content);
|
|
1292
|
+
assert.ok(!('consolidated_into' in parsed.frontmatter),
|
|
1293
|
+
`${f} should NOT have consolidated_into after undo`);
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it('restored source ideas do not have consolidated_from', () => {
|
|
1298
|
+
initLocalGitRepo(tmpDir);
|
|
1299
|
+
createPendingIdea(tmpDir, 1, 'Source X', ['api']);
|
|
1300
|
+
createPendingIdea(tmpDir, 2, 'Source Y', ['ui']);
|
|
1301
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
1302
|
+
|
|
1303
|
+
consolidateViaSubprocess(tmpDir, '1,2', 'Merged XY');
|
|
1304
|
+
undoViaSubprocess(tmpDir, '3');
|
|
1305
|
+
|
|
1306
|
+
// Restored source ideas should NOT have consolidated_from (they never had it)
|
|
1307
|
+
const pendingDir = path.join(tmpDir, '.planning', 'ideas', 'pending');
|
|
1308
|
+
const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
|
|
1309
|
+
for (const f of files) {
|
|
1310
|
+
const content = fs.readFileSync(path.join(pendingDir, f), 'utf-8');
|
|
1311
|
+
const parsed = parseIdeaFrontmatter(content);
|
|
1312
|
+
assert.ok(!('consolidated_from' in parsed.frontmatter),
|
|
1313
|
+
`${f} should NOT have consolidated_from after undo`);
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
it('returns structured JSON with undo details', () => {
|
|
1318
|
+
initLocalGitRepo(tmpDir);
|
|
1319
|
+
createPendingIdea(tmpDir, 1, 'Detail A', ['api']);
|
|
1320
|
+
createPendingIdea(tmpDir, 2, 'Detail B', ['db']);
|
|
1321
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
1322
|
+
|
|
1323
|
+
consolidateViaSubprocess(tmpDir, '1,2', 'Detailed merge');
|
|
1324
|
+
const result = undoViaSubprocess(tmpDir, '3');
|
|
1325
|
+
const json = JSON.parse(result);
|
|
1326
|
+
|
|
1327
|
+
assert.ok('undone_idea_id' in json, 'result should have undone_idea_id');
|
|
1328
|
+
assert.ok(Array.isArray(json.restored_ids), 'result should have restored_ids array');
|
|
1329
|
+
assert.ok('deleted_file' in json, 'result should have deleted_file');
|
|
1330
|
+
assert.strictEqual(json.undone_idea_id, 3);
|
|
1331
|
+
assert.strictEqual(json.restored_ids.length, 2);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
it('creates atomic git commit', () => {
|
|
1335
|
+
initLocalGitRepo(tmpDir);
|
|
1336
|
+
createPendingIdea(tmpDir, 1, 'Commit A', ['api']);
|
|
1337
|
+
createPendingIdea(tmpDir, 2, 'Commit B', ['db']);
|
|
1338
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
1339
|
+
|
|
1340
|
+
consolidateViaSubprocess(tmpDir, '1,2', 'Commit merge');
|
|
1341
|
+
undoViaSubprocess(tmpDir, '3');
|
|
1342
|
+
|
|
1343
|
+
const log = execSync('git log --oneline -1', { cwd: tmpDir, encoding: 'utf-8' }).trim();
|
|
1344
|
+
assert.ok(
|
|
1345
|
+
log.includes('ideas: undo consolidation #3'),
|
|
1346
|
+
`Expected commit message with "ideas: undo consolidation #3", got: ${log}`
|
|
1347
|
+
);
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
it('errors when idea not found', () => {
|
|
1351
|
+
initLocalGitRepo(tmpDir);
|
|
1352
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
1353
|
+
try {
|
|
1354
|
+
execSync(
|
|
1355
|
+
`node -e "require('./ideas.cjs').cmdIdeasUndoConsolidation('${escapedCwd}', '999', false, null)"`,
|
|
1356
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'] }
|
|
1357
|
+
);
|
|
1358
|
+
assert.fail('should have exited with error');
|
|
1359
|
+
} catch (err) {
|
|
1360
|
+
const stderr = err.stderr.toString();
|
|
1361
|
+
assert.ok(
|
|
1362
|
+
stderr.includes('not found'),
|
|
1363
|
+
`Expected "not found" in stderr, got: ${stderr}`
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
it('errors when idea has no consolidated_from', () => {
|
|
1369
|
+
initLocalGitRepo(tmpDir);
|
|
1370
|
+
createPendingIdea(tmpDir, 1, 'Plain idea', ['api']);
|
|
1371
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
1372
|
+
|
|
1373
|
+
const escapedCwd = tmpDir.replace(/'/g, "\\'");
|
|
1374
|
+
try {
|
|
1375
|
+
execSync(
|
|
1376
|
+
`node -e "require('./ideas.cjs').cmdIdeasUndoConsolidation('${escapedCwd}', '1', false, null)"`,
|
|
1377
|
+
{ cwd: path.join(__dirname), stdio: ['pipe', 'pipe', 'pipe'] }
|
|
1378
|
+
);
|
|
1379
|
+
assert.fail('should have exited with error');
|
|
1380
|
+
} catch (err) {
|
|
1381
|
+
const stderr = err.stderr.toString();
|
|
1382
|
+
assert.ok(
|
|
1383
|
+
stderr.includes('not a consolidated idea') || stderr.includes('no consolidated_from'),
|
|
1384
|
+
`Expected consolidation error in stderr, got: ${stderr}`
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
it('handles 3+ source ideas', () => {
|
|
1390
|
+
initLocalGitRepo(tmpDir);
|
|
1391
|
+
createPendingIdea(tmpDir, 1, 'Triple A', ['api']);
|
|
1392
|
+
createPendingIdea(tmpDir, 2, 'Triple B', ['db']);
|
|
1393
|
+
createPendingIdea(tmpDir, 3, 'Triple C', ['ui']);
|
|
1394
|
+
execSync('git add -A && git commit -m "add ideas"', { cwd: tmpDir, stdio: 'pipe' });
|
|
1395
|
+
|
|
1396
|
+
// Consolidate all 3 into idea 4
|
|
1397
|
+
consolidateViaSubprocess(tmpDir, '1,2,3', 'Triple merge');
|
|
1398
|
+
|
|
1399
|
+
// Undo the consolidation
|
|
1400
|
+
const result = undoViaSubprocess(tmpDir, '4');
|
|
1401
|
+
const json = JSON.parse(result);
|
|
1402
|
+
|
|
1403
|
+
// All 3 source ideas should be restored to pending
|
|
1404
|
+
const pendingDir = path.join(tmpDir, '.planning', 'ideas', 'pending');
|
|
1405
|
+
const pendingFiles = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
|
|
1406
|
+
assert.ok(pendingFiles.some(f => f.startsWith('001')), 'Idea 1 should be restored');
|
|
1407
|
+
assert.ok(pendingFiles.some(f => f.startsWith('002')), 'Idea 2 should be restored');
|
|
1408
|
+
assert.ok(pendingFiles.some(f => f.startsWith('003')), 'Idea 3 should be restored');
|
|
1409
|
+
|
|
1410
|
+
// Idea 4 should be deleted
|
|
1411
|
+
assert.ok(!pendingFiles.some(f => f.startsWith('004')), 'Idea 4 should be deleted');
|
|
1412
|
+
|
|
1413
|
+
// JSON should show all 3 restored
|
|
1414
|
+
assert.strictEqual(json.restored_ids.length, 3);
|
|
1415
|
+
assert.strictEqual(json.undone_idea_id, 4);
|
|
1416
|
+
});
|
|
1417
|
+
});
|