dw-kit 1.9.0-rc.1 → 1.9.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.
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import { parseFrontmatter, stringifyFrontmatter } from '../lib/frontmatter.mjs';
5
5
  import { logEvent } from '../lib/telemetry.mjs';
6
+ import { bareSchemaVersion } from '../lib/task-store.mjs';
6
7
 
7
8
  const TASKS_DIR = '.dw/tasks';
8
9
 
@@ -72,7 +73,7 @@ function mergeFrontmatter(specFm, trackingFm) {
72
73
  depth: specFm.depth || 'standard',
73
74
  related_adr: String(specFm.related_adr || 'none').match(/^(ADR-\d{4}|none)$/) ? specFm.related_adr : 'none',
74
75
  target_ship: specFm.target_ship || 'TBD',
75
- schema_version: 'v3.0',
76
+ schema_version: 'task@v3.0',
76
77
  blockers: trackingFm.blockers || 'none',
77
78
  };
78
79
  }
@@ -308,7 +309,7 @@ function findV3Tasks(rootDir, targetSchemaVersion = 'v3.0') {
308
309
  const taskFile = join(e.path, 'task.md');
309
310
  if (!existsSync(taskFile)) return false;
310
311
  const fm = parseFrontmatter(readFileSync(taskFile, 'utf8'));
311
- return fm.schema_version === targetSchemaVersion;
312
+ return bareSchemaVersion(fm.schema_version) === bareSchemaVersion(targetSchemaVersion);
312
313
  } catch { return false; }
313
314
  });
314
315
  }
@@ -321,16 +322,16 @@ async function migrateOneToV31(taskDir, opts) {
321
322
  }
322
323
  const content = readFileSync(taskFile, 'utf8');
323
324
  const fm = parseFrontmatter(content);
