@bantay/cli 0.2.0 → 0.3.1
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/package.json +1 -1
- package/src/aide/discovery.ts +88 -0
- package/src/aide/index.ts +9 -0
- package/src/cli.ts +44 -2
- package/src/commands/aide.ts +432 -48
- package/src/commands/check.ts +9 -4
- package/src/commands/diff.ts +387 -0
- package/src/commands/init.ts +38 -1
- package/src/commands/status.ts +9 -7
- package/src/commands/tasks.ts +220 -0
- package/src/export/claude.ts +8 -5
- package/src/export/codex.ts +5 -3
- package/src/export/cursor.ts +5 -3
- package/src/export/invariants.ts +7 -5
- package/src/generators/claude-commands.ts +61 -0
- package/src/templates/commands/bantay-check.md +58 -0
- package/src/templates/commands/bantay-interview.md +155 -0
- package/src/templates/commands/bantay-orchestrate.md +164 -0
- package/src/templates/commands/bantay-status.md +39 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diff.ts — bantay diff command
|
|
3
|
+
*
|
|
4
|
+
* Wraps bantay aide diff with entity type classification based on parent chain.
|
|
5
|
+
* bantay aide diff stays unchanged (raw structural output).
|
|
6
|
+
* bantay diff adds classification for human-readable output.
|
|
7
|
+
*
|
|
8
|
+
* @scenario sc_diff_classified
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from "fs";
|
|
12
|
+
import { readFile, readdir } from "fs/promises";
|
|
13
|
+
import { read, type AideTree } from "../aide";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Container parents that define entity types
|
|
17
|
+
*/
|
|
18
|
+
const PARENT_TYPE_MAP: Record<string, string> = {
|
|
19
|
+
cujs: "scenario",
|
|
20
|
+
invariants: "invariant",
|
|
21
|
+
constraints: "constraint",
|
|
22
|
+
foundations: "foundation",
|
|
23
|
+
wisdom: "wisdom",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const CUJ_PREFIX = "cuj_";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Classified change entry
|
|
30
|
+
*/
|
|
31
|
+
export interface ClassifiedChange {
|
|
32
|
+
action: "ADDED" | "MODIFIED" | "REMOVED";
|
|
33
|
+
type: string;
|
|
34
|
+
entity_id: string;
|
|
35
|
+
parent?: string;
|
|
36
|
+
from?: string;
|
|
37
|
+
to?: string;
|
|
38
|
+
relationship_type?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Result from diff command
|
|
43
|
+
*/
|
|
44
|
+
export interface DiffResult {
|
|
45
|
+
hasChanges: boolean;
|
|
46
|
+
changes: ClassifiedChange[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Discover aide file in directory
|
|
51
|
+
*/
|
|
52
|
+
async function discoverAideFile(cwd: string): Promise<string | null> {
|
|
53
|
+
try {
|
|
54
|
+
const files = await readdir(cwd);
|
|
55
|
+
const aideFiles = files.filter((f) => f.endsWith(".aide"));
|
|
56
|
+
if (aideFiles.length === 1) {
|
|
57
|
+
return `${cwd}/${aideFiles[0]}`;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get entity type by walking parent chain
|
|
67
|
+
*/
|
|
68
|
+
function getEntityTypeByParent(id: string, parent: string | undefined, tree: AideTree): string {
|
|
69
|
+
if (!parent) {
|
|
70
|
+
return "entity";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Direct mapping
|
|
74
|
+
if (parent in PARENT_TYPE_MAP) {
|
|
75
|
+
return PARENT_TYPE_MAP[parent];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// If parent starts with cuj_, child is a scenario
|
|
79
|
+
if (parent.startsWith(CUJ_PREFIX)) {
|
|
80
|
+
return "scenario";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Walk up the parent chain
|
|
84
|
+
const parentEntity = tree.entities[parent];
|
|
85
|
+
if (parentEntity && parentEntity.parent) {
|
|
86
|
+
return getEntityTypeByParent(id, parentEntity.parent, tree);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return "entity";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get entity type from parent ID (for removed entities)
|
|
94
|
+
*/
|
|
95
|
+
function getEntityTypeFromParentId(parentId: string): string {
|
|
96
|
+
if (parentId in PARENT_TYPE_MAP) {
|
|
97
|
+
return PARENT_TYPE_MAP[parentId];
|
|
98
|
+
}
|
|
99
|
+
if (parentId.startsWith(CUJ_PREFIX)) {
|
|
100
|
+
return "scenario";
|
|
101
|
+
}
|
|
102
|
+
return "entity";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fallback: get entity type from ID prefix
|
|
107
|
+
*/
|
|
108
|
+
function getEntityTypeByIdPrefix(id: string): string {
|
|
109
|
+
const prefixes: Record<string, string> = {
|
|
110
|
+
cuj_: "cuj",
|
|
111
|
+
sc_: "scenario",
|
|
112
|
+
inv_: "invariant",
|
|
113
|
+
con_: "constraint",
|
|
114
|
+
found_: "foundation",
|
|
115
|
+
wis_: "wisdom",
|
|
116
|
+
};
|
|
117
|
+
for (const [prefix, type] of Object.entries(prefixes)) {
|
|
118
|
+
if (id.startsWith(prefix)) {
|
|
119
|
+
return type;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return "entity";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Lock file entity with parent info
|
|
127
|
+
*/
|
|
128
|
+
interface LockFileEntity {
|
|
129
|
+
hash: string;
|
|
130
|
+
parent?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface LockFile {
|
|
134
|
+
entities: Record<string, LockFileEntity>;
|
|
135
|
+
relationships: string[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Parse lock file content
|
|
140
|
+
*/
|
|
141
|
+
function parseLockFile(content: string): LockFile {
|
|
142
|
+
const result: LockFile = {
|
|
143
|
+
entities: {},
|
|
144
|
+
relationships: [],
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
let section = "";
|
|
148
|
+
const lines = content.split("\n");
|
|
149
|
+
|
|
150
|
+
for (const line of lines) {
|
|
151
|
+
const trimmed = line.trim();
|
|
152
|
+
|
|
153
|
+
if (trimmed.startsWith("#") || trimmed === "") {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (trimmed === "entities:") {
|
|
158
|
+
section = "entities";
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (trimmed === "relationships:") {
|
|
162
|
+
section = "relationships";
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (section === "entities") {
|
|
167
|
+
const matchWithParent = trimmed.match(/^(\w+):\s*(\w+)\s+parent:(\w+)$/);
|
|
168
|
+
if (matchWithParent) {
|
|
169
|
+
result.entities[matchWithParent[1]] = {
|
|
170
|
+
hash: matchWithParent[2],
|
|
171
|
+
parent: matchWithParent[3],
|
|
172
|
+
};
|
|
173
|
+
} else {
|
|
174
|
+
const match = trimmed.match(/^(\w+):\s*(\w+)$/);
|
|
175
|
+
if (match) {
|
|
176
|
+
result.entities[match[1]] = {
|
|
177
|
+
hash: match[2],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} else if (section === "relationships") {
|
|
182
|
+
const match = trimmed.match(/^-\s*(\w+):(\w+):(\w+):\s*\w+$/);
|
|
183
|
+
if (match) {
|
|
184
|
+
result.relationships.push(`${match[1]}:${match[2]}:${match[3]}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Compute entity hash (same as aide.ts)
|
|
194
|
+
*/
|
|
195
|
+
function computeEntityHash(
|
|
196
|
+
id: string,
|
|
197
|
+
entity: { display?: string; parent?: string; props?: Record<string, unknown> }
|
|
198
|
+
): string {
|
|
199
|
+
const str = JSON.stringify({ id, ...entity });
|
|
200
|
+
let hash = 0;
|
|
201
|
+
for (let i = 0; i < str.length; i++) {
|
|
202
|
+
const char = str.charCodeAt(i);
|
|
203
|
+
hash = ((hash << 5) - hash) + char;
|
|
204
|
+
hash = hash & hash;
|
|
205
|
+
}
|
|
206
|
+
return Math.abs(hash).toString(16).padStart(8, "0");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Run diff and classify changes
|
|
211
|
+
*/
|
|
212
|
+
export async function runDiff(projectPath: string): Promise<DiffResult> {
|
|
213
|
+
const aidePath = await discoverAideFile(projectPath);
|
|
214
|
+
|
|
215
|
+
if (!aidePath) {
|
|
216
|
+
throw new Error("No .aide file found. Run 'bantay aide init' to create one.");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const lockPath = `${aidePath}.lock`;
|
|
220
|
+
|
|
221
|
+
if (!existsSync(lockPath)) {
|
|
222
|
+
throw new Error(`Lock file not found: ${lockPath}. Run 'bantay aide lock' first.`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Read current aide tree
|
|
226
|
+
const tree = await read(aidePath);
|
|
227
|
+
|
|
228
|
+
// Read and parse lock file
|
|
229
|
+
const lockContent = await readFile(lockPath, "utf-8");
|
|
230
|
+
const lock = parseLockFile(lockContent);
|
|
231
|
+
|
|
232
|
+
// Compute current hashes
|
|
233
|
+
const currentHashes: Record<string, string> = {};
|
|
234
|
+
for (const [id, entity] of Object.entries(tree.entities)) {
|
|
235
|
+
currentHashes[id] = computeEntityHash(id, entity);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Compute current relationships
|
|
239
|
+
const currentRelationships = new Set<string>();
|
|
240
|
+
for (const rel of tree.relationships) {
|
|
241
|
+
currentRelationships.add(`${rel.from}:${rel.to}:${rel.type}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const lockRelationships = new Set<string>(lock.relationships);
|
|
245
|
+
|
|
246
|
+
// Build classified changes
|
|
247
|
+
const changes: ClassifiedChange[] = [];
|
|
248
|
+
|
|
249
|
+
// Added entities
|
|
250
|
+
for (const id of Object.keys(currentHashes)) {
|
|
251
|
+
if (!(id in lock.entities)) {
|
|
252
|
+
const entity = tree.entities[id];
|
|
253
|
+
const parent = entity.parent;
|
|
254
|
+
const entityType = getEntityTypeByParent(id, parent, tree);
|
|
255
|
+
changes.push({
|
|
256
|
+
action: "ADDED",
|
|
257
|
+
type: entityType,
|
|
258
|
+
entity_id: id,
|
|
259
|
+
parent,
|
|
260
|
+
});
|
|
261
|
+
} else if (lock.entities[id].hash !== currentHashes[id]) {
|
|
262
|
+
const entity = tree.entities[id];
|
|
263
|
+
const parent = entity.parent;
|
|
264
|
+
const entityType = getEntityTypeByParent(id, parent, tree);
|
|
265
|
+
changes.push({
|
|
266
|
+
action: "MODIFIED",
|
|
267
|
+
type: entityType,
|
|
268
|
+
entity_id: id,
|
|
269
|
+
parent,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Removed entities
|
|
275
|
+
for (const id of Object.keys(lock.entities)) {
|
|
276
|
+
if (!(id in currentHashes)) {
|
|
277
|
+
const lockEntity = lock.entities[id];
|
|
278
|
+
let entityType: string;
|
|
279
|
+
if (lockEntity.parent) {
|
|
280
|
+
entityType = getEntityTypeFromParentId(lockEntity.parent);
|
|
281
|
+
} else {
|
|
282
|
+
entityType = getEntityTypeByIdPrefix(id);
|
|
283
|
+
}
|
|
284
|
+
changes.push({
|
|
285
|
+
action: "REMOVED",
|
|
286
|
+
type: entityType,
|
|
287
|
+
entity_id: id,
|
|
288
|
+
parent: lockEntity.parent,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Added relationships
|
|
294
|
+
for (const rel of currentRelationships) {
|
|
295
|
+
if (!lockRelationships.has(rel)) {
|
|
296
|
+
const [from, to, relType] = rel.split(":");
|
|
297
|
+
changes.push({
|
|
298
|
+
action: "ADDED",
|
|
299
|
+
type: "relationship",
|
|
300
|
+
entity_id: `${from}:${to}`,
|
|
301
|
+
from,
|
|
302
|
+
to,
|
|
303
|
+
relationship_type: relType,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Removed relationships
|
|
309
|
+
for (const rel of lockRelationships) {
|
|
310
|
+
if (!currentRelationships.has(rel)) {
|
|
311
|
+
const [from, to, relType] = rel.split(":");
|
|
312
|
+
changes.push({
|
|
313
|
+
action: "REMOVED",
|
|
314
|
+
type: "relationship",
|
|
315
|
+
entity_id: `${from}:${to}`,
|
|
316
|
+
from,
|
|
317
|
+
to,
|
|
318
|
+
relationship_type: relType,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
hasChanges: changes.length > 0,
|
|
325
|
+
changes,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Format diff result as human-readable output
|
|
331
|
+
*/
|
|
332
|
+
export function formatDiff(result: DiffResult): string {
|
|
333
|
+
if (!result.hasChanges) {
|
|
334
|
+
return "No changes since last lock.";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const lines: string[] = [];
|
|
338
|
+
|
|
339
|
+
// Sort by action then by entity_id
|
|
340
|
+
const sorted = [...result.changes].sort((a, b) => {
|
|
341
|
+
const actionOrder = { ADDED: 0, MODIFIED: 1, REMOVED: 2 };
|
|
342
|
+
const actionDiff = actionOrder[a.action] - actionOrder[b.action];
|
|
343
|
+
if (actionDiff !== 0) return actionDiff;
|
|
344
|
+
return a.entity_id.localeCompare(b.entity_id);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
for (const change of sorted) {
|
|
348
|
+
if (change.type === "relationship") {
|
|
349
|
+
lines.push(`${change.action} relationship: ${change.from} → ${change.to}`);
|
|
350
|
+
} else {
|
|
351
|
+
const parentStr = change.parent ? ` (parent: ${change.parent})` : "";
|
|
352
|
+
lines.push(`${change.action} ${change.type}: ${change.entity_id}${parentStr}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return lines.join("\n");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Format diff result as JSON
|
|
361
|
+
*/
|
|
362
|
+
export function formatDiffJson(result: DiffResult): string {
|
|
363
|
+
return JSON.stringify(result, null, 2);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Handle bantay diff command
|
|
368
|
+
*/
|
|
369
|
+
export async function handleDiff(args: string[]): Promise<void> {
|
|
370
|
+
const projectPath = process.cwd();
|
|
371
|
+
const jsonOutput = args.includes("--json");
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const result = await runDiff(projectPath);
|
|
375
|
+
|
|
376
|
+
if (jsonOutput) {
|
|
377
|
+
console.log(formatDiffJson(result));
|
|
378
|
+
} else {
|
|
379
|
+
console.log(formatDiff(result));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
process.exit(result.hasChanges ? 1 : 0);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
console.error(`Error: ${error instanceof Error ? error.message : error}`);
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
387
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import { writeFile, access } from "fs/promises";
|
|
1
|
+
import { writeFile, access, mkdir } from "fs/promises";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { detectStack, type StackDetectionResult } from "../detectors";
|
|
4
4
|
import { generateInvariants } from "../generators/invariants";
|
|
5
5
|
import { generateConfig, configToYaml } from "../generators/config";
|
|
6
|
+
import {
|
|
7
|
+
generateInterviewCommand,
|
|
8
|
+
generateStatusCommand,
|
|
9
|
+
generateCheckCommand,
|
|
10
|
+
generateOrchestrateCommand,
|
|
11
|
+
} from "../generators/claude-commands";
|
|
6
12
|
|
|
7
13
|
export interface InitOptions {
|
|
8
14
|
regenerateConfig?: boolean;
|
|
15
|
+
force?: boolean;
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
export interface InitResult {
|
|
@@ -66,6 +73,36 @@ export async function runInit(
|
|
|
66
73
|
filesCreated.push("bantay.config.yml");
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
// Generate Claude Code slash commands
|
|
77
|
+
const claudeCommandsDir = join(projectPath, ".claude", "commands");
|
|
78
|
+
await mkdir(claudeCommandsDir, { recursive: true });
|
|
79
|
+
|
|
80
|
+
const interviewPath = join(claudeCommandsDir, "bantay-interview.md");
|
|
81
|
+
const statusPath = join(claudeCommandsDir, "bantay-status.md");
|
|
82
|
+
const checkPath = join(claudeCommandsDir, "bantay-check.md");
|
|
83
|
+
const orchestratePath = join(claudeCommandsDir, "bantay-orchestrate.md");
|
|
84
|
+
|
|
85
|
+
// Only create if they don't exist (don't overwrite user customizations) unless --force
|
|
86
|
+
if (options?.force || !(await fileExists(interviewPath))) {
|
|
87
|
+
await writeFile(interviewPath, generateInterviewCommand());
|
|
88
|
+
filesCreated.push(".claude/commands/bantay-interview.md");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (options?.force || !(await fileExists(statusPath))) {
|
|
92
|
+
await writeFile(statusPath, generateStatusCommand());
|
|
93
|
+
filesCreated.push(".claude/commands/bantay-status.md");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (options?.force || !(await fileExists(checkPath))) {
|
|
97
|
+
await writeFile(checkPath, generateCheckCommand());
|
|
98
|
+
filesCreated.push(".claude/commands/bantay-check.md");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (options?.force || !(await fileExists(orchestratePath))) {
|
|
102
|
+
await writeFile(orchestratePath, generateOrchestrateCommand());
|
|
103
|
+
filesCreated.push(".claude/commands/bantay-orchestrate.md");
|
|
104
|
+
}
|
|
105
|
+
|
|
69
106
|
return {
|
|
70
107
|
success: true,
|
|
71
108
|
filesCreated,
|
package/src/commands/status.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile, readdir, access } from "fs/promises";
|
|
2
2
|
import { join, relative } from "path";
|
|
3
3
|
import * as yaml from "js-yaml";
|
|
4
|
+
import { tryResolveAidePath } from "../aide";
|
|
4
5
|
|
|
5
6
|
export interface StatusOptions {
|
|
6
7
|
json?: boolean;
|
|
@@ -146,17 +147,18 @@ export async function runStatus(
|
|
|
146
147
|
projectPath: string,
|
|
147
148
|
options?: StatusOptions
|
|
148
149
|
): Promise<StatusResult> {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (!(await fileExists(aidePath))) {
|
|
150
|
+
// Discover aide file
|
|
151
|
+
const resolved = await tryResolveAidePath(projectPath);
|
|
152
|
+
if (!resolved) {
|
|
153
153
|
return {
|
|
154
154
|
scenarios: [],
|
|
155
155
|
summary: { total: 0, implemented: 0, missing: 0 },
|
|
156
|
-
error: "
|
|
156
|
+
error: "No .aide file found. Run 'bantay aide init' to create one.",
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
const aidePath = resolved.path;
|
|
161
|
+
|
|
160
162
|
// Parse aide file
|
|
161
163
|
let aideContent: AideFile;
|
|
162
164
|
try {
|
|
@@ -166,7 +168,7 @@ export async function runStatus(
|
|
|
166
168
|
return {
|
|
167
169
|
scenarios: [],
|
|
168
170
|
summary: { total: 0, implemented: 0, missing: 0 },
|
|
169
|
-
error: `Failed to parse
|
|
171
|
+
error: `Failed to parse ${resolved.filename}: ${e instanceof Error ? e.message : String(e)}`,
|
|
170
172
|
};
|
|
171
173
|
}
|
|
172
174
|
|
|
@@ -174,7 +176,7 @@ export async function runStatus(
|
|
|
174
176
|
return {
|
|
175
177
|
scenarios: [],
|
|
176
178
|
summary: { total: 0, implemented: 0, missing: 0 },
|
|
177
|
-
error:
|
|
179
|
+
error: `${resolved.filename} has no entities`,
|
|
178
180
|
};
|
|
179
181
|
}
|
|
180
182
|
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { readFile, writeFile } from "fs/promises";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import * as yaml from "js-yaml";
|
|
5
|
+
import { resolveAidePath } from "../aide/discovery";
|
|
6
|
+
|
|
7
|
+
interface Entity {
|
|
8
|
+
id: string;
|
|
9
|
+
parent?: string;
|
|
10
|
+
display?: string;
|
|
11
|
+
props?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Relationship {
|
|
15
|
+
from: string;
|
|
16
|
+
to: string;
|
|
17
|
+
type: string;
|
|
18
|
+
cardinality?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface AideData {
|
|
22
|
+
entities: Record<string, Entity>;
|
|
23
|
+
relationships: Relationship[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface CUJ {
|
|
27
|
+
id: string;
|
|
28
|
+
feature: string;
|
|
29
|
+
tier?: string;
|
|
30
|
+
area?: string;
|
|
31
|
+
scenarios: Scenario[];
|
|
32
|
+
dependsOn: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Scenario {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
given?: string;
|
|
39
|
+
when?: string;
|
|
40
|
+
then?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface TasksOptions {
|
|
44
|
+
all?: boolean;
|
|
45
|
+
aide?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function runTasks(
|
|
49
|
+
projectPath: string,
|
|
50
|
+
options: TasksOptions = {}
|
|
51
|
+
): Promise<{ outputPath: string; cujs: CUJ[] }> {
|
|
52
|
+
// Resolve aide file path
|
|
53
|
+
const { path: aidePath, filename } = await resolveAidePath(
|
|
54
|
+
projectPath,
|
|
55
|
+
options.aide
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (!existsSync(aidePath)) {
|
|
59
|
+
throw new Error("No .aide file found. Run 'bantay aide init' to create one.");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Parse the aide file
|
|
63
|
+
const aideContent = await readFile(aidePath, "utf-8");
|
|
64
|
+
const aideData = yaml.load(aideContent) as AideData;
|
|
65
|
+
|
|
66
|
+
// Get CUJs to process
|
|
67
|
+
let cujsToProcess: string[];
|
|
68
|
+
|
|
69
|
+
if (options.all) {
|
|
70
|
+
// All CUJs
|
|
71
|
+
cujsToProcess = Object.keys(aideData.entities).filter((id) =>
|
|
72
|
+
id.startsWith("cuj_")
|
|
73
|
+
);
|
|
74
|
+
} else {
|
|
75
|
+
// Diff mode - only added/modified CUJs
|
|
76
|
+
const lockPath = aidePath + ".lock";
|
|
77
|
+
if (!existsSync(lockPath)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"No lock file found. Run 'bantay aide lock' first, or use --all for full generation."
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const lockContent = await readFile(lockPath, "utf-8");
|
|
84
|
+
const lockData = yaml.load(lockContent) as { entities: Record<string, string> };
|
|
85
|
+
|
|
86
|
+
// Find CUJs that are new or modified
|
|
87
|
+
cujsToProcess = Object.keys(aideData.entities).filter((id) => {
|
|
88
|
+
if (!id.startsWith("cuj_")) return false;
|
|
89
|
+
// CUJ is new if not in lock file
|
|
90
|
+
if (!lockData.entities[id]) return true;
|
|
91
|
+
// CUJ is modified if hash differs (we'd need to compute hash, but for now check existence)
|
|
92
|
+
return false;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build CUJ objects with scenarios and dependencies
|
|
97
|
+
const cujs: CUJ[] = cujsToProcess.map((id) => {
|
|
98
|
+
const entity = aideData.entities[id];
|
|
99
|
+
const props = entity.props || {};
|
|
100
|
+
|
|
101
|
+
// Find scenarios that are children of this CUJ
|
|
102
|
+
const scenarios: Scenario[] = Object.entries(aideData.entities)
|
|
103
|
+
.filter(([scId, scEntity]) =>
|
|
104
|
+
scId.startsWith("sc_") && scEntity.parent === id
|
|
105
|
+
)
|
|
106
|
+
.map(([scId, scEntity]) => ({
|
|
107
|
+
id: scId,
|
|
108
|
+
name: scEntity.props?.name || scId,
|
|
109
|
+
given: scEntity.props?.given,
|
|
110
|
+
when: scEntity.props?.when,
|
|
111
|
+
then: scEntity.props?.then,
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
// Find dependencies (depends_on relationships where this CUJ is the 'from')
|
|
115
|
+
const dependsOn: string[] = aideData.relationships
|
|
116
|
+
.filter((rel) => rel.from === id && rel.type === "depends_on")
|
|
117
|
+
.map((rel) => rel.to);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
id,
|
|
121
|
+
feature: props.feature || id,
|
|
122
|
+
tier: props.tier,
|
|
123
|
+
area: props.area,
|
|
124
|
+
scenarios,
|
|
125
|
+
dependsOn,
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Order CUJs into phases using topological sort
|
|
130
|
+
const phases = topologicalSort(cujs);
|
|
131
|
+
|
|
132
|
+
// Generate tasks.md content
|
|
133
|
+
const content = generateTasksMarkdown(phases);
|
|
134
|
+
|
|
135
|
+
// Write to tasks.md
|
|
136
|
+
const outputPath = join(projectPath, "tasks.md");
|
|
137
|
+
await writeFile(outputPath, content, "utf-8");
|
|
138
|
+
|
|
139
|
+
return { outputPath, cujs };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function topologicalSort(cujs: CUJ[]): CUJ[][] {
|
|
143
|
+
const cujMap = new Map(cujs.map((c) => [c.id, c]));
|
|
144
|
+
const phases: CUJ[][] = [];
|
|
145
|
+
const processed = new Set<string>();
|
|
146
|
+
|
|
147
|
+
// Keep going until all CUJs are processed
|
|
148
|
+
while (processed.size < cujs.length) {
|
|
149
|
+
const phase: CUJ[] = [];
|
|
150
|
+
|
|
151
|
+
for (const cuj of cujs) {
|
|
152
|
+
if (processed.has(cuj.id)) continue;
|
|
153
|
+
|
|
154
|
+
// Check if all dependencies are already processed
|
|
155
|
+
const allDepsProcessed = cuj.dependsOn.every(
|
|
156
|
+
(dep) => !cujMap.has(dep) || processed.has(dep)
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (allDepsProcessed) {
|
|
160
|
+
phase.push(cuj);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (phase.length === 0 && processed.size < cujs.length) {
|
|
165
|
+
// Circular dependency - just add remaining
|
|
166
|
+
for (const cuj of cujs) {
|
|
167
|
+
if (!processed.has(cuj.id)) {
|
|
168
|
+
phase.push(cuj);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const cuj of phase) {
|
|
174
|
+
processed.add(cuj.id);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (phase.length > 0) {
|
|
178
|
+
phases.push(phase);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return phases;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function generateTasksMarkdown(phases: CUJ[][]): string {
|
|
186
|
+
const lines: string[] = ["# Tasks", ""];
|
|
187
|
+
|
|
188
|
+
for (let i = 0; i < phases.length; i++) {
|
|
189
|
+
const phase = phases[i];
|
|
190
|
+
lines.push(`## Phase ${i + 1}`);
|
|
191
|
+
lines.push("");
|
|
192
|
+
|
|
193
|
+
for (const cuj of phase) {
|
|
194
|
+
lines.push(`### ${cuj.id}: ${cuj.feature}`);
|
|
195
|
+
lines.push("");
|
|
196
|
+
lines.push(`- [ ] Implement ${cuj.feature}`);
|
|
197
|
+
lines.push("");
|
|
198
|
+
|
|
199
|
+
if (cuj.scenarios.length > 0) {
|
|
200
|
+
lines.push("**Acceptance Criteria:**");
|
|
201
|
+
lines.push("");
|
|
202
|
+
for (const sc of cuj.scenarios) {
|
|
203
|
+
lines.push(`- [ ] ${sc.name}`);
|
|
204
|
+
if (sc.given || sc.when || sc.then) {
|
|
205
|
+
if (sc.given) lines.push(` - Given: ${sc.given}`);
|
|
206
|
+
if (sc.when) lines.push(` - When: ${sc.when}`);
|
|
207
|
+
if (sc.then) lines.push(` - Then: ${sc.then}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
lines.push("");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return lines.join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function formatTasks(result: { outputPath: string; cujs: CUJ[] }): string {
|
|
219
|
+
return `Generated ${result.outputPath} with ${result.cujs.length} tasks`;
|
|
220
|
+
}
|