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.
- package/.claude/hooks/stop-check.sh +10 -0
- package/.claude/skills/dw-decision/SKILL.md +2 -1
- package/.claude/skills/dw-kit-report/SKILL.md +38 -7
- package/.dw/core/schemas/decision-frontmatter.schema.json +54 -0
- package/.dw/core/schemas/goal-frontmatter.schema.json +2 -2
- package/.dw/core/schemas/task-frontmatter.schema.json +2 -2
- package/.dw/core/templates/v3/task.md +41 -19
- package/README.md +5 -2
- package/package.json +5 -3
- package/src/cli.mjs +33 -0
- package/src/commands/decision-index.mjs +45 -0
- package/src/commands/goal-delete.mjs +3 -1
- package/src/commands/goal-link.mjs +3 -1
- package/src/commands/goal-status.mjs +95 -0
- package/src/commands/lint-task.mjs +20 -0
- package/src/commands/task-index.mjs +47 -0
- package/src/commands/task-migrate.mjs +16 -5
- package/src/commands/task-new.mjs +6 -0
- package/src/commands/task-summary.mjs +4 -3
- package/src/lib/decision-store.mjs +146 -0
- package/src/lib/goal-store.mjs +42 -15
- package/src/lib/lint-rules.mjs +10 -1
- package/src/lib/task-store.mjs +164 -0
- package/src/lib/timeline-parser.mjs +54 -15
- package/.dw/core/PILLARS.md +0 -122
- package/CLAUDE.md +0 -44
|
@@ -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
|
+
}
|
package/src/lib/goal-store.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/lint-rules.mjs
CHANGED
|
@@ -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
|
|
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('
|
|
39
|
-
if (
|
|
40
|
-
const cells = line
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
}
|