@ouro.bot/cli 0.1.0-alpha.157 → 0.1.0-alpha.158

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/changelog.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.158",
6
+ "changes": [
7
+ "Task scanner v2: explicit identity via kind: task field. Scanner only parses files that declare themselves as task cards — doing docs, planning docs, and artifacts silently skipped. Eliminates 184 false parse errors on real bundles.",
8
+ "Typed issue model: every scanner issue has a code, description, proposed fix, confidence (safe/needs_review), and category (live/migration). Replaces flat parseErrors/invalidFilenames arrays.",
9
+ "Board health line: compact board shows health: clean or health: 1 live, 10 migration. Live vs migration split prevents cleanup noise from looking like breakage.",
10
+ "Fix command: ouro task fix (dry-run), ouro task fix --safe (apply deterministic fixes), ouro task fix <id> (inspect/apply individual issues). Currently auto-fixes schema-missing-kind.",
11
+ "Cancelled status: new terminal state reachable from any active status. Auto-archives with work directory, hidden from active board view.",
12
+ "Derived child_tasks: computed at scan time from parent_task links. child_tasks removed from authored schema — no more hand-maintained stale arrays.",
13
+ "Work directory awareness: same-stem directories detected and listed on TaskFile (hasWorkDir, workDirFiles). Scanner never descends into them.",
14
+ "Collection root clutter detection: non-task support docs at collection root summarized as one aggregated migration issue per collection.",
15
+ "Root-only scanning: flat directory reads replace recursive walks. Faster and correct."
16
+ ]
17
+ },
4
18
  {
5
19
  "version": "0.1.0-alpha.157",
6
20
  "changes": [
@@ -305,6 +305,7 @@ function usage() {
305
305
  " ouro task create <title> [--type <type>] [--agent <name>]",
306
306
  " ouro task update <id> <status> [--agent <name>]",
307
307
  " ouro task show <id> [--agent <name>]",
308
+ " ouro task fix [--safe|--all] [<id> [--option <N>]] [--agent <name>]",
308
309
  " ouro task actionable|deps|sessions [--agent <name>]",
309
310
  " ouro reminder create <title> --body <body> [--at <iso>] [--cadence <interval>] [--category <category>] [--agent <name>]",
310
311
  " ouro friend list [--agent <name>]",
@@ -737,6 +738,31 @@ function parseTaskCommand(args) {
737
738
  return { kind: "task.deps", ...(agent ? { agent } : {}) };
738
739
  if (sub === "sessions")
739
740
  return { kind: "task.sessions", ...(agent ? { agent } : {}) };
741
+ if (sub === "fix") {
742
+ // fix --safe | fix --all | fix <id> [--option N] | fix (dry-run)
743
+ if (rest.length === 0)
744
+ return { kind: "task.fix", mode: "dry-run", ...(agent ? { agent } : {}) };
745
+ const first = rest[0];
746
+ if (first === "--safe" || first === "--all") {
747
+ return { kind: "task.fix", mode: "safe", ...(agent ? { agent } : {}) };
748
+ }
749
+ // first arg is an issue ID (contains a colon, e.g. schema-missing-kind:one-shots/foo.md)
750
+ const issueId = first;
751
+ let option;
752
+ for (let i = 1; i < rest.length; i++) {
753
+ if (rest[i] === "--option" && rest[i + 1]) {
754
+ option = parseInt(rest[i + 1], 10);
755
+ i += 1;
756
+ }
757
+ }
758
+ return {
759
+ kind: "task.fix",
760
+ mode: "single",
761
+ issueId,
762
+ ...(option !== undefined ? { option } : {}),
763
+ ...(agent ? { agent } : {}),
764
+ };
765
+ }
740
766
  throw new Error(`Usage\n${usage()}`);
741
767
  }
742
768
  function parseAuthCommand(args) {
@@ -1796,6 +1822,60 @@ function executeTaskCommand(command, taskMod) {
1796
1822
  const lines = taskMod.boardDeps();
1797
1823
  return lines.length > 0 ? lines.join("\n") : "no unresolved dependencies";
1798
1824
  }
1825
+ if (command.kind === "task.fix") {
1826
+ try {
1827
+ const fixOptions = {
1828
+ mode: command.mode,
1829
+ ...(command.issueId ? { issueId: command.issueId } : {}),
1830
+ ...(command.option !== undefined ? { option: command.option } : {}),
1831
+ };
1832
+ const result = taskMod.fix(fixOptions);
1833
+ if (command.mode === "dry-run") {
1834
+ if (result.remaining.length === 0) {
1835
+ return `task health: clean`;
1836
+ }
1837
+ const safeIssues = result.remaining.filter((i) => i.confidence === "safe");
1838
+ const reviewIssues = result.remaining.filter((i) => i.confidence === "needs_review");
1839
+ const lines = [`${result.remaining.length} issues found`];
1840
+ if (safeIssues.length > 0) {
1841
+ lines.push("", `safe fixes (${safeIssues.length}):`);
1842
+ for (const issue of safeIssues) {
1843
+ lines.push(` ${issue.code}:${issue.target} -- ${issue.description}`);
1844
+ }
1845
+ }
1846
+ if (reviewIssues.length > 0) {
1847
+ lines.push("", `needs review (${reviewIssues.length}):`);
1848
+ for (const issue of reviewIssues) {
1849
+ lines.push(` ${issue.code}:${issue.target} -- ${issue.description}`);
1850
+ }
1851
+ }
1852
+ lines.push("", `task health: ${result.health}`);
1853
+ return lines.join("\n");
1854
+ }
1855
+ // safe, single, or --all modes: show what was done
1856
+ const lines = [];
1857
+ if (result.applied.length > 0) {
1858
+ lines.push(`${result.applied.length} applied:`);
1859
+ for (const issue of result.applied) {
1860
+ lines.push(` ${issue.code}:${issue.target}`);
1861
+ }
1862
+ }
1863
+ if (result.remaining.length > 0) {
1864
+ lines.push(`${result.remaining.length} remaining:`);
1865
+ for (const issue of result.remaining) {
1866
+ lines.push(` ${issue.code}:${issue.target} -- ${issue.description}`);
1867
+ }
1868
+ }
1869
+ if (result.applied.length === 0 && result.remaining.length === 0) {
1870
+ lines.push("no issues");
1871
+ }
1872
+ lines.push(`task health: ${result.health}`);
1873
+ return lines.join("\n");
1874
+ }
1875
+ catch (error) {
1876
+ return `error: ${error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(error)}`;
1877
+ }
1878
+ }
1799
1879
  // command.kind === "task.sessions"
1800
1880
  const lines = taskMod.boardSessions();
1801
1881
  return lines.length > 0 ? lines.join("\n") : "no active sessions";
@@ -2580,7 +2660,7 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
2580
2660
  // ── task subcommands (local, no daemon socket needed) ──
2581
2661
  if (command.kind === "task.board" || command.kind === "task.create" || command.kind === "task.update" ||
2582
2662
  command.kind === "task.show" || command.kind === "task.actionable" || command.kind === "task.deps" ||
2583
- command.kind === "task.sessions") {
2663
+ command.kind === "task.sessions" || command.kind === "task.fix") {
2584
2664
  /* v8 ignore start -- production default: requires full identity setup @preserve */
2585
2665
  const taskMod = deps.taskModule ?? (0, tasks_1.getTaskModule)();
2586
2666
  /* v8 ignore stop */
@@ -162,6 +162,7 @@ exports.OURO_CLI_TRUST_MANIFEST = {
162
162
  "task actionable": "friend",
163
163
  "task deps": "friend",
164
164
  "task sessions": "friend",
165
+ "task fix": "friend",
165
166
  "friend list": "friend",
166
167
  "friend show": "friend",
167
168
  "friend create": "friend",
@@ -7,14 +7,21 @@ exports.boardDeps = boardDeps;
7
7
  exports.boardSessions = boardSessions;
8
8
  const runtime_1 = require("../../nerves/runtime");
9
9
  const transitions_1 = require("./transitions");
10
- const BOARD_STATUS_ORDER = [
10
+ const BOARD_ACTIVE_ORDER = [
11
11
  "blocked",
12
12
  "processing",
13
13
  "collaborating",
14
14
  "drafting",
15
15
  "validating",
16
16
  "paused",
17
+ ];
18
+ const BOARD_TERMINAL_ORDER = [
17
19
  "done",
20
+ "cancelled",
21
+ ];
22
+ const BOARD_STATUS_ORDER = [
23
+ ...BOARD_ACTIVE_ORDER,
24
+ ...BOARD_TERMINAL_ORDER,
18
25
  ];
19
26
  function compactName(task) {
20
27
  const suffix = task.stem.replace(/^\d{4}-\d{2}-\d{2}-\d{4}-/, "");
@@ -29,6 +36,7 @@ function groupByStatus(tasks) {
29
36
  paused: [],
30
37
  blocked: [],
31
38
  done: [],
39
+ cancelled: [],
32
40
  };
33
41
  for (const task of tasks) {
34
42
  grouped[task.status].push(compactName(task));
@@ -65,8 +73,20 @@ function activeBridgeLines(tasks) {
65
73
  .map((task) => `${task.stem} -> ${String(task.frontmatter.active_bridge).trim()}`)
66
74
  .sort();
67
75
  }
76
+ function buildHealthLine(issues) {
77
+ if (issues.length === 0)
78
+ return "health: clean";
79
+ const liveCount = issues.filter((i) => i.category === "live").length;
80
+ const migrationCount = issues.filter((i) => i.category === "migration").length;
81
+ const parts = [];
82
+ if (liveCount > 0)
83
+ parts.push(`${liveCount} live`);
84
+ if (migrationCount > 0)
85
+ parts.push(`${migrationCount} migration`);
86
+ return `health: ${parts.join(", ")}`;
87
+ }
68
88
  function actionRequired(index, byStatus) {
69
- const actions = [...index.parseErrors, ...index.invalidFilenames.map((filePath) => `bad filename: ${filePath}`)];
89
+ const actions = index.issues.map((issue) => `${issue.code}: ${issue.description} [${issue.target}]`);
70
90
  if (byStatus.blocked.length > 0) {
71
91
  actions.push(`blocked tasks: ${byStatus.blocked.join(", ")}`);
72
92
  }
@@ -83,14 +103,19 @@ function buildTaskBoard(index) {
83
103
  meta: { taskCount: index.tasks.length },
84
104
  });
85
105
  const byStatus = groupByStatus(index.tasks);
86
- const counts = transitions_1.TASK_VALID_STATUSES.map((status) => `${status}:${byStatus[status].length}`).join(" ");
106
+ const activeCounts = BOARD_ACTIVE_ORDER.map((status) => `${status}:${byStatus[status].length}`).join(" ");
87
107
  const processing = byStatus.processing.length > 0 ? `\n processing: ${byStatus.processing.join(", ")}` : "";
88
108
  const blocked = byStatus.blocked.length > 0 ? `\n blocked: ${byStatus.blocked.join(", ")}` : "";
89
- const compact = `[Tasks] ${counts}${processing}${blocked}`;
109
+ const terminalParts = BOARD_TERMINAL_ORDER
110
+ .filter((status) => byStatus[status].length > 0)
111
+ .map((status) => `${status}:${byStatus[status].length}`);
112
+ const terminalLine = terminalParts.length > 0 ? `\n terminal: ${terminalParts.join(" ")}` : "";
113
+ const healthLine = `\n ${buildHealthLine(index.issues)}`;
114
+ const compact = `[Tasks] ${activeCounts}${processing}${blocked}${terminalLine}${healthLine}`;
90
115
  const fullLines = [];
91
116
  for (const status of BOARD_STATUS_ORDER) {
92
117
  const names = byStatus[status];
93
- if (status === "done" && names.length === 0)
118
+ if ((status === "done" || status === "cancelled") && names.length === 0)
94
119
  continue;
95
120
  fullLines.push(`## ${status}`);
96
121
  fullLines.push(names.length > 0 ? names.map((name) => `- ${name}`).join("\n") : "- (none)");
@@ -114,6 +139,7 @@ function buildTaskBoard(index) {
114
139
  compact,
115
140
  full: fullLines.join("\n\n"),
116
141
  byStatus,
142
+ issues: index.issues,
117
143
  actionRequired: actionRequired(index, byStatus),
118
144
  unresolvedDependencies: unresolved,
119
145
  activeSessions: active,
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.addKindToLegacyCard = addKindToLegacyCard;
37
+ exports.applyFixes = applyFixes;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const runtime_1 = require("../../nerves/runtime");
41
+ const scanner_1 = require("./scanner");
42
+ const parser_1 = require("./parser");
43
+ function issueId(issue) {
44
+ return `${issue.code}:${issue.target}`;
45
+ }
46
+ function addKindToLegacyCard(root, issue) {
47
+ (0, runtime_1.emitNervesEvent)({
48
+ event: "repertoire.fix_apply_start",
49
+ component: "repertoire",
50
+ message: "adding kind: task to legacy card",
51
+ meta: { target: issue.target },
52
+ });
53
+ const filePath = path.join(root, issue.target);
54
+ if (!fs.existsSync(filePath))
55
+ return false;
56
+ const content = fs.readFileSync(filePath, "utf-8");
57
+ const frontmatter = (0, scanner_1.tryExtractFrontmatter)(content);
58
+ if (!frontmatter)
59
+ return false;
60
+ // Add kind: task to frontmatter
61
+ frontmatter.kind = "task";
62
+ // Extract body (everything after the second ---)
63
+ const lines = content.split(/\r?\n/);
64
+ const firstDelim = lines.findIndex((line) => line.trim() === "---");
65
+ const secondDelim = lines.findIndex((line, idx) => idx > firstDelim && line.trim() === "---");
66
+ const body = lines.slice(secondDelim + 1).join("\n").replace(/^\n/, "");
67
+ const rendered = (0, parser_1.renderTaskFile)(frontmatter, body);
68
+ fs.writeFileSync(filePath, rendered, "utf-8");
69
+ return true;
70
+ }
71
+ function applySingleFix(root, issue) {
72
+ switch (issue.code) {
73
+ case "schema-missing-kind":
74
+ return addKindToLegacyCard(root, issue);
75
+ default:
76
+ return false;
77
+ }
78
+ }
79
+ function applyFixes(options, root) {
80
+ (0, runtime_1.emitNervesEvent)({
81
+ event: "repertoire.fix_start",
82
+ component: "repertoire",
83
+ message: `applying fixes in ${options.mode} mode`,
84
+ meta: { mode: options.mode, root },
85
+ });
86
+ (0, scanner_1.clearTaskScanCache)();
87
+ const index = (0, scanner_1.scanTasks)(root);
88
+ const issues = index.issues;
89
+ if (options.mode === "dry-run") {
90
+ (0, runtime_1.emitNervesEvent)({
91
+ event: "repertoire.fix_complete",
92
+ component: "repertoire",
93
+ message: "dry-run complete, no changes made",
94
+ meta: { issueCount: issues.length },
95
+ });
96
+ const health = issues.length === 0 ? "clean" : buildHealthSummary(issues);
97
+ return {
98
+ applied: [],
99
+ remaining: issues,
100
+ skipped: [],
101
+ health,
102
+ };
103
+ }
104
+ if (options.mode === "single") {
105
+ const targetId = options.issueId ?? "";
106
+ const match = issues.find((i) => issueId(i) === targetId);
107
+ if (!match) {
108
+ (0, runtime_1.emitNervesEvent)({
109
+ event: "repertoire.fix_complete",
110
+ component: "repertoire",
111
+ message: "single fix: issue not found",
112
+ meta: { issueId: targetId },
113
+ });
114
+ const health = issues.length === 0 ? "clean" : buildHealthSummary(issues);
115
+ return {
116
+ applied: [],
117
+ remaining: issues,
118
+ skipped: [],
119
+ health,
120
+ };
121
+ }
122
+ const success = applySingleFix(root, match);
123
+ (0, scanner_1.clearTaskScanCache)();
124
+ const afterIndex = (0, scanner_1.scanTasks)(root);
125
+ const afterIssues = afterIndex.issues;
126
+ const health = afterIssues.length === 0 ? "clean" : buildHealthSummary(afterIssues);
127
+ (0, runtime_1.emitNervesEvent)({
128
+ event: "repertoire.fix_complete",
129
+ component: "repertoire",
130
+ message: `single fix ${success ? "applied" : "failed"}`,
131
+ meta: { issueId: targetId, success },
132
+ });
133
+ return {
134
+ applied: success ? [match] : [],
135
+ remaining: afterIssues,
136
+ skipped: success ? [] : [match],
137
+ health,
138
+ };
139
+ }
140
+ // safe mode: apply all safe-confidence fixes
141
+ const applied = [];
142
+ const skipped = [];
143
+ for (const issue of issues) {
144
+ if (issue.confidence !== "safe") {
145
+ skipped.push(issue);
146
+ continue;
147
+ }
148
+ const success = applySingleFix(root, issue);
149
+ if (success) {
150
+ applied.push(issue);
151
+ }
152
+ else {
153
+ skipped.push(issue);
154
+ }
155
+ }
156
+ (0, scanner_1.clearTaskScanCache)();
157
+ const afterIndex = (0, scanner_1.scanTasks)(root);
158
+ const afterIssues = afterIndex.issues;
159
+ const health = afterIssues.length === 0 ? "clean" : buildHealthSummary(afterIssues);
160
+ (0, runtime_1.emitNervesEvent)({
161
+ event: "repertoire.fix_complete",
162
+ component: "repertoire",
163
+ message: `safe fixes complete: ${applied.length} applied, ${skipped.length} skipped`,
164
+ meta: { applied: applied.length, skipped: skipped.length },
165
+ });
166
+ return {
167
+ applied,
168
+ remaining: afterIssues,
169
+ skipped,
170
+ health,
171
+ };
172
+ }
173
+ function buildHealthSummary(issues) {
174
+ const liveCount = issues.filter((i) => i.category === "live").length;
175
+ const migrationCount = issues.filter((i) => i.category === "migration").length;
176
+ const parts = [];
177
+ if (liveCount > 0)
178
+ parts.push(`${liveCount} live`);
179
+ if (migrationCount > 0)
180
+ parts.push(`${migrationCount} migration`);
181
+ return parts.join(", ");
182
+ }
@@ -40,6 +40,7 @@ const path = __importStar(require("path"));
40
40
  const config_1 = require("../../heart/config");
41
41
  const runtime_1 = require("../../nerves/runtime");
42
42
  const board_1 = require("./board");
43
+ const fix_1 = require("./fix");
43
44
  const lifecycle_1 = require("./lifecycle");
44
45
  const parser_1 = require("./parser");
45
46
  const scanner_1 = require("./scanner");
@@ -97,6 +98,7 @@ class FileTaskModule {
97
98
  const filePath = path.join(root, collection, filename);
98
99
  const today = formatDate();
99
100
  const frontmatter = {
101
+ kind: "task",
100
102
  type,
101
103
  category: input.category || "infrastructure",
102
104
  title: input.title,
@@ -108,7 +110,6 @@ class FileTaskModule {
108
110
  lastRun: input.lastRun ?? null,
109
111
  created: today,
110
112
  updated: today,
111
- child_tasks: [],
112
113
  artifacts: [],
113
114
  };
114
115
  if (type === "one-shot") {
@@ -192,6 +193,9 @@ class FileTaskModule {
192
193
  }
193
194
  return (0, middleware_1.validateSpawn)(task, spawnType);
194
195
  }
196
+ fix(options) {
197
+ return (0, fix_1.applyFixes)(options, (0, scanner_1.getTaskRoot)());
198
+ }
195
199
  detectStale(thresholdDays) {
196
200
  return (0, lifecycle_1.detectStaleTasks)(this.scan(), thresholdDays);
197
201
  }
@@ -48,7 +48,7 @@ function archiveCompletedTasks(index) {
48
48
  const archived = [];
49
49
  const failures = [];
50
50
  for (const task of index.tasks) {
51
- if (task.status !== "done")
51
+ if (task.status !== "done" && task.status !== "cancelled")
52
52
  continue;
53
53
  const archiveDir = path.join(index.root, "archive", task.collection);
54
54
  const archiveFile = path.join(archiveDir, task.name);
@@ -73,7 +73,7 @@ function detectStaleTasks(index, thresholdDays, now = new Date()) {
73
73
  const updated = Date.parse(task.updated);
74
74
  if (Number.isNaN(updated))
75
75
  return false;
76
- if (task.status === "done")
76
+ if (task.status === "done" || task.status === "cancelled")
77
77
  return false;
78
78
  return updated < staleCutoffMs;
79
79
  });
@@ -136,6 +136,9 @@ function parseTaskFile(content, filePath) {
136
136
  _isCanonicalFilename: (0, transitions_1.isCanonicalTaskFilename)(name),
137
137
  },
138
138
  body: parsed.body,
139
+ hasWorkDir: false,
140
+ workDirFiles: [],
141
+ derivedChildren: [],
139
142
  };
140
143
  }
141
144
  function formatFrontmatterValue(value) {
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.getTaskRoot = getTaskRoot;
37
37
  exports.ensureTaskLayout = ensureTaskLayout;
38
+ exports.tryExtractFrontmatter = tryExtractFrontmatter;
38
39
  exports.clearTaskScanCache = clearTaskScanCache;
39
40
  exports.scanTasks = scanTasks;
40
41
  const fs = __importStar(require("fs"));
@@ -59,30 +60,57 @@ function ensureTaskLayout(root = getTaskRoot()) {
59
60
  fs.mkdirSync(dir, { recursive: true });
60
61
  }
61
62
  }
62
- function walkMarkdownFiles(dir, acc) {
63
- if (!fs.existsSync(dir))
64
- return;
65
- const entries = fs.readdirSync(dir, { withFileTypes: true });
66
- for (const entry of entries) {
67
- if (entry.isDirectory()) {
68
- if (transitions_1.TASK_RESERVED_DIRECTORIES.includes(entry.name)) {
63
+ /**
64
+ * Attempt to extract frontmatter from markdown content.
65
+ * Returns parsed frontmatter dict or null. Never throws.
66
+ */
67
+ function tryExtractFrontmatter(content) {
68
+ (0, runtime_1.emitNervesEvent)({
69
+ event: "repertoire.frontmatter_extract_start",
70
+ component: "repertoire",
71
+ message: "attempting frontmatter extraction",
72
+ });
73
+ const lines = content.split(/\r?\n/);
74
+ if (lines[0]?.trim() !== "---") {
75
+ return null;
76
+ }
77
+ const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
78
+ if (closing === -1) {
79
+ return null;
80
+ }
81
+ const rawFrontmatter = lines.slice(1, closing).join("\n");
82
+ return (0, parser_1.parseFrontmatter)(rawFrontmatter);
83
+ }
84
+ const LEGACY_TASK_TYPES = ["one-shot", "ongoing", "habit"];
85
+ function buildFingerprint(root) {
86
+ (0, runtime_1.emitNervesEvent)({
87
+ event: "repertoire.fingerprint_build_start",
88
+ component: "repertoire",
89
+ message: "building scan fingerprint",
90
+ meta: { root },
91
+ });
92
+ const segments = [];
93
+ for (const collection of transitions_1.TASK_CANONICAL_COLLECTIONS) {
94
+ const collDir = path.join(root, collection);
95
+ const entries = fs.readdirSync(collDir, { withFileTypes: true });
96
+ for (const entry of entries) {
97
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
69
98
  continue;
70
- }
71
- walkMarkdownFiles(path.join(dir, entry.name), acc);
72
- continue;
73
- }
74
- if (entry.name.endsWith(".md")) {
75
- acc.push(path.join(dir, entry.name));
99
+ const filePath = path.join(collDir, entry.name);
100
+ const stat = fs.statSync(filePath);
101
+ segments.push(`${filePath}:${stat.mtimeMs}:${stat.size}`);
76
102
  }
77
103
  }
78
- }
79
- function buildFingerprint(paths) {
80
- const segments = paths
81
- .map((filePath) => {
104
+ // Also include root-level md files for orphan detection fingerprinting
105
+ const rootEntries = fs.readdirSync(root, { withFileTypes: true });
106
+ for (const entry of rootEntries) {
107
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
108
+ continue;
109
+ const filePath = path.join(root, entry.name);
82
110
  const stat = fs.statSync(filePath);
83
- return `${filePath}:${stat.mtimeMs}:${stat.size}`;
84
- })
85
- .sort();
111
+ segments.push(`${filePath}:${stat.mtimeMs}:${stat.size}`);
112
+ }
113
+ segments.sort();
86
114
  return segments.join("|");
87
115
  }
88
116
  function clearTaskScanCache() {
@@ -96,35 +124,164 @@ function scanTasks(root = getTaskRoot()) {
96
124
  meta: { root },
97
125
  });
98
126
  ensureTaskLayout(root);
99
- const files = [];
100
- for (const collection of transitions_1.TASK_CANONICAL_COLLECTIONS) {
101
- walkMarkdownFiles(path.join(root, collection), files);
102
- }
103
- const fingerprint = buildFingerprint(files);
127
+ const fingerprint = buildFingerprint(root);
104
128
  if (scanCache && scanCache.fingerprint === fingerprint) {
105
129
  return scanCache.index;
106
130
  }
107
131
  const tasks = [];
108
- const parseErrors = [];
109
- const invalidFilenames = [];
110
- for (const filePath of files) {
111
- const base = path.basename(filePath);
112
- if (!(0, transitions_1.isCanonicalTaskFilename)(base)) {
113
- invalidFilenames.push(filePath);
132
+ const issues = [];
133
+ // Scan each collection with flat directory reads (no recursion)
134
+ for (const collection of transitions_1.TASK_CANONICAL_COLLECTIONS) {
135
+ const collDir = path.join(root, collection);
136
+ const entries = fs.readdirSync(collDir, { withFileTypes: true });
137
+ const dirNames = new Set();
138
+ // Collect directory names for work dir detection
139
+ for (const entry of entries) {
140
+ if (entry.isDirectory()) {
141
+ dirNames.add(entry.name);
142
+ }
114
143
  }
115
- try {
144
+ // Process only .md files at collection root (flat, no recursion)
145
+ let supportDocCount = 0;
146
+ for (const entry of entries) {
147
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
148
+ continue;
149
+ const filePath = path.join(collDir, entry.name);
116
150
  const content = fs.readFileSync(filePath, "utf-8");
117
- tasks.push((0, parser_1.parseTaskFile)(content, filePath));
151
+ // Step 1: Try to extract frontmatter
152
+ const frontmatter = tryExtractFrontmatter(content);
153
+ if (!frontmatter) {
154
+ // No frontmatter — not a task, count as support doc clutter
155
+ supportDocCount += 1;
156
+ continue;
157
+ }
158
+ // Step 2: Check kind: task
159
+ const isKindTask = frontmatter.kind === "task";
160
+ const fmType = typeof frontmatter.type === "string" ? frontmatter.type.trim().toLowerCase() : "";
161
+ const isLegacyTask = !isKindTask && LEGACY_TASK_TYPES.includes(fmType);
162
+ if (!isKindTask && !isLegacyTask) {
163
+ // Has frontmatter but not a task — skip silently
164
+ continue;
165
+ }
166
+ // Step 3: Emit migration issue for legacy task
167
+ if (isLegacyTask) {
168
+ const relPath = path.relative(root, filePath);
169
+ issues.push({
170
+ target: relPath,
171
+ code: "schema-missing-kind",
172
+ description: "Task card missing kind: task field",
173
+ fix: "Add kind: task to frontmatter",
174
+ confidence: "safe",
175
+ category: "migration",
176
+ });
177
+ }
178
+ // Step 4: Parse the task file
179
+ try {
180
+ const task = (0, parser_1.parseTaskFile)(content, filePath);
181
+ // Check filename canonicality
182
+ const base = path.basename(filePath);
183
+ if (!(0, transitions_1.isCanonicalTaskFilename)(base)) {
184
+ const relPath = path.relative(root, filePath);
185
+ issues.push({
186
+ target: relPath,
187
+ code: "filename-not-canonical",
188
+ description: `Non-canonical filename: ${base}`,
189
+ fix: `Rename to canonical format (YYYY-MM-DD-HHMM-slug.md)`,
190
+ confidence: "safe",
191
+ category: "migration",
192
+ });
193
+ }
194
+ // Work dir detection
195
+ const stem = base.replace(/\.md$/i, "");
196
+ if (dirNames.has(stem)) {
197
+ task.hasWorkDir = true;
198
+ const workDirPath = path.join(collDir, stem);
199
+ task.workDirFiles = fs.readdirSync(workDirPath).sort();
200
+ }
201
+ tasks.push(task);
202
+ }
203
+ catch (error) {
204
+ // Parse error on a file we identified as a task — real issue
205
+ const relPath = path.relative(root, filePath);
206
+ issues.push({
207
+ target: relPath,
208
+ code: "schema-invalid",
209
+ description: `Parse error: ${error instanceof Error ? error.message : String(error)}`,
210
+ fix: "Fix the task card schema (ensure required fields: type, status, etc.)",
211
+ confidence: "needs_review",
212
+ category: "live",
213
+ });
214
+ }
215
+ }
216
+ // Summarize non-task support docs at collection root as one migration issue
217
+ if (supportDocCount > 0) {
218
+ (0, runtime_1.emitNervesEvent)({
219
+ event: "repertoire.support_docs_detected",
220
+ component: "repertoire",
221
+ message: "non-task support documents at collection root",
222
+ meta: { collection, count: supportDocCount },
223
+ });
224
+ issues.push({
225
+ target: collection,
226
+ code: "org-collection-root-clutter",
227
+ description: `${supportDocCount} non-task file${supportDocCount === 1 ? "" : "s"} at ${collection}/ root (doing docs, planning docs, etc.)`,
228
+ fix: "Move support docs into same-stem work directories for their parent tasks",
229
+ confidence: "needs_review",
230
+ category: "migration",
231
+ });
232
+ }
233
+ }
234
+ // Orphan detection: root-level .md files outside any canonical collection
235
+ const rootEntries = fs.readdirSync(root, { withFileTypes: true });
236
+ const collectionSet = new Set(transitions_1.TASK_CANONICAL_COLLECTIONS);
237
+ for (const entry of rootEntries) {
238
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
239
+ continue;
240
+ const filePath = path.join(root, entry.name);
241
+ const content = fs.readFileSync(filePath, "utf-8");
242
+ const frontmatter = tryExtractFrontmatter(content);
243
+ // Only flag as orphan if it has task-like frontmatter
244
+ if (!frontmatter)
245
+ continue;
246
+ const fmType = typeof frontmatter.type === "string" ? frontmatter.type.trim().toLowerCase() : "";
247
+ const hasTaskLikeContent = frontmatter.kind === "task" ||
248
+ LEGACY_TASK_TYPES.includes(fmType) ||
249
+ (typeof frontmatter.status === "string" && typeof frontmatter.title === "string");
250
+ if (hasTaskLikeContent) {
251
+ (0, runtime_1.emitNervesEvent)({
252
+ event: "repertoire.orphan_detected",
253
+ component: "repertoire",
254
+ message: "root-level orphan document detected",
255
+ meta: { filePath },
256
+ });
257
+ issues.push({
258
+ target: entry.name,
259
+ code: "org-root-level-doc",
260
+ description: `Root-level document outside any collection: ${entry.name}`,
261
+ fix: `Move to appropriate collection directory (${[...collectionSet].join(", ")})`,
262
+ confidence: "needs_review",
263
+ category: "migration",
264
+ });
118
265
  }
119
- catch (error) {
120
- parseErrors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`);
266
+ }
267
+ // Populate derivedChildren from parent_task links
268
+ const stemToTask = new Map();
269
+ for (const task of tasks) {
270
+ stemToTask.set(task.stem, task);
271
+ }
272
+ for (const task of tasks) {
273
+ const parentStem = typeof task.frontmatter.parent_task === "string"
274
+ ? task.frontmatter.parent_task.trim()
275
+ : "";
276
+ if (parentStem && stemToTask.has(parentStem)) {
277
+ const parent = stemToTask.get(parentStem);
278
+ parent.derivedChildren.push(task.stem);
121
279
  }
122
280
  }
123
281
  const index = {
124
282
  root,
125
283
  tasks,
126
- invalidFilenames,
127
- parseErrors,
284
+ issues,
128
285
  fingerprint,
129
286
  };
130
287
  scanCache = { fingerprint, index };
@@ -16,15 +16,17 @@ exports.TASK_VALID_STATUSES = [
16
16
  "paused",
17
17
  "blocked",
18
18
  "done",
19
+ "cancelled",
19
20
  ];
20
21
  exports.TASK_STATUS_TRANSITIONS = {
21
- drafting: ["processing", "collaborating"],
22
- processing: ["validating", "paused", "blocked"],
23
- validating: ["done", "processing", "collaborating"],
24
- collaborating: ["processing", "validating", "paused"],
25
- paused: ["processing", "blocked"],
26
- blocked: ["processing", "paused"],
22
+ drafting: ["processing", "collaborating", "cancelled"],
23
+ processing: ["validating", "paused", "blocked", "cancelled"],
24
+ validating: ["done", "processing", "collaborating", "cancelled"],
25
+ collaborating: ["processing", "validating", "paused", "cancelled"],
26
+ paused: ["processing", "blocked", "cancelled"],
27
+ blocked: ["processing", "paused", "cancelled"],
27
28
  done: [],
29
+ cancelled: [],
28
30
  };
29
31
  exports.TASK_CANONICAL_TYPES = [
30
32
  "one-shot",
@@ -42,6 +44,7 @@ exports.TASK_RESERVED_DIRECTORIES = ["templates", ".trash", "archive"];
42
44
  exports.TASK_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}-\d{4}-[a-z0-9][a-z0-9-]*\.md$/;
43
45
  exports.TASK_REQUIRED_TEMPLATE_FIELDS = {
44
46
  "one-shot": [
47
+ "kind",
45
48
  "type",
46
49
  "category",
47
50
  "title",
@@ -55,10 +58,10 @@ exports.TASK_REQUIRED_TEMPLATE_FIELDS = {
55
58
  "updated",
56
59
  "parent_task",
57
60
  "depends_on",
58
- "child_tasks",
59
61
  "artifacts",
60
62
  ],
61
63
  ongoing: [
64
+ "kind",
62
65
  "type",
63
66
  "category",
64
67
  "title",
@@ -70,7 +73,6 @@ exports.TASK_REQUIRED_TEMPLATE_FIELDS = {
70
73
  "lastRun",
71
74
  "created",
72
75
  "updated",
73
- "child_tasks",
74
76
  "artifacts",
75
77
  ],
76
78
  };
@@ -179,7 +179,9 @@ function emptyTaskBoard() {
179
179
  paused: [],
180
180
  blocked: [],
181
181
  done: [],
182
+ cancelled: [],
182
183
  },
184
+ issues: [],
183
185
  actionRequired: [],
184
186
  unresolvedDependencies: [],
185
187
  activeSessions: [],
@@ -37,7 +37,9 @@ function emptyTaskBoard() {
37
37
  paused: [],
38
38
  blocked: [],
39
39
  done: [],
40
+ cancelled: [],
40
41
  },
42
+ issues: [],
41
43
  actionRequired: [],
42
44
  unresolvedDependencies: [],
43
45
  activeSessions: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.157",
3
+ "version": "0.1.0-alpha.158",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",