plankit-cli 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -50
- package/bin/plankit.js +1 -0
- package/package.json +10 -1
- package/src/agents.js +98 -0
- package/src/cli.js +590 -170
- package/src/commandBundles.js +51 -0
- package/src/config.js +234 -0
- package/src/templates.js +21 -143
- package/templates/AGENTS.md +12 -0
- package/templates/PLANKIT.md +40 -0
- package/templates/clarifications.md +16 -0
- package/templates/cursorrules +10 -0
- package/templates/feature-readme.md +22 -0
- package/templates/gemini-instructions.md +28 -0
- package/templates/opencode/plankit-clarify.md +10 -0
- package/templates/opencode/plankit-implement.md +14 -0
- package/templates/opencode/plankit-plan.md +13 -0
- package/templates/opencode/plankit-review.md +11 -0
- package/templates/opencode/plankit.md +12 -0
- package/templates/output-report.md +17 -0
- package/templates/phase-spec.md +19 -0
package/src/cli.js
CHANGED
|
@@ -1,235 +1,655 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
6
|
+
assertSafeName,
|
|
7
|
+
createDefaultConfigFile,
|
|
8
|
+
ensureDir,
|
|
9
|
+
listDirectories,
|
|
10
|
+
loadConfig,
|
|
11
|
+
normalizePhases,
|
|
12
|
+
parsePhaseFlag,
|
|
13
|
+
resolveConfiguredAgent,
|
|
14
|
+
safeJoin,
|
|
15
|
+
writeManagedFile
|
|
16
|
+
} from './config.js';
|
|
17
|
+
import { renderList, renderTemplate, packageRoot } from './templates.js';
|
|
18
|
+
import { DEFAULT_AGENT, GEMINI_SKILL_ENTRIES, getAgent, listAgents, validAgentNames } from './agents.js';
|
|
19
|
+
import { bundleEntry, bundleSourceExists } from './commandBundles.js';
|
|
20
|
+
|
|
21
|
+
export async function runCli(args, context = {}) {
|
|
22
|
+
const io = createIo(context);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const parsed = parseArgs(args);
|
|
26
|
+
const command = parsed.command;
|
|
27
|
+
|
|
28
|
+
switch (command) {
|
|
29
|
+
case 'init':
|
|
30
|
+
await initProject(parsed, io);
|
|
31
|
+
break;
|
|
32
|
+
case 'plan':
|
|
33
|
+
planFeature(parsed, io);
|
|
34
|
+
break;
|
|
35
|
+
case 'clarify':
|
|
36
|
+
clarifyFeature(parsed, io);
|
|
37
|
+
break;
|
|
38
|
+
case 'implement':
|
|
39
|
+
prepareImplementation(parsed, io);
|
|
40
|
+
break;
|
|
41
|
+
case 'review':
|
|
42
|
+
reviewFeature(parsed, io);
|
|
43
|
+
break;
|
|
44
|
+
case 'status':
|
|
45
|
+
showStatus(parsed, io);
|
|
46
|
+
break;
|
|
47
|
+
case 'archive':
|
|
48
|
+
archiveFeature(parsed, io);
|
|
49
|
+
break;
|
|
50
|
+
case 'help':
|
|
51
|
+
case '--help':
|
|
52
|
+
case '-h':
|
|
53
|
+
case undefined:
|
|
54
|
+
showHelp(io);
|
|
55
|
+
break;
|
|
56
|
+
default:
|
|
57
|
+
throw new Error(`Unknown command "${command}". Run "plankit help" for usage.`);
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
io.stderr(`Error: ${error.message}`);
|
|
61
|
+
io.exit(1);
|
|
47
62
|
}
|
|
48
63
|
}
|
|
49
64
|
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
async function initProject(parsed, io) {
|
|
66
|
+
const { config, path: configPath } = loadConfig(io.cwd);
|
|
67
|
+
const options = writeOptions(parsed, config);
|
|
68
|
+
const agent = await resolveAgent(parsed, config, io, configPath);
|
|
69
|
+
const results = [];
|
|
70
|
+
const artifactsDir = safeJoin(io.cwd, config.artifactsDir);
|
|
71
|
+
|
|
72
|
+
ensureDir(path.join(artifactsDir, 'current'), options);
|
|
73
|
+
ensureDir(path.join(artifactsDir, 'archived'), options);
|
|
74
|
+
|
|
75
|
+
results.push(writeManagedFile(
|
|
76
|
+
path.join(artifactsDir, 'PLANKIT.md'),
|
|
77
|
+
renderTemplate(config, 'PLANKIT.md', { artifactsDir: config.artifactsDir }, io.cwd),
|
|
78
|
+
options
|
|
79
|
+
));
|
|
80
|
+
results.push(writeManagedFile(path.join(artifactsDir, 'current', '.gitkeep'), '', options));
|
|
81
|
+
results.push(writeManagedFile(path.join(artifactsDir, 'archived', '.gitkeep'), '', options));
|
|
82
|
+
results.push(createDefaultConfigFile(io.cwd, options));
|
|
83
|
+
|
|
84
|
+
for (const binding of agent.bindings) {
|
|
85
|
+
results.push(writeManagedFile(
|
|
86
|
+
safeJoin(io.cwd, ...binding.dest),
|
|
87
|
+
renderTemplate(config, binding.template, { artifactsDir: config.artifactsDir, agent: agent.id }, io.cwd),
|
|
88
|
+
options
|
|
89
|
+
));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const bundleName of agent.commandBundles) {
|
|
93
|
+
results.push(installCommandBundle(agent, bundleName, config, options, io));
|
|
53
94
|
}
|
|
95
|
+
|
|
96
|
+
if (parsed.flags['global-gemini-skills'] && agent.id === 'gemini') {
|
|
97
|
+
results.push(...installGlobalGeminiSkills(config, options));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
io.stdout(`PlanKit initialized for agent "${agent.id}".`);
|
|
101
|
+
printResults(results, io);
|
|
54
102
|
}
|
|
55
103
|
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
104
|
+
function installCommandBundle(agent, bundleName, config, options, io) {
|
|
105
|
+
const entry = bundleEntry(bundleName);
|
|
106
|
+
if (!entry) {
|
|
107
|
+
return { action: 'info', path: bundleName, message: `unknown bundle for ${agent.id}` };
|
|
108
|
+
}
|
|
109
|
+
if (!bundleSourceExists(bundleName, io.cwd, config)) {
|
|
110
|
+
return {
|
|
111
|
+
action: 'info',
|
|
112
|
+
path: bundleName,
|
|
113
|
+
message: `${entry.label} for ${agent.id} not shipped yet`
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (entry.global && options.dryRun) {
|
|
118
|
+
return { action: 'info', path: bundleName, message: 'would install to user profile (dry run)' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const targetRoot = entry.global
|
|
122
|
+
? path.join(os.homedir(), ...entry.dest)
|
|
123
|
+
: safeJoin(io.cwd, ...entry.dest);
|
|
124
|
+
|
|
125
|
+
const sourceRoot = resolveBundleSource(entry.src, io.cwd, config);
|
|
126
|
+
ensureDir(targetRoot, options);
|
|
127
|
+
return copyBundleRecursive(sourceRoot, targetRoot, options);
|
|
59
128
|
}
|
|
60
129
|
|
|
61
|
-
function
|
|
62
|
-
|
|
130
|
+
function resolveBundleSource(src, cwd, config) {
|
|
131
|
+
const customPath = config.templatesDir
|
|
132
|
+
? path.resolve(cwd, config.templatesDir, src)
|
|
133
|
+
: null;
|
|
134
|
+
if (customPath && fs.existsSync(customPath)) {
|
|
135
|
+
return customPath;
|
|
136
|
+
}
|
|
137
|
+
return path.join(packageRoot, 'templates', src);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function copyBundleRecursive(sourceRoot, targetRoot, options) {
|
|
141
|
+
const copyResults = [];
|
|
142
|
+
const files = collectFiles(sourceRoot);
|
|
143
|
+
for (const rel of files) {
|
|
144
|
+
const srcFile = path.join(sourceRoot, rel);
|
|
145
|
+
const destFile = path.join(targetRoot, rel);
|
|
146
|
+
const rendered = renderTemplateFromFile(srcFile);
|
|
147
|
+
copyResults.push(writeManagedFile(destFile, rendered, options));
|
|
148
|
+
}
|
|
149
|
+
const wroteAny = copyResults.some((result) => result && result.action !== 'skipped' && result.action !== 'unchanged');
|
|
150
|
+
return {
|
|
151
|
+
action: wroteAny ? 'copied' : 'unchanged',
|
|
152
|
+
path: targetRoot,
|
|
153
|
+
copies: copyResults
|
|
154
|
+
};
|
|
155
|
+
}
|
|
63
156
|
|
|
64
|
-
|
|
157
|
+
function collectFiles(dir) {
|
|
158
|
+
const results = [];
|
|
159
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
160
|
+
const full = path.join(dir, entry.name);
|
|
161
|
+
if (entry.isDirectory()) {
|
|
162
|
+
results.push(...collectFiles(full).map((rel) => path.join(entry.name, rel)));
|
|
163
|
+
} else {
|
|
164
|
+
results.push(entry.name);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderTemplateFromFile(filePath) {
|
|
171
|
+
return fs.readFileSync(filePath, 'utf8')
|
|
172
|
+
.replace(/\{\{([a-zA-Z0-9_.-]+)\}\}/g, (_, key) => {
|
|
173
|
+
const maps = { artifactsDir: 'artifacts' };
|
|
174
|
+
return key in maps ? maps[key] : '';
|
|
175
|
+
});
|
|
176
|
+
}
|
|
65
177
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
178
|
+
function planFeature(parsed, io) {
|
|
179
|
+
const featureName = parsed.positionals[0];
|
|
180
|
+
if (!featureName) {
|
|
181
|
+
throw new Error('Please specify a feature name. Example: plankit plan my-feature');
|
|
182
|
+
}
|
|
183
|
+
assertSafeName(featureName, 'feature name');
|
|
72
184
|
|
|
73
|
-
|
|
74
|
-
|
|
185
|
+
const { config } = loadConfig(io.cwd);
|
|
186
|
+
const phases = parsePhaseFlag(parsed.flags.phases) || normalizePhases(config.defaultPhases);
|
|
187
|
+
const requirements = parsed.flags.requirements || parsed.positionals.slice(1).join(' ') || 'Detailed description of the feature goal and scope.';
|
|
188
|
+
const options = writeOptions(parsed, config);
|
|
189
|
+
const featureDir = safeJoin(io.cwd, config.artifactsDir, 'current', featureName);
|
|
75
190
|
|
|
76
|
-
|
|
77
|
-
|
|
191
|
+
if (fs.existsSync(featureDir)) {
|
|
192
|
+
throw new Error(`Feature "${featureName}" already exists at ${path.join(config.artifactsDir, 'current', featureName)}`);
|
|
193
|
+
}
|
|
78
194
|
|
|
79
|
-
|
|
80
|
-
|
|
195
|
+
ensureDir(path.join(featureDir, 'phases'), options);
|
|
196
|
+
ensureDir(path.join(featureDir, 'outputs'), options);
|
|
197
|
+
|
|
198
|
+
const phaseChecklist = renderList(phases, (phase, index) => (
|
|
199
|
+
`- [ ] Phase ${index + 1}: ${phase.title} — ([Spec](phases/${phaseFileName(index + 1, phase)}))`
|
|
200
|
+
));
|
|
201
|
+
const phaseMatrix = renderList(phases, (phase, index) => {
|
|
202
|
+
const phaseNumber = index + 1;
|
|
203
|
+
const specPath = `phases/${phaseFileName(phaseNumber, phase)}`;
|
|
204
|
+
const outputPath = `outputs/phase-${phaseNumber}-output.md`;
|
|
205
|
+
return `| Phase ${phaseNumber} | ${phase.title} | \`${specPath}\` | \`${outputPath}\` | **PENDING** |`;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const results = [];
|
|
209
|
+
results.push(writeManagedFile(
|
|
210
|
+
path.join(featureDir, 'README.md'),
|
|
211
|
+
renderTemplate(config, 'feature-readme.md', {
|
|
212
|
+
featureName,
|
|
213
|
+
requirements,
|
|
214
|
+
phaseChecklist,
|
|
215
|
+
phaseMatrix
|
|
216
|
+
}, io.cwd),
|
|
217
|
+
options
|
|
218
|
+
));
|
|
219
|
+
|
|
220
|
+
results.push(writeManagedFile(
|
|
221
|
+
path.join(featureDir, 'plankit.json'),
|
|
222
|
+
`${JSON.stringify(createFeatureState(featureName, phases), null, 2)}\n`,
|
|
223
|
+
options
|
|
224
|
+
));
|
|
225
|
+
|
|
226
|
+
phases.forEach((phase, index) => {
|
|
227
|
+
const phaseNumber = index + 1;
|
|
228
|
+
const tasks = phase.tasks.map((task) => `- [ ] ${task}`).join('\n');
|
|
229
|
+
results.push(writeManagedFile(
|
|
230
|
+
path.join(featureDir, 'phases', phaseFileName(phaseNumber, phase)),
|
|
231
|
+
renderTemplate(config, 'phase-spec.md', {
|
|
232
|
+
phaseNumber,
|
|
233
|
+
previousPhaseNumber: Math.max(phaseNumber - 1, 0),
|
|
234
|
+
phaseTitle: phase.title,
|
|
235
|
+
objective: phase.objective,
|
|
236
|
+
tasks
|
|
237
|
+
}, io.cwd),
|
|
238
|
+
options
|
|
239
|
+
));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
results.push(writeManagedFile(path.join(featureDir, 'outputs', '.gitkeep'), '', options));
|
|
243
|
+
|
|
244
|
+
io.stdout(`Feature "${featureName}" planned with ${phases.length} phase(s).`);
|
|
245
|
+
printResults(results, io);
|
|
246
|
+
}
|
|
81
247
|
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
248
|
+
function clarifyFeature(parsed, io) {
|
|
249
|
+
const { featureName, phaseNumber } = readFeatureAndPhaseArgs(parsed, { phaseOptional: true });
|
|
250
|
+
const { config } = loadConfig(io.cwd);
|
|
251
|
+
const options = writeOptions(parsed, config);
|
|
252
|
+
const featureDir = requireFeatureDir(io.cwd, config, featureName);
|
|
253
|
+
const phaseLabel = phaseNumber ? `Phase ${phaseNumber}` : 'Unspecified phase';
|
|
254
|
+
|
|
255
|
+
const result = writeManagedFile(
|
|
256
|
+
path.join(featureDir, 'clarifications.md'),
|
|
257
|
+
renderTemplate(config, 'clarifications.md', { featureName, phaseLabel }, io.cwd),
|
|
258
|
+
options
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
io.stdout(`Clarification artifact prepared for "${featureName}".`);
|
|
262
|
+
printResults([result], io);
|
|
263
|
+
}
|
|
89
264
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
for (const skill of skillsToCreate) {
|
|
104
|
-
const skillPath = path.join(globalSkillsDir, skill.name, 'SKILL.md');
|
|
105
|
-
const skillContent = `---\nname: ${skill.name}\ndescription: ${skill.desc}\n---\n\n# ${skill.name}\n${skill.desc}\n`;
|
|
106
|
-
writeFile(skillPath, skillContent);
|
|
265
|
+
function prepareImplementation(parsed, io) {
|
|
266
|
+
const { featureName, phaseNumber } = readFeatureAndPhaseArgs(parsed);
|
|
267
|
+
const { config } = loadConfig(io.cwd);
|
|
268
|
+
const options = writeOptions(parsed, config);
|
|
269
|
+
const featureDir = requireFeatureDir(io.cwd, config, featureName);
|
|
270
|
+
const phaseSpec = findPhaseSpec(featureDir, phaseNumber);
|
|
271
|
+
const previousMissing = [];
|
|
272
|
+
|
|
273
|
+
for (let index = 1; index < phaseNumber; index += 1) {
|
|
274
|
+
const outputPath = path.join(featureDir, 'outputs', `phase-${index}-output.md`);
|
|
275
|
+
if (!fs.existsSync(outputPath)) {
|
|
276
|
+
previousMissing.push(path.relative(featureDir, outputPath));
|
|
107
277
|
}
|
|
108
|
-
console.log(' ✓ Installed global Gemini skills in ~/.gemini/skills/');
|
|
109
|
-
} catch (e) {
|
|
110
|
-
// Ignore global home dir write errors if restricted
|
|
111
278
|
}
|
|
112
279
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
280
|
+
if (previousMissing.length > 0) {
|
|
281
|
+
throw new Error(`Cannot prepare Phase ${phaseNumber}; missing previous output(s): ${previousMissing.join(', ')}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const phaseTitle = readPhaseTitle(phaseSpec, phaseNumber);
|
|
285
|
+
const outputPath = path.join(featureDir, 'outputs', `phase-${phaseNumber}-output.md`);
|
|
286
|
+
const result = writeManagedFile(
|
|
287
|
+
outputPath,
|
|
288
|
+
renderTemplate(config, 'output-report.md', { phaseNumber, phaseTitle }, io.cwd),
|
|
289
|
+
options
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
io.stdout(`Implementation context validated for "${featureName}" Phase ${phaseNumber}.`);
|
|
293
|
+
io.stdout(`Phase spec: ${path.relative(io.cwd, phaseSpec)}`);
|
|
294
|
+
printResults([result], io);
|
|
122
295
|
}
|
|
123
296
|
|
|
124
|
-
function
|
|
125
|
-
const
|
|
126
|
-
|
|
297
|
+
function reviewFeature(parsed, io) {
|
|
298
|
+
const featureName = parsed.positionals[0];
|
|
299
|
+
if (!featureName) {
|
|
300
|
+
throw new Error('Please specify a feature name. Example: plankit review my-feature');
|
|
301
|
+
}
|
|
302
|
+
assertSafeName(featureName, 'feature name');
|
|
303
|
+
|
|
304
|
+
const { config } = loadConfig(io.cwd);
|
|
305
|
+
const featureDir = requireFeatureDir(io.cwd, config, featureName);
|
|
306
|
+
const testCommand = parsed.flags['test-command'] ?? config.verification.testCommand;
|
|
307
|
+
const buildCommand = parsed.flags['build-command'] ?? config.verification.buildCommand;
|
|
127
308
|
|
|
128
|
-
if (
|
|
129
|
-
|
|
309
|
+
if (!parsed.flags['skip-test'] && testCommand) {
|
|
310
|
+
runVerificationCommand(testCommand, io);
|
|
311
|
+
}
|
|
312
|
+
if (!parsed.flags['skip-build'] && buildCommand) {
|
|
313
|
+
runVerificationCommand(buildCommand, io);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
markReadmeArchived(path.join(featureDir, 'README.md'), parsed.dryRun);
|
|
317
|
+
moveFeatureToArchive(io.cwd, config, featureName, parsed.dryRun);
|
|
318
|
+
io.stdout(`Feature "${featureName}" reviewed and archived.`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function showStatus(parsed, io) {
|
|
322
|
+
const { config } = loadConfig(io.cwd);
|
|
323
|
+
const currentDir = safeJoin(io.cwd, config.artifactsDir, 'current');
|
|
324
|
+
const archivedDir = safeJoin(io.cwd, config.artifactsDir, 'archived');
|
|
325
|
+
const status = {
|
|
326
|
+
active: listDirectories(currentDir),
|
|
327
|
+
archived: listDirectories(archivedDir)
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
if (parsed.flags.json) {
|
|
331
|
+
io.stdout(JSON.stringify(status, null, 2));
|
|
130
332
|
return;
|
|
131
333
|
}
|
|
132
334
|
|
|
133
|
-
|
|
335
|
+
io.stdout('PlanKit Feature Status Summary:');
|
|
336
|
+
io.stdout('');
|
|
337
|
+
io.stdout(`ACTIVE FEATURES (${path.join(config.artifactsDir, 'current')}/):`);
|
|
338
|
+
io.stdout(status.active.length ? status.active.map((feature) => ` - ${feature}`).join('\n') : ' (none)');
|
|
339
|
+
io.stdout('');
|
|
340
|
+
io.stdout(`ARCHIVED FEATURES (${path.join(config.artifactsDir, 'archived')}/):`);
|
|
341
|
+
io.stdout(status.archived.length ? status.archived.map((feature) => ` - ${feature}`).join('\n') : ' (none)');
|
|
342
|
+
}
|
|
134
343
|
|
|
135
|
-
|
|
136
|
-
|
|
344
|
+
function archiveFeature(parsed, io) {
|
|
345
|
+
const featureName = parsed.positionals[0];
|
|
346
|
+
if (!featureName) {
|
|
347
|
+
throw new Error('Please specify a feature name. Example: plankit archive my-feature');
|
|
348
|
+
}
|
|
349
|
+
assertSafeName(featureName, 'feature name');
|
|
137
350
|
|
|
138
|
-
const
|
|
351
|
+
const { config } = loadConfig(io.cwd);
|
|
352
|
+
moveFeatureToArchive(io.cwd, config, featureName, parsed.dryRun);
|
|
353
|
+
io.stdout(`Feature "${featureName}" archived.`);
|
|
354
|
+
}
|
|
139
355
|
|
|
140
|
-
|
|
141
|
-
|
|
356
|
+
function moveFeatureToArchive(cwd, config, featureName, dryRun = false) {
|
|
357
|
+
const sourceDir = requireFeatureDir(cwd, config, featureName);
|
|
358
|
+
const targetDir = safeJoin(cwd, config.artifactsDir, 'archived', featureName);
|
|
142
359
|
|
|
143
|
-
|
|
360
|
+
if (fs.existsSync(targetDir)) {
|
|
361
|
+
throw new Error(`Archived feature "${featureName}" already exists.`);
|
|
362
|
+
}
|
|
144
363
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
364
|
+
if (!dryRun) {
|
|
365
|
+
ensureDir(path.dirname(targetDir));
|
|
366
|
+
fs.renameSync(sourceDir, targetDir);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
148
369
|
|
|
149
|
-
|
|
370
|
+
function requireFeatureDir(cwd, config, featureName) {
|
|
371
|
+
assertSafeName(featureName, 'feature name');
|
|
372
|
+
const featureDir = safeJoin(cwd, config.artifactsDir, 'current', featureName);
|
|
150
373
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
`;
|
|
374
|
+
if (!fs.existsSync(featureDir)) {
|
|
375
|
+
throw new Error(`Active feature "${featureName}" not found in ${path.join(config.artifactsDir, 'current')}/`);
|
|
376
|
+
}
|
|
155
377
|
|
|
156
|
-
|
|
378
|
+
return featureDir;
|
|
379
|
+
}
|
|
157
380
|
|
|
158
|
-
|
|
159
|
-
|
|
381
|
+
function findPhaseSpec(featureDir, phaseNumber) {
|
|
382
|
+
const phasesDir = path.join(featureDir, 'phases');
|
|
383
|
+
const prefix = `phase-${phaseNumber}-`;
|
|
384
|
+
const file = fs.readdirSync(phasesDir).find((name) => name.startsWith(prefix) && name.endsWith('.md'));
|
|
160
385
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
`;
|
|
386
|
+
if (!file) {
|
|
387
|
+
throw new Error(`Phase ${phaseNumber} spec not found under ${path.relative(process.cwd(), phasesDir)}`);
|
|
388
|
+
}
|
|
165
389
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
writeFile(path.join(featureDir, 'outputs', '.gitkeep'), '');
|
|
390
|
+
return path.join(phasesDir, file);
|
|
391
|
+
}
|
|
169
392
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
393
|
+
function readPhaseTitle(phaseSpec, phaseNumber) {
|
|
394
|
+
const firstLine = fs.readFileSync(phaseSpec, 'utf8').split(/\r?\n/, 1)[0];
|
|
395
|
+
return firstLine.replace(/^#\s*Phase\s+\d+:\s*/i, '') || `Phase ${phaseNumber}`;
|
|
173
396
|
}
|
|
174
397
|
|
|
175
|
-
function
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
398
|
+
function markReadmeArchived(readmePath, dryRun = false) {
|
|
399
|
+
if (!fs.existsSync(readmePath) || dryRun) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const content = fs.readFileSync(readmePath, 'utf8');
|
|
404
|
+
const updated = content.replace(/^# Feature: (.+?)(?: \[ARCHIVED\])?$/m, '# Feature: $1 [ARCHIVED]');
|
|
405
|
+
fs.writeFileSync(readmePath, updated, 'utf8');
|
|
406
|
+
}
|
|
179
407
|
|
|
180
|
-
|
|
408
|
+
function runVerificationCommand(command, io) {
|
|
409
|
+
io.stdout(`Running: ${command}`);
|
|
410
|
+
const result = spawnSync(command, {
|
|
411
|
+
cwd: io.cwd,
|
|
412
|
+
shell: true,
|
|
413
|
+
stdio: 'inherit'
|
|
414
|
+
});
|
|
181
415
|
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
416
|
+
if (result.status !== 0) {
|
|
417
|
+
throw new Error(`Verification command failed: ${command}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function installGlobalGeminiSkills(config, options) {
|
|
422
|
+
const globalSkillsDir = path.join(os.homedir(), '.gemini', 'skills');
|
|
423
|
+
|
|
424
|
+
return GEMINI_SKILL_ENTRIES.map(([name, description]) => writeManagedFile(
|
|
425
|
+
path.join(globalSkillsDir, name, 'SKILL.md'),
|
|
426
|
+
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n\n${description}\n\nSpecification: ${config.artifactsDir}/PLANKIT.md\n`,
|
|
427
|
+
options
|
|
428
|
+
));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function createFeatureState(featureName, phases) {
|
|
432
|
+
return {
|
|
433
|
+
feature: featureName,
|
|
434
|
+
status: 'active',
|
|
435
|
+
createdAt: new Date().toISOString(),
|
|
436
|
+
phases: phases.map((phase, index) => ({
|
|
437
|
+
number: index + 1,
|
|
438
|
+
title: phase.title,
|
|
439
|
+
specPath: `phases/${phaseFileName(index + 1, phase)}`,
|
|
440
|
+
outputPath: `outputs/phase-${index + 1}-output.md`,
|
|
441
|
+
status: 'pending'
|
|
442
|
+
}))
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function phaseFileName(phaseNumber, phase) {
|
|
447
|
+
return `phase-${phaseNumber}-${phase.slug}.md`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function readFeatureAndPhaseArgs(parsed, options = {}) {
|
|
451
|
+
const featureName = parsed.positionals[0];
|
|
452
|
+
const rawPhase = parsed.positionals[1] || parsed.flags.phase;
|
|
453
|
+
|
|
454
|
+
if (!featureName) {
|
|
455
|
+
throw new Error('Please specify a feature name.');
|
|
456
|
+
}
|
|
457
|
+
assertSafeName(featureName, 'feature name');
|
|
458
|
+
|
|
459
|
+
if (!rawPhase && options.phaseOptional) {
|
|
460
|
+
return { featureName, phaseNumber: null };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const phaseNumber = Number.parseInt(rawPhase, 10);
|
|
464
|
+
if (!Number.isInteger(phaseNumber) || phaseNumber <= 0) {
|
|
465
|
+
throw new Error('Please specify a positive phase number.');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { featureName, phaseNumber };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function resolveAgent(parsed, config, io, configPath = null) {
|
|
472
|
+
const fromFlag = parsed.flags.agent || parsed.flags.agents;
|
|
473
|
+
if (fromFlag) {
|
|
474
|
+
const parts = String(fromFlag).split(',').map((item) => item.trim()).filter(Boolean);
|
|
475
|
+
if (parts.length > 1) {
|
|
476
|
+
throw new Error(`Select a single agent, got ${parts.length}. Use --agent <name>. Valid agents: ${validAgentNames()}.`);
|
|
189
477
|
}
|
|
478
|
+
const agent = getAgent(parts[0]);
|
|
479
|
+
io.stdout(`Using agent from --agent flag: "${agent.id}".`);
|
|
480
|
+
return agent;
|
|
190
481
|
}
|
|
191
482
|
|
|
192
|
-
|
|
483
|
+
if (configPath) {
|
|
484
|
+
const configured = resolveConfiguredAgent(config);
|
|
485
|
+
if (configured) {
|
|
486
|
+
io.stdout(`Using configured agent "${configured.id}" from plan config.`);
|
|
487
|
+
return configured;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
193
490
|
|
|
194
|
-
if (
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
} else {
|
|
200
|
-
archivedFeatures.forEach(f => console.log(` - 🟢 ${f}`));
|
|
491
|
+
if (io.isTTY && io.promptChoice) {
|
|
492
|
+
const agent = await promptAgent(io);
|
|
493
|
+
if (agent) {
|
|
494
|
+
io.stdout(`Selected agent: "${agent.id}".`);
|
|
495
|
+
return agent;
|
|
201
496
|
}
|
|
202
497
|
}
|
|
498
|
+
|
|
499
|
+
const agent = getAgent(DEFAULT_AGENT);
|
|
500
|
+
io.stdout(`No agent selected — using default agent "${agent.id}". Pass --agent <name> to change it.`);
|
|
501
|
+
return agent;
|
|
203
502
|
}
|
|
204
503
|
|
|
205
|
-
function
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
504
|
+
async function promptAgent(io) {
|
|
505
|
+
const agents = listAgents();
|
|
506
|
+
const header = agents.map((agent, index) => (
|
|
507
|
+
` ${index + 1}. ${agent.label} — ${agent.description}`
|
|
508
|
+
)).join('\n');
|
|
509
|
+
const answer = await io.promptChoice(
|
|
510
|
+
`Select your coding agent:\n${header}\n\nEnter a number 1-${agents.length} or a name`,
|
|
511
|
+
agents.map((agent) => agent.id)
|
|
512
|
+
);
|
|
513
|
+
if (!answer) {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
const trimmed = answer.trim().toLowerCase();
|
|
517
|
+
const num = Number.parseInt(trimmed, 10);
|
|
518
|
+
if (Number.isInteger(num) && num >= 1 && num <= agents.length) {
|
|
519
|
+
return agents[num - 1];
|
|
520
|
+
}
|
|
521
|
+
return getAgent(trimmed) || null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function writeOptions(parsed, config) {
|
|
525
|
+
return {
|
|
526
|
+
force: Boolean(parsed.flags.force) || config.overwrite === 'force',
|
|
527
|
+
dryRun: Boolean(parsed.flags['dry-run'])
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function printResults(results, io) {
|
|
532
|
+
for (const result of results.filter(Boolean)) {
|
|
533
|
+
if (result.action === 'info') {
|
|
534
|
+
io.stdout(` ${'info'.padEnd(9)} ${path.relative(io.cwd, result.path) || result.path} (${result.message || ''})`);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (result.action === 'copied' && Array.isArray(result.copies)) {
|
|
538
|
+
io.stdout(` ${'copied'.padEnd(9)} ${path.relative(io.cwd, result.path) || result.path}/`);
|
|
539
|
+
for (const copy of result.copies.filter(Boolean)) {
|
|
540
|
+
io.stdout(` ${' '.padEnd(9)} ${path.relative(io.cwd, copy.path) || copy.path}`);
|
|
541
|
+
}
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
io.stdout(` ${result.action.padEnd(9)} ${path.relative(io.cwd, result.path) || result.path}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function parseArgs(args) {
|
|
549
|
+
const [command, ...rest] = args;
|
|
550
|
+
const flags = {};
|
|
551
|
+
const positionals = [];
|
|
552
|
+
|
|
553
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
554
|
+
const arg = rest[index];
|
|
209
555
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
556
|
+
if (!arg.startsWith('--')) {
|
|
557
|
+
positionals.push(arg);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const flag = arg.slice(2);
|
|
562
|
+
const [inlineName, inlineValue] = flag.split('=', 2);
|
|
563
|
+
if (inlineValue !== undefined) {
|
|
564
|
+
flags[inlineName] = inlineValue;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const next = rest[index + 1];
|
|
569
|
+
if (next && !next.startsWith('--')) {
|
|
570
|
+
flags[inlineName] = next;
|
|
571
|
+
index += 1;
|
|
572
|
+
} else {
|
|
573
|
+
flags[inlineName] = true;
|
|
574
|
+
}
|
|
213
575
|
}
|
|
214
576
|
|
|
215
|
-
|
|
216
|
-
|
|
577
|
+
return {
|
|
578
|
+
command,
|
|
579
|
+
flags,
|
|
580
|
+
positionals,
|
|
581
|
+
dryRun: Boolean(flags['dry-run'])
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function createIo(context) {
|
|
586
|
+
const input = context.input || process.stdin;
|
|
587
|
+
const output = context.output || process.stdout;
|
|
588
|
+
const io = {
|
|
589
|
+
cwd: context.cwd || process.cwd(),
|
|
590
|
+
stdout: context.stdout || ((message) => console.log(message)),
|
|
591
|
+
stderr: context.stderr || ((message) => console.error(message)),
|
|
592
|
+
exit: context.exit || ((code) => process.exit(code)),
|
|
593
|
+
isTTY: context.isTTY !== undefined ? context.isTTY : Boolean(output && output.isTTY)
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
if (context.promptResponses !== undefined || input && input.isTTY) {
|
|
597
|
+
io.promptChoice = makePromptChoice(input, io.stdout, context.promptResponses);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return io;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function makePromptChoice(input, write, promptResponses) {
|
|
604
|
+
if (Array.isArray(promptResponses)) {
|
|
605
|
+
let index = 0;
|
|
606
|
+
return (prompt) => {
|
|
607
|
+
write(prompt);
|
|
608
|
+
const answer = promptResponses[index];
|
|
609
|
+
index += 1;
|
|
610
|
+
return answer === undefined ? '' : String(answer);
|
|
611
|
+
};
|
|
612
|
+
}
|
|
217
613
|
|
|
218
|
-
|
|
614
|
+
return async (prompt) => {
|
|
615
|
+
write(prompt);
|
|
616
|
+
const readline = (await import('node:readline/promises')).default;
|
|
617
|
+
const rl = readline.createInterface({ input, output: process.stdout });
|
|
618
|
+
const answer = await rl.question('');
|
|
619
|
+
rl.close();
|
|
620
|
+
return answer;
|
|
621
|
+
};
|
|
219
622
|
}
|
|
220
623
|
|
|
221
|
-
function showHelp() {
|
|
222
|
-
|
|
624
|
+
function showHelp(io) {
|
|
625
|
+
io.stdout(`
|
|
223
626
|
PlanKit CLI — Command Suite for AI Coding Assistants
|
|
224
627
|
|
|
225
628
|
Usage:
|
|
226
|
-
|
|
629
|
+
plankit <command> [options]
|
|
227
630
|
|
|
228
631
|
Commands:
|
|
229
|
-
init
|
|
230
|
-
plan <feature
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
632
|
+
init Initialize PlanKit rules, config, templates, and artifact folders
|
|
633
|
+
plan <feature> [requirements] Create a feature workspace under artifacts/current/<feature>/
|
|
634
|
+
clarify <feature> [phase] Create a clarification artifact for a feature
|
|
635
|
+
implement <feature> <phase> Validate phase context and prepare phase output artifact
|
|
636
|
+
review <feature> Run verification and archive a completed feature
|
|
637
|
+
status [--json] Show active and archived features
|
|
638
|
+
archive <feature> Move a feature to archived without running verification
|
|
639
|
+
help Show this help message
|
|
640
|
+
|
|
641
|
+
Options:
|
|
642
|
+
--force Overwrite generated files where safe
|
|
643
|
+
--dry-run Show intended actions without writing
|
|
644
|
+
--agent <name> Select your coding agent during init
|
|
645
|
+
(opencode, gemini, codex, cursor; default: opencode)
|
|
646
|
+
--global-gemini-skills Also install Gemini skills under the user profile
|
|
647
|
+
--phases 3 Generate N default phases during plan
|
|
648
|
+
--phases "Design,Build,Test" Generate named phases during plan
|
|
649
|
+
--requirements "text" Set feature requirements during plan
|
|
650
|
+
--test-command "command" Override review test command
|
|
651
|
+
--build-command "command" Override review build command
|
|
652
|
+
--skip-test Skip review test command
|
|
653
|
+
--skip-build Skip review build command
|
|
234
654
|
`);
|
|
235
655
|
}
|