324
- if (fm.schema_version === 'v3.1') {
325
+ if (bareSchemaVersion(fm.schema_version) === 'v3.1') {
325
326
  console.log(chalk.dim(` · ${taskDir} — already v3.1`));
326
327
  return { ok: true, noop: true };
327
328
  }
328
- if (fm.schema_version !== 'v3.0') {
329
+ if (bareSchemaVersion(fm.schema_version) !== 'v3.0') {
329
330
  console.log(chalk.yellow(` ⚠ ${taskDir} — schema_version=${fm.schema_version || 'missing'}, expected v3.0 — skipping`));
330
331
  return { ok: false, skipped: true };
331
332
  }
332
333
 
333
- const updated = { ...fm, schema_version: 'v3.1' };
334
+ const updated = { ...fm, schema_version: 'task@v3.1' };
334
335
  if (!('parent_goal_id' in updated)) updated.parent_goal_id = null;
335
336
  if (!('contributing_goal_ids' in updated)) updated.contributing_goal_ids = [];
336
337
  if (!('summary' in updated)) updated.summary = null;
@@ -457,6 +458,16 @@ export async function taskMigrateCommand(taskName, opts = {}) {
457
458
  dry_run: !!opts.dryRun,
458
459
  }, rootDir);
459
460
 
461
+ // Keep tasks-index@v1 current after a real migration (ADR-0017 doc contract).
462
+ if (!opts.dryRun && !opts.diff) {
463
+ try {
464
+ const { syncTaskIndexEntry } = await import('../lib/task-store.mjs');
465
+ for (const r of results) {
466
+ if (r.ok && !r.noop && !r.skipped) syncTaskIndexEntry(r.name, rootDir);
467
+ }
468
+ } catch { /* best-effort; `dw task index` rebuilds authoritatively */ }
469
+ }
470
+
460
471
  console.log();
461
472
  if (opts.dryRun) {
462
473
  console.log(chalk.cyan(` Dry-run complete. Re-run without --dry-run to apply.`));
@@ -76,6 +76,12 @@ export async function taskNewCommand(taskName, opts = {}) {
76
76
  const target = join(taskDir, 'task.md');
77
77
  writeFileSync(target, filled, 'utf8');
78
78
 
79
+ // Keep tasks-index@v1 current on creation (ADR-0017 doc contract).
80
+ try {
81
+ const { syncTaskIndexEntry } = await import('../lib/task-store.mjs');
82
+ syncTaskIndexEntry(slug, rootDir);
83
+ } catch { /* index is best-effort here; `dw task index` rebuilds authoritatively */ }
84
+
79
85
  logEvent({ event: 'task', action: 'new', name: slug, depth }, rootDir);
80
86
 
81
87
  console.log();
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import { parseFrontmatter, stringifyFrontmatter } from '../lib/frontmatter.mjs';
5
5
  import { logEvent } from '../lib/telemetry.mjs';
6
+ import { bareSchemaVersion } from '../lib/task-store.mjs';
6
7
 
7
8
  const TASKS_DIR = '.dw/tasks';
8
9
  const MAX_SUMMARY_CHARS = 1000;
@@ -38,7 +39,7 @@ export async function taskSummaryCommand(taskName, opts = {}) {
38
39
  process.exit(1);
39
40
  }
40
41
  const updated = { ...fm, summary: opts.write || null, last_updated: todayIso() };
41
- if (updated.schema_version === 'v3.0') updated.schema_version = 'v3.1';
42
+ if (bareSchemaVersion(updated.schema_version) === 'v3.0') updated.schema_version = 'task@v3.1';
42
43
  const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
43
44
  writeFileSync(file, stringifyFrontmatter(updated) + body, 'utf8');
44
45
 
@@ -46,8 +47,8 @@ export async function taskSummaryCommand(taskName, opts = {}) {
46
47
 
47
48
  console.log();
48
49
  console.log(chalk.green(` ✓ Summary updated for ${chalk.bold(taskName)} (${(opts.write || '').length}/${MAX_SUMMARY_CHARS} chars)`));
49
- if (fm.schema_version === 'v3.0') {
50
- console.log(chalk.dim(` Auto-bumped schema_version v3.0 → v3.1`));
50
+ if (bareSchemaVersion(fm.schema_version) === 'v3.0') {
51
+ console.log(chalk.dim(` Auto-bumped schema_version v3.0 → task@v3.1`));
51
52
  }
52
53
  console.log();
53
54
  return;
@@ -0,0 +1,146 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { parseFrontmatter } from './frontmatter.mjs';
4
+
5
+ // DW Document Schema + Index v1.0 — decisions (ADR) half (ADR-0017 v1.1).
6
+ // ADRs use TWO metadata styles in the wild:
7
+ // - YAML frontmatter (early ADRs, e.g. ADR-0001)
8
+ // - markdown headers `# ADR-NNNN: Title` + `## Status:` / `## Date:` / ...
9
+ // The parser tolerates both so external adapters consume one normalized index.
10
+
11
+ const DECISIONS_DIR = '.dw/decisions';
12
+ const INDEX_FILE = '.dw/decisions/decisions-index.json';
13
+ const SCHEMA_VERSION = 'decisions-index@v1';
14
+ const STATUS_KEYWORDS = ['Proposed', 'Accepted', 'Deprecated', 'Superseded'];
15
+
16
+ export function decisionsDir(rootDir = process.cwd()) {
17
+ return join(rootDir, DECISIONS_DIR);
18
+ }
19
+
20
+ export function decisionIndexFile(rootDir = process.cwd()) {
21
+ return join(rootDir, INDEX_FILE);
22
+ }
23
+
24
+ function nowUtc() {
25
+ return new Date().toISOString().replace(/\.\d+Z$/, 'Z');
26
+ }
27
+
28
+ function emptyIndex() {
29
+ return { schema_version: SCHEMA_VERSION, last_updated: nowUtc(), decisions: {} };
30
+ }
31
+
32
+ export function readDecisionIndex(rootDir = process.cwd()) {
33
+ const file = decisionIndexFile(rootDir);
34
+ if (!existsSync(file)) return emptyIndex();
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
37
+ if (!parsed || typeof parsed !== 'object' || !parsed.decisions) return emptyIndex();
38
+ return parsed;
39
+ } catch {
40
+ return emptyIndex();
41
+ }
42
+ }
43
+
44
+ export function writeDecisionIndex(index, rootDir = process.cwd()) {
45
+ const file = decisionIndexFile(rootDir);
46
+ const dir = dirname(file);
47
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
48
+ const updated = { ...index, schema_version: SCHEMA_VERSION, last_updated: nowUtc() };
49
+ writeFileSync(file, JSON.stringify(updated, null, 2) + '\n', 'utf8');
50
+ }
51
+
52
+ // .dw/decisions/NNNN-slug.md → { id: 'ADR-NNNN', num: 'NNNN', file }
53
+ export function listDecisionFiles(rootDir = process.cwd()) {
54
+ const dir = decisionsDir(rootDir);
55
+ if (!existsSync(dir)) return [];
56
+ const out = [];
57
+ for (const name of readdirSync(dir)) {
58
+ if (!name.endsWith('.md')) continue;
59
+ if (name.startsWith('_')) continue; // _template.md
60
+ const m = name.match(/^(\d{3,4})-/);
61
+ if (!m) continue;
62
+ out.push({ id: `ADR-${m[1]}`, num: m[1], file: join(dir, name), rel: `${DECISIONS_DIR}/${name}` });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function firstDate(s) {
68
+ if (!s) return null;
69
+ const m = String(s).match(/\d{4}-\d{2}-\d{2}/);
70
+ return m ? m[0] : null;
71
+ }
72
+
73
+ function normalizeStatus(raw) {
74
+ if (!raw) return { status: 'Proposed', superseded_by: null };
75
+ const text = String(raw);
76
+ const kw = STATUS_KEYWORDS.find((k) => new RegExp(`^\\s*${k}`, 'i').test(text));
77
+ const status = kw || (/superseded/i.test(text) ? 'Superseded' : 'Proposed');
78
+ const sb = text.match(/superseded by\s+(ADR-\d{3,4})/i);
79
+ return { status, superseded_by: sb ? sb[1] : null };
80
+ }
81
+
82
+ function adrRefs(s, selfId) {
83
+ if (!s) return [];
84
+ const refs = [...String(s).matchAll(/ADR-\d{3,4}/g)].map((m) => m[0]);
85
+ return [...new Set(refs)].filter((r) => r !== selfId);
86
+ }
87
+
88
+ function mdHeader(content, label) {
89
+ const m = content.match(new RegExp(`^##\\s+${label}:\\s*(.+)$`, 'm'));
90
+ return m ? m[1].trim() : null;
91
+ }
92
+
93
+ // Tolerant extractor: YAML frontmatter wins; falls back to markdown headers.
94
+ export function parseDecisionMeta(id, content) {
95
+ const fm = parseFrontmatter(content);
96
+ const hasFm = fm && (fm.status || fm.title || fm.id);
97
+
98
+ const title = (hasFm && fm.title)
99
+ || (content.match(/^#\s+ADR-\d{3,4}:\s*(.+?)\s*$/m)?.[1])
100
+ || (content.match(/^#\s+(.+?)\s*$/m)?.[1])
101
+ || id;
102
+
103
+ const rawStatus = (hasFm && fm.status) || mdHeader(content, 'Status');
104
+ const { status, superseded_by } = normalizeStatus(rawStatus);
105
+
106
+ const date = firstDate((hasFm && fm.date) || mdHeader(content, 'Date'));
107
+ const deciders = ((hasFm && fm.deciders) || mdHeader(content, 'Deciders') || null);
108
+
109
+ const relatedSrc = `${(hasFm && fm.related) || mdHeader(content, 'Related') || ''} ${rawStatus || ''}`;
110
+ const related = adrRefs(relatedSrc, id);
111
+ const supersedes = hasFm && fm.supersedes && fm.supersedes !== 'null'
112
+ ? adrRefs(String(fm.supersedes), id)
113
+ : [];
114
+
115
+ return {
116
+ title: String(title).trim(),
117
+ status,
118
+ status_raw: rawStatus ? String(rawStatus).trim() : null,
119
+ date,
120
+ deciders: deciders ? String(deciders).trim() : null,
121
+ related,
122
+ supersedes,
123
+ superseded_by: superseded_by || (hasFm && fm['superseded-by'] && fm['superseded-by'] !== 'null' ? String(fm['superseded-by']) : null),
124
+ };
125
+ }
126
+
127
+ function decisionEntry(file, id, rootDir) {
128
+ if (!existsSync(file)) return null;
129
+ const content = readFileSync(file, 'utf8');
130
+ const meta = parseDecisionMeta(id, content);
131
+ return { ...meta, file: `${DECISIONS_DIR}/${file.split(/[\\/]/).pop()}` };
132
+ }
133
+
134
+ export function rebuildDecisionIndex(rootDir = process.cwd()) {
135
+ const index = emptyIndex();
136
+ for (const d of listDecisionFiles(rootDir)) {
137
+ const entry = decisionEntry(d.file, d.id, rootDir);
138
+ if (entry) index.decisions[d.id] = entry;
139
+ }
140
+ const existing = readDecisionIndex(rootDir);
141
+ if (JSON.stringify(existing.decisions) === JSON.stringify(index.decisions)) {
142
+ return existing;
143
+ }
144
+ writeDecisionIndex(index, rootDir);
145
+ return index;
146
+ }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { parseFrontmatter, stringifyFrontmatter } from './frontmatter.mjs';
4
+ import { parseSubtaskTracker } from './timeline-parser.mjs';
4
5
 
5
6
  const GOALS_DIR = '.dw/goals';
6
7
  const INDEX_FILE = '.dw/goals/goals-index.json';
@@ -38,15 +39,35 @@ export function readGoalIndex(rootDir = process.cwd()) {
38
39
  }
39
40
  }
40
41
 
42
+ // Stable key order so the same goal set serializes byte-identically regardless
43
+ // of writer path (syncIndexEntry appends; rebuilds use readdirSync order) (#21).
44
+ function sortGoals(goals) {
45
+ const sorted = {};
46
+ for (const id of Object.keys(goals || {}).sort()) sorted[id] = goals[id];
47
+ return sorted;
48
+ }
49
+
41
50
  export function writeGoalIndex(index, rootDir = process.cwd()) {
42
51
  const file = goalIndexFile(rootDir);
43
52
  const dir = dirname(file);
44
53
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
54
+ const goals = sortGoals(index.goals);
55
+ // Skip the write when the goal set is byte-identical to disk: preserves the
56
+ // on-disk last_updated (freshness signal, ADR-0017/0018) and yields zero diff.
57
+ const prior = readGoalIndex(rootDir);
58
+ if (existsSync(file) && JSON.stringify(sortGoals(prior.goals)) === JSON.stringify(goals)) {
59
+ return prior;
60
+ }
61
+ // Spread `index` first so non-payload top-level fields ($schema_note,
62
+ // schema_version) and their order are preserved; override goals (sorted) +
63
+ // last_updated in place.
45
64
  const updated = {
46
65
  ...index,
47
66
  last_updated: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
67
+ goals,
48
68
  };
49
69
  writeFileSync(file, JSON.stringify(updated, null, 2) + '\n', 'utf8');
70
+ return updated;
50
71
  }
51
72
 
52
73
  export function readGoal(goalId, rootDir = process.cwd()) {
@@ -125,7 +146,7 @@ export function computeGoalProgress(goalId, rootDir = process.cwd(), linkedTasks
125
146
  if (!existsSync(taskFile)) continue;
126
147
  const content = readFileSync(taskFile, 'utf8');
127
148
  const tracker = extractTrackerSection(content);
128
- for (const row of parseTrackerRows(tracker)) {
149
+ for (const row of parseSubtaskTracker(tracker)) {
129
150
  total++;
130
151
  if (row.status.includes('✅') || /Done/i.test(row.status)) done++;
131
152
  else if (row.status.includes('🟡') || /In Progress/i.test(row.status)) inProgress++;
@@ -143,19 +164,6 @@ function extractTrackerSection(content) {
143
164
  return m ? m[0] : '';
144
165
  }
145
166
 
146
- function parseTrackerRows(section) {
147
- const rows = [];
148
- const lines = section.split('\n');
149
- for (const line of lines) {
150
- // Match table rows like: | ST-N | Description | Status | Date | Notes |
151
- if (!/^\|\s*(ST|WS)-/.test(line)) continue;
152
- const cells = line.split('|').map((c) => c.trim());
153
- if (cells.length < 4) continue;
154
- // cells[0] = "" (before first |), [1] = ID, [2] = subtask, [3] = status
155
- rows.push({ id: cells[1], subtask: cells[2], status: cells[3] || '', date: cells[4] || '', notes: cells[5] || '' });
156
- }
157
- return rows;
158
- }
159
167
 
160
168
  export function removeIndexEntry(goalId, rootDir = process.cwd()) {
161
169
  const index = readGoalIndex(rootDir);
@@ -181,7 +189,9 @@ export function findLinkedTaskIds(goalId, rootDir = process.cwd()) {
181
189
  else if (Array.isArray(fm.contributing_goal_ids) && fm.contributing_goal_ids.includes(goalId)) linked.push(entry);
182
190
  } catch { /* skip */ }
183
191
  }
184
- return linked;
192
+ // Sort for determinism — readdirSync order is filesystem-dependent and would
193
+ // otherwise churn linked_task_ids in the committed index (#21).
194
+ return linked.sort();
185
195
  }
186
196
 
187
197
  function extractTitle(content) {
@@ -200,3 +210,20 @@ export function nowUtc() {
200
210
  export function validateGoalId(goalId) {
201
211
  return /^G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?$/.test(goalId);
202
212
  }
213
+
214
+ // Single source for the goal lifecycle status enum (#20). Reads the project's
215
+ // schema (consumer-vendored via `dw init`) and falls back to the canonical set
216
+ // when absent, so the status command works in a stripped install.
217
+ const FALLBACK_GOAL_STATUSES = ['Draft', 'Active', 'Achieved', 'Abandoned', 'Pivoted'];
218
+
219
+ export function goalStatusEnum(rootDir = process.cwd()) {
220
+ const schemaFile = join(rootDir, '.dw/core/schemas/goal-frontmatter.schema.json');
221
+ if (!existsSync(schemaFile)) return [...FALLBACK_GOAL_STATUSES];
222
+ try {
223
+ const schema = JSON.parse(readFileSync(schemaFile, 'utf8'));
224
+ const enumVals = schema?.properties?.status?.enum;
225
+ return Array.isArray(enumVals) && enumVals.length ? enumVals : [...FALLBACK_GOAL_STATUSES];
226
+ } catch {
227
+ return [...FALLBACK_GOAL_STATUSES];
228
+ }
229
+ }
@@ -44,10 +44,19 @@ export function lintTimeline(taskDir, opts = {}) {
44
44
  const result = validateFrontmatter(fm, schema);
45
45
  if (!result.ok) {
46
46
  for (const e of result.errors) {
47
+ let message = `${e.path}: ${e.message}${e.keyword ? ` (${e.keyword})` : ''}`;
48
+ // #22: the raw ajv enum error for schema_version is opaque. Teach the
49
+ // allowed values + the namespaced-vs-bare convention right at the error.
50
+ if (e.path === '/schema_version' && e.keyword === 'enum') {
51
+ const allowed = (e.params?.allowedValues || []).map((v) => `"${v}"`).join(', ');
52
+ message = `schema_version: must be one of ${allowed}. Canonical is namespaced `
53
+ + `(task@v3.x) to match goal@v1 / tasks-index@v1; bare v3.x stays valid for `
54
+ + `back-compat. See docs/specs/dw-document-schema-v1.0.md §2.1.`;
55
+ }
47
56
  violations.push({
48
57
  severity: 'error',
49
58
  rule: 'frontmatter-schema',
50
- message: `${e.path}: ${e.message}${e.keyword ? ` (${e.keyword})` : ''}`,
59
+ message,
51
60
  file: timelineFile,
52
61
  });
53
62
  }
@@ -0,0 +1,164 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { parseFrontmatter } from './frontmatter.mjs';
4
+
5
+ // DW Document Schema + Index v1.0 (ADR-0017) — task half.
6
+ // Mirrors goal-store.mjs / goals-index@v1: a committed manifest so external
7
+ // adapters read task state from one O(1) file instead of reverse-engineering
8
+ // every task.md.
9
+
10
+ const TASKS_DIR = '.dw/tasks';
11
+ const INDEX_FILE = '.dw/tasks/tasks-index.json';
12
+ const SCHEMA_VERSION = 'tasks-index@v1';
13
+
14
+ export function tasksDir(rootDir = process.cwd()) {
15
+ return join(rootDir, TASKS_DIR);
16
+ }
17
+
18
+ export function taskFile(taskId, rootDir = process.cwd()) {
19
+ return join(rootDir, TASKS_DIR, taskId, 'task.md');
20
+ }
21
+
22
+ export function taskIndexFile(rootDir = process.cwd()) {
23
+ return join(rootDir, INDEX_FILE);
24
+ }
25
+
26
+ function nowUtc() {
27
+ return new Date().toISOString().replace(/\.\d+Z$/, 'Z');
28
+ }
29
+
30
+ // Stable key order so the same task set always serializes byte-identically,
31
+ // regardless of writer path (syncTaskIndexEntry appends; rebuildTaskIndex uses
32
+ // readdirSync order, which is filesystem-dependent). Without this the two paths
33
+ // disagree and the post-commit hook refresh churns the file (#21).
34
+ function sortTasks(tasks) {
35
+ const sorted = {};
36
+ for (const id of Object.keys(tasks || {}).sort()) sorted[id] = tasks[id];
37
+ return sorted;
38
+ }
39
+
40
+ // schema_version naming (#22): task frontmatter accepts both the legacy bare
41
+ // form (v3.0/v3.1) and the canonical namespaced form (task@v3.0/task@v3.1) that
42
+ // matches the repo-wide @v convention (goal@v1, *-index@v1). New writes emit the
43
+ // namespaced form; readers tolerate both. `bareSchemaVersion` strips the prefix
44
+ // for version-comparison logic; `canonicalSchemaVersion` adds it for emission.
45
+ export function bareSchemaVersion(v) {
46
+ if (!v || typeof v !== 'string') return v;
47
+ return v.startsWith('task@') ? v.slice('task@'.length) : v;
48
+ }
49
+
50
+ export function canonicalSchemaVersion(v) {
51
+ if (!v || typeof v !== 'string') return v;
52
+ return v.startsWith('task@') ? v : `task@${v}`;
53
+ }
54
+
55
+ function emptyIndex() {
56
+ return { schema_version: SCHEMA_VERSION, last_updated: nowUtc(), tasks: {} };
57
+ }
58
+
59
+ export function readTaskIndex(rootDir = process.cwd()) {
60
+ const file = taskIndexFile(rootDir);
61
+ if (!existsSync(file)) return emptyIndex();
62
+ try {
63
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
64
+ if (!parsed || typeof parsed !== 'object' || !parsed.tasks) return emptyIndex();
65
+ return parsed;
66
+ } catch {
67
+ return emptyIndex();
68
+ }
69
+ }
70
+
71
+ export function writeTaskIndex(index, rootDir = process.cwd()) {
72
+ const file = taskIndexFile(rootDir);
73
+ const dir = dirname(file);
74
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
75
+ const tasks = sortTasks(index.tasks);
76
+ // Skip the write entirely when the task set is byte-identical to disk: this
77
+ // keeps the on-disk `last_updated` (the spec freshness signal, ADR-0017) and
78
+ // guarantees zero git diff. `last_updated` advances only on a real change.
79
+ const prior = readTaskIndex(rootDir);
80
+ if (existsSync(file) && JSON.stringify(sortTasks(prior.tasks)) === JSON.stringify(tasks)) {
81
+ return prior;
82
+ }
83
+ const updated = { schema_version: SCHEMA_VERSION, last_updated: nowUtc(), tasks };
84
+ writeFileSync(file, JSON.stringify(updated, null, 2) + '\n', 'utf8');
85
+ return updated;
86
+ }
87
+
88
+ export function listTaskIds(rootDir = process.cwd()) {
89
+ const dir = tasksDir(rootDir);
90
+ if (!existsSync(dir)) return [];
91
+ return readdirSync(dir).filter((entry) => {
92
+ if (entry === 'archive' || entry === 'ACTIVE.md') return false;
93
+ try {
94
+ const path = join(dir, entry);
95
+ return statSync(path).isDirectory() && existsSync(join(path, 'task.md'));
96
+ } catch {
97
+ return false;
98
+ }
99
+ });
100
+ }
101
+
102
+ export function readTask(taskId, rootDir = process.cwd()) {
103
+ const file = taskFile(taskId, rootDir);
104
+ if (!existsSync(file)) return null;
105
+ const content = readFileSync(file, 'utf8');
106
+ return { fm: parseFrontmatter(content), content, file };
107
+ }
108
+
109
+ export function extractTaskTitle(content) {
110
+ const m = content.match(/^#\s+(?:Timeline:|Spec:)?\s*(.+?)\s*$/m);
111
+ return m ? m[1].trim() : null;
112
+ }
113
+
114
+ // doc@v1 task projection — consumer-facing fields only (no body text).
115
+ function taskEntry(taskId, rootDir) {
116
+ const task = readTask(taskId, rootDir);
117
+ if (!task) return null;
118
+ const fm = task.fm || {};
119
+ return {
120
+ title: extractTaskTitle(task.content) || taskId,
121
+ status: fm.status || 'Draft',
122
+ phase: fm.phase || null,
123
+ owner: fm.owner || 'unknown',
124
+ depth: fm.depth || null,
125
+ related_adr: fm.related_adr || null,
126
+ target_ship: fm.target_ship || null,
127
+ parent_goal_id: fm.parent_goal_id && fm.parent_goal_id !== 'none' ? fm.parent_goal_id : null,
128
+ contributing_goal_ids: Array.isArray(fm.contributing_goal_ids) ? fm.contributing_goal_ids : [],
129
+ summary: fm.summary || null,
130
+ schema_version: fm.schema_version || null,
131
+ last_updated: fm.last_updated || new Date().toISOString().slice(0, 10),
132
+ };
133
+ }
134
+
135
+ export function syncTaskIndexEntry(taskId, rootDir = process.cwd()) {
136
+ const entry = taskEntry(taskId, rootDir);
137
+ if (!entry) return null;
138
+ const index = readTaskIndex(rootDir);
139
+ index.tasks[taskId] = entry;
140
+ writeTaskIndex(index, rootDir);
141
+ return entry;
142
+ }
143
+
144
+ export function removeTaskIndexEntry(taskId, rootDir = process.cwd()) {
145
+ const index = readTaskIndex(rootDir);
146
+ if (index.tasks[taskId]) {
147
+ delete index.tasks[taskId];
148
+ writeTaskIndex(index, rootDir);
149
+ return true;
150
+ }
151
+ return false;
152
+ }
153
+
154
+ // Authoritative full rebuild — scans .dw/tasks/ and rewrites the index.
155
+ // Idempotency (sorted keys + skip-when-identical) lives in writeTaskIndex, so an
156
+ // unchanged task set produces zero git diff and the stop-check refresh is safe.
157
+ export function rebuildTaskIndex(rootDir = process.cwd()) {
158
+ const index = emptyIndex();
159
+ for (const taskId of listTaskIds(rootDir)) {
160
+ const entry = taskEntry(taskId, rootDir);
161
+ if (entry) index.tasks[taskId] = entry;
162
+ }
163
+ return writeTaskIndex(index, rootDir);
164
+ }
@@ -29,29 +29,68 @@ export function parseTimeline(content) {
29
29
  };
30
30
  }
31
31
 
32
+ // Header-driven so the column ORDER is not load-bearing (#24): the §3 tracker
33
+ // may be `# | Subtask | Status | Date | Notes` (legacy) or
34
+ // `# | Subtask | Acceptance | Est | Status | Notes` (current) — we read columns
35
+ // by name. Falls back to legacy positional mapping when no header row is found,
36
+ // so older / hand-written tables keep parsing.
37
+ const TRACKER_COLS = {
38
+ id: ['#', 'id'],
39
+ name: ['subtask', 'task', 'name'],
40
+ status: ['status'],
41
+ date: ['date'],
42
+ notes: ['notes'],
43
+ acceptance: ['acceptance'],
44
+ est: ['est', 'estimate'],
45
+ };
46
+ const LEGACY_POS = { id: 0, name: 1, status: 2, date: 3, notes: 4 };
47
+
48
+ function splitRow(line) {
49
+ // Drop the empty cells produced by the leading/trailing pipes.
50
+ return line.split('|').map((c) => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length - 1);
51
+ }
52
+
32
53
  export function parseSubtaskTracker(sectionText) {
33
54
  if (!sectionText) return [];
34
55
  const lines = sectionText.split('\n').map((l) => l.trim()).filter(Boolean);
56
+ let colMap = null; // canonical-name -> column index
35
57
  const rows = [];
36
- let inTable = false;
37
58
  for (const line of lines) {
38
- if (line.startsWith('|---') || line.match(/^\|[\s|:-]+\|$/)) { inTable = true; continue; }
39
- if (!line.startsWith('|')) { inTable = false; continue; }
40
- const cells = line.split('|').map((c) => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length - 1);
59
+ if (!line.startsWith('|')) continue;
60
+ if (line.match(/^\|[\s|:-]+\|$/)) continue; // separator row
61
+ const cells = splitRow(line);
41
62
  if (cells.length < 2) continue;
42
- if (!inTable && (cells[0].toLowerCase().startsWith('#') || cells[0].toLowerCase() === 'ws' || cells[0].toLowerCase() === 'st')) {
43
- // header row
63
+
64
+ const low = cells.map((c) => c.toLowerCase());
65
+ // Header row: first cell is '#'/'id', or it names known columns. Detect once.
66
+ const looksLikeHeader = low[0] === '#' || low[0] === 'id'
67
+ || low.includes('status') || low.includes('subtask');
68
+ if (!colMap && looksLikeHeader) {
69
+ colMap = {};
70
+ for (const [canon, aliases] of Object.entries(TRACKER_COLS)) {
71
+ const idx = low.findIndex((c) => aliases.includes(c));
72
+ if (idx >= 0) colMap[canon] = idx;
73
+ }
44
74
  continue;
45
75
  }
46
- if (cells.length >= 3) {
47
- rows.push({
48
- id: cells[0],
49
- name: cells[1],
50
- status: cells[2],
51
- date: cells[3] || '',
52
- notes: cells[4] || '',
53
- });
54
- }
76
+
77
+ // Data row. Skip stray non-subtask rows when using positional fallback.
78
+ if (!colMap && !/^(ST|WS)-/i.test(cells[0]) && !/^\d/.test(cells[0])) continue;
79
+ if (cells.length < 3 && !colMap) continue;
80
+
81
+ const get = (canon) => {
82
+ const idx = colMap ? colMap[canon] : LEGACY_POS[canon];
83
+ return idx != null && idx >= 0 ? (cells[idx] ?? '') : '';
84
+ };
85
+ rows.push({
86
+ id: get('id'),
87
+ name: get('name'),
88
+ status: get('status'),
89
+ date: get('date'),
90
+ notes: get('notes'),
91
+ acceptance: get('acceptance'),
92
+ est: get('est'),
93
+ });
55
94
  }
56
95
  return rows;
57
96
  }