#!/usr/bin/env bun
/**
* lythoskill-project-cortex CLI
* Thin skill router — delegates to command modules.
*/
import { readdirSync } from 'node:fs';
import { loadConfig } from './config.js';
import { initWorkflow } from './commands/init.js';
import { createTask } from './commands/task.js';
import { createEpic } from './commands/epic.js';
import { createAdr } from './commands/adr.js';
import { listAll } from './commands/list.js';
import { showStats, showNextIds } from './commands/stats.js';
import { probeStatus } from './commands/probe.js';
import { showFlow } from './commands/flow.js';
import { moveTask, moveAdr, moveEpic } from './commands/move.js';
import { generateIndex, generateWikiIndex } from './generate-index.js';
import { createWiki } from './commands/wiki.js';
import {
findLinkedEpic,
checkEpicAdrCompletion,
} from './lib/coupling.js';
function printHelp(): void {
console.log(`📋 lythoskill-project-cortex — Project management CLI
Commands:
init Initialize cortex workflow directories
task "
" Create a new Task
task create "" Create a new Task (explicit)
task Task state transition (start/review/done/complete/suspend/resume/reject/terminate/archive)
epic "" --lane main|emergency [--override ""] [--skip-checklist ""]
Create a new Epic. --lane is required.
--override bypasses the lane-full guard (max 1 per lane).
--skip-checklist bypasses the 5-question prompt.
adr "" Create a new ADR
list List all tasks and epics
stats Show project statistics
next-id Display timestamp ID format example
index Generate INDEX.md and wiki/INDEX.md
index wiki Generate wiki/INDEX.md only
wiki "" Create a new Wiki entry [--category pattern|faq|lesson]
probe Check status consistency (dir vs Status History)
--suspicious Only report suspicious patterns (empty shells, stale, drift)
--include-completed-empty-shells Include empty shells in completed/terminal dirs
--include-completed-checklists Include checklist drift in completed tasks
flow Show kanban CFD — count, avg age, WIP limits
dispatch-trailers Parse last commit for trailers and dispatch follow-up (used by post-commit hook)
Task state machine:
task start Move task to in-progress
task review Move task to review
task done Move task to completed (must be in review)
task complete Move task to completed (any status; trailer-driven close)
task suspend Move task to suspended
task resume Move suspended task back to in-progress
task reject Move reviewed task back to in-progress (re-work)
task terminate Move task to terminated (any status)
task archive Move completed task to archived
(Legacy aliases also work: start, review, done, complete, suspend, resume, reject, terminate, archive)
ADR state machine:
adr accept Move ADR to accepted
adr reject Move ADR to rejected
adr supersede [--by ] Move ADR to superseded
Epic state machine:
epic done Move epic to done
epic suspend Move epic to suspended
epic resume Move suspended epic back to active
Examples:
lythoskill-project-cortex init
lythoskill-project-cortex task "Fix login bug"
lythoskill-project-cortex epic "User auth system"
lythoskill-project-cortex adr accept ADR-20260502234833756
lythoskill-project-cortex epic done EPIC-20260503010218940`);
}
function parseFlag(args: string[], name: string): string | undefined {
const idx = args.indexOf(name);
if (idx === -1) return undefined;
return args[idx + 1];
}
/** True when a flag is present (regardless of value). Used for `--skip-checklist` with optional reason. */
function hasFlag(args: string[], name: string): boolean {
return args.indexOf(name) !== -1;
}
/**
* Validate a required TASK / EPIC / ADR id arg. If missing, emit a HATEOAS-style
* error (problem + Usage + concrete Example + pointer to `cortex list`) and exit.
*
* Optional `hint` is appended for cases where two transition verbs share an ID
* shape but differ in semantics (e.g. `done` requires review while `complete`
* accepts any status).
*/
function requireDocId(
arg: string | undefined,
command: string,
kind: 'TASK' | 'EPIC' | 'ADR',
hint?: string,
): string {
if (arg) return arg;
const idLabel = `${kind}-ID`;
const exampleId = `${kind}-20260509121724330`;
console.error(`❌ ${idLabel} required for "${command}".
Usage: bunx @lythos/project-cortex ${command} <${idLabel}>
Example: bunx @lythos/project-cortex ${command} ${exampleId}
To list existing items: bunx @lythos/project-cortex list${hint ? `\n\n Note: ${hint}` : ''}`);
process.exit(1);
}
function handleTaskTransition(
verb: string,
taskId: string,
config: WorkflowConfig,
): void {
switch (verb) {
case 'start':
moveTask(taskId, 'in-progress', config, { note: 'Started' });
break;
case 'review':
moveTask(taskId, 'review', config, { note: 'Deliverables committed' });
break;
case 'done':
moveTask(
taskId,
'completed', config,
{ note: 'Done' },
);
break;
case 'complete':
moveTask(
taskId,
'completed', config,
{ allowAny: true, note: 'Closed via trailer' },
);
break;
case 'suspend':
moveTask(taskId, 'suspended', config, { note: 'Blocked' });
break;
case 'resume':
moveTask(taskId, 'in-progress', config, { note: 'Resumed' });
break;
case 'reject':
moveTask(taskId, 'in-progress', config, { note: 'Re-work required' });
break;
case 'terminate':
moveTask(taskId, 'terminated', config, { allowAny: true, note: 'Terminated' });
break;
case 'archive':
moveTask(taskId, 'archived', config, { allowAny: true, note: 'Archived' });
break;
default:
console.error(`❌ Unknown task verb: ${verb}
Supported verbs: start, review, done, complete, suspend, resume, reject, terminate, archive
Example: bunx @lythos/project-cortex task review TASK-20260509121724330`);
process.exit(1);
}
}
async function main(): Promise {
const config = loadConfig();
const command = process.argv[2];
const arg = process.argv[3];
const restArgs = process.argv.slice(4);
const allFlags = process.argv.slice(3);
// --help anywhere in the arg list triggers help (not treated as a positional arg)
if (allFlags.includes('--help') || allFlags.includes('-h')) {
printHelp();
process.exit(0);
}
switch (command) {
case 'init':
initWorkflow(config);
break;
case 'task': {
const TASK_VERBS = ['start', 'review', 'done', 'complete', 'suspend', 'resume', 'reject', 'terminate', 'archive', 'create'];
// Subcommand form: `task ` (state transition)
if (arg && TASK_VERBS.includes(arg) && arg !== 'create') {
const taskId = requireDocId(restArgs[0], `task ${arg}`, 'TASK',
arg === 'done' ? "'done' enforces review → completed. For trailer-driven any-status close, use 'complete'." :
arg === 'complete' ? "'complete' allows any current status (trailer-driven). Use 'done' for strict review → completed." :
undefined
);
handleTaskTransition(arg, taskId, config);
break;
}
// Create form: `task create ""` or `task ""` (legacy)
let title: string;
if (arg === 'create' && restArgs[0]) {
title = restArgs[0];
} else if (arg) {
title = arg;
} else {
console.error(`❌ Task title or subcommand required.
Create: bunx @lythos/project-cortex task ""
bunx @lythos/project-cortex task create ""
Transition: bunx @lythos/project-cortex task
(verbs: start, review, done, complete, suspend, resume, reject, terminate, archive)
Example: bunx @lythos/project-cortex task "Fix login redirect bug"
The title becomes the kebab-cased filename suffix; the new task lands
in cortex/tasks/01-backlog/. To list existing tasks:
bunx @lythos/project-cortex list`);
process.exit(1);
}
createTask(title, config);
break;
}
case 'epic': {
if (!arg) {
console.error(`❌ Epic title or subcommand required.
Create: bunx @lythos/project-cortex epic "" --lane main|emergency
Example: bunx @lythos/project-cortex epic "User auth system" --lane main
Subcommands (state machine):
epic done Move epic to done
epic suspend Move epic to suspended
epic resume Move suspended epic back to active
To list existing epics: bunx @lythos/project-cortex list`);
process.exit(1);
}
// Subcommand form: `epic `
if (arg === 'done' || arg === 'suspend' || arg === 'resume') {
const epicId = requireDocId(restArgs[0], `epic ${arg}`, 'EPIC');
if (arg === 'done') {
moveEpic(epicId, 'done', config, { note: 'Done' });
} else if (arg === 'suspend') {
moveEpic(epicId, 'suspended', config, { note: 'Suspended' });
} else {
moveEpic(epicId, 'active', config, { note: 'Resumed' });
}
break;
}
// Title form: create new epic.
// allFlags == process.argv.slice(3); arg (the title) is allFlags[0],
// so we look for flags starting at index 1.
const flagArgs = allFlags.slice(1);
const lane = parseFlag(flagArgs, '--lane');
const override = parseFlag(flagArgs, '--override');
const skipChecklistPresent = hasFlag(flagArgs, '--skip-checklist');
const skipChecklistReason = parseFlag(flagArgs, '--skip-checklist');
// If --skip-checklist is followed by another --flag (or nothing), treat reason as ''.
const skipChecklist = skipChecklistPresent
? (skipChecklistReason && !skipChecklistReason.startsWith('--') ? skipChecklistReason : '')
: undefined;
// Fire-and-await the async create flow; map any rejection to a non-zero exit.
createEpic(arg, config, { lane, override, skipChecklist }).catch(err => {
const errMsg = err instanceof Error ? err.message : String(err);
console.error(`❌ Epic creation failed: ${errMsg}
Common causes & fixes:
• Missing --lane: pass --lane main or --lane emergency
• Lane full (max 1 active per lane): close active epic first
(bunx @lythos/project-cortex epic done ) or pass
--override "" to bypass the guard
• Checklist required (interactive TTY): answer the 5 questions, or
pass --skip-checklist "" in non-interactive contexts
To see existing epics: bunx @lythos/project-cortex list`);
process.exit(1);
});
break;
}
case 'adr': {
if (!arg) {
console.error(`❌ ADR title or subcommand required.
Create: bunx @lythos/project-cortex adr ""
Example: bunx @lythos/project-cortex adr "Lock-step versioning across packages"
Subcommands (state machine):
adr accept Move ADR to accepted
adr reject Move ADR to rejected
adr supersede [--by ] Move ADR to superseded
To list existing ADRs: bunx @lythos/project-cortex list`);
process.exit(1);
}
if (arg === 'accept' || arg === 'reject' || arg === 'supersede') {
const adrId = requireDocId(restArgs[0], `adr ${arg}`, 'ADR');
if (arg === 'accept') {
moveAdr(adrId, 'accepted', config, { note: 'Accepted' });
// Reverse coupling: check if linked epic's ADRs are all accepted
const acceptedDir = `${config.adrDir}/${config.adrSubdirs.accepted}`;
const allAcceptedAdrs = readdirSync(acceptedDir).filter(f => f.startsWith(adrId));
if (allAcceptedAdrs.length > 0) {
const adrPath = `${acceptedDir}/${allAcceptedAdrs[0]}`;
const epicId = findLinkedEpic(adrPath);
if (epicId) {
const status = checkEpicAdrCompletion(epicId, {
proposedAdrDir: `${config.adrDir}/${config.adrSubdirs.proposed}`,
acceptedAdrDir: acceptedDir,
});
if (status.allAccepted) {
console.log(`🔗 All ${status.total} ADR(s) linked to ${epicId} are now accepted.`);
console.log(` Run: bunx @lythos/project-cortex epic done ${epicId}`);
} else if (status.proposedIds.length > 0) {
console.log(`🔗 Linked to ${epicId}: ${status.acceptedIds.length}/${status.total} ADRs accepted (${status.proposedIds.length} remaining: ${status.proposedIds.join(', ')})`);
}
}
}
} else if (arg === 'reject') {
moveAdr(adrId, 'rejected', config, { note: 'Rejected' });
} else {
const by = parseFlag(restArgs.slice(1), '--by');
const note = by ? `Superseded by ${by}` : 'Superseded';
moveAdr(adrId, 'superseded', config, { note });
}
break;
}
createAdr(arg, config);
break;
}
case 'list':
listAll(config);
break;
case 'stats':
showStats(config);
break;
case 'next-id':
showNextIds();
break;
case 'index':
if (arg === 'wiki') {
generateWikiIndex(config);
} else {
generateIndex(config);
generateWikiIndex(config);
}
break;
case 'wiki':
if (!arg) {
console.error(`❌ Wiki title required.
Usage: bunx @lythos/project-cortex wiki "" [--category pattern|faq|lesson]
Example: bunx @lythos/project-cortex wiki "Dormancy property test" --category pattern
Categories (default: pattern):
pattern Reusable solutions and conventions
faq Common questions
lesson Retrospectives and post-mortems
Index: cortex/wiki/INDEX.md is auto-regenerated. To rebuild manually:
bunx @lythos/project-cortex index wiki`);
process.exit(1);
}
{
const category = parseFlag(restArgs, '--category') || 'pattern';
createWiki(arg, config, category);
}
break;
case 'probe':
probeStatus(config, {
activeOnly: allFlags.includes('--active-only') || allFlags.includes('--suspicious'),
includeCompletedEmptyShells: allFlags.includes('--include-completed-empty-shells'),
includeCompletedChecklists: allFlags.includes('--include-completed-checklists'),
suspicious: allFlags.includes('--suspicious'),
});
break;
case 'flow':
showFlow(config);
break;
case 'start':
handleTaskTransition('start', requireDocId(arg, 'start', 'TASK'), config);
break;
case 'review':
handleTaskTransition('review', requireDocId(arg, 'review', 'TASK'), config);
break;
case 'done':
handleTaskTransition('done', requireDocId(arg, 'done', 'TASK', "'done' enforces review → completed. For trailer-driven any-status close, use 'complete'."), config);
break;
case 'complete':
handleTaskTransition('complete', requireDocId(arg, 'complete', 'TASK', "'complete' allows any current status (trailer-driven). Use 'done' for strict review → completed."), config);
break;
case 'suspend':
handleTaskTransition('suspend', requireDocId(arg, 'suspend', 'TASK'), config);
break;
case 'resume':
handleTaskTransition('resume', requireDocId(arg, 'resume', 'TASK'), config);
break;
case 'reject':
handleTaskTransition('reject', requireDocId(arg, 'reject', 'TASK'), config);
break;
case 'terminate':
handleTaskTransition('terminate', requireDocId(arg, 'terminate', 'TASK'), config);
break;
case 'archive':
handleTaskTransition('archive', requireDocId(arg, 'archive', 'TASK'), config);
break;
case 'dispatch-trailers':
{
const { dispatchTrailers } = await import('./hooks/dispatch.js')
const { spawnSync } = await import('node:child_process')
const msg = spawnSync('git', ['log', '-1', '--format=%B', 'HEAD'], { encoding: 'utf-8' }).stdout || ''
const sha = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { encoding: 'utf-8' }).stdout?.trim() || ''
dispatchTrailers(msg, sha, {
cortexCli: ['bunx', '@lythos/project-cortex'],
})
}
break
case '--help':
case '-h':
default:
printHelp();
if (command !== '--help' && command !== '-h') process.exit(1);
}
}
if (import.meta.main) {
main();
}