dw-kit 1.4.0 → 1.7.0-rc.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/agents/executor.md +80 -80
- package/.claude/hooks/pre-commit-gate.sh +59 -0
- package/.claude/hooks/stop-check.sh +111 -31
- package/.claude/rules/commit-standards.md +48 -37
- package/.claude/rules/dw.md +47 -11
- package/.claude/skills/dw-commit/SKILL.md +7 -4
- package/.claude/skills/dw-decision/SKILL.md +5 -4
- package/.claude/skills/dw-execute/SKILL.md +18 -5
- package/.claude/skills/dw-handoff/SKILL.md +8 -3
- package/.claude/skills/dw-plan/SKILL.md +15 -2
- package/.claude/skills/dw-research/SKILL.md +7 -5
- package/.claude/skills/dw-retroactive/SKILL.md +75 -63
- package/.claude/skills/dw-task-init/SKILL.md +40 -35
- package/.dw/adapters/generic/AGENT.md +171 -169
- package/.dw/core/WORKFLOW.md +450 -450
- package/.dw/core/schemas/agent-claim.schema.json +127 -0
- package/.dw/core/schemas/agent-report.schema.json +72 -0
- package/.dw/core/schemas/goal-frontmatter.schema.json +84 -0
- package/.dw/core/schemas/task-frontmatter.schema.json +97 -0
- package/.dw/core/templates/v3/goal.md +146 -0
- package/.dw/core/templates/v3/task.md +188 -0
- package/CLAUDE.md +2 -2
- package/MIGRATION-v1.5.md +330 -0
- package/README.md +17 -0
- package/package.json +3 -2
- package/src/cli.mjs +312 -0
- package/src/commands/agent-claim.mjs +235 -0
- package/src/commands/agent-inspect.mjs +123 -0
- package/src/commands/doctor.mjs +64 -0
- package/src/commands/goal-bump.mjs +50 -0
- package/src/commands/goal-delete.mjs +120 -0
- package/src/commands/goal-link.mjs +126 -0
- package/src/commands/goal-lint.mjs +152 -0
- package/src/commands/goal-new.mjs +86 -0
- package/src/commands/goal-portfolio.mjs +84 -0
- package/src/commands/goal-render.mjs +49 -0
- package/src/commands/goal-set.mjs +62 -0
- package/src/commands/goal-show.mjs +94 -0
- package/src/commands/goal-stubs.mjs +21 -0
- package/src/commands/goal-suggest-krs.mjs +139 -0
- package/src/commands/goal-summary.mjs +67 -0
- package/src/commands/goal-view.mjs +196 -0
- package/src/commands/lint-task.mjs +112 -0
- package/src/commands/task-migrate.mjs +471 -0
- package/src/commands/task-new.mjs +90 -0
- package/src/commands/task-render.mjs +235 -0
- package/src/commands/task-rotate.mjs +168 -0
- package/src/commands/task-show.mjs +137 -0
- package/src/commands/task-summary.mjs +68 -0
- package/src/commands/task-view.mjs +386 -0
- package/src/commands/task-watch.mjs +868 -0
- package/src/lib/active-index.mjs +19 -1
- package/src/lib/agent-claim.mjs +173 -0
- package/src/lib/agent-conflict.mjs +137 -0
- package/src/lib/agent-events.mjs +43 -0
- package/src/lib/agent-report.mjs +96 -0
- package/src/lib/frontmatter.mjs +72 -0
- package/src/lib/goal-events.mjs +79 -0
- package/src/lib/goal-store.mjs +202 -0
- package/src/lib/goal-svg.mjs +293 -0
- package/src/lib/goal-watch.mjs +133 -0
- package/src/lib/lint-rules.mjs +149 -0
- package/src/lib/sse-broker.mjs +91 -0
- package/src/lib/timeline-parser.mjs +80 -0
- package/src/lib/watch-auth.mjs +64 -0
package/src/commands/doctor.mjs
CHANGED
|
@@ -191,6 +191,70 @@ export async function doctorCommand() {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
info('Task Docs v3 (ADR-0008)');
|
|
195
|
+
const templateV3 = join(projectDir, '.dw/core/templates/v3/task.md');
|
|
196
|
+
const schemaV3 = join(projectDir, '.dw/core/schemas/task-frontmatter.schema.json');
|
|
197
|
+
if (existsSync(templateV3) && existsSync(schemaV3)) {
|
|
198
|
+
ok('v3 template + JSON schema present');
|
|
199
|
+
} else {
|
|
200
|
+
warn('v3 template/schema not in project — run `dw upgrade` or run from a v1.5+ project');
|
|
201
|
+
warnings++;
|
|
202
|
+
}
|
|
203
|
+
const tasksDir = join(projectDir, '.dw/tasks');
|
|
204
|
+
if (existsSync(tasksDir)) {
|
|
205
|
+
const fs = await import('node:fs');
|
|
206
|
+
const entries = fs.readdirSync(tasksDir).filter((e) => e !== 'archive' && e !== 'ACTIVE.md');
|
|
207
|
+
let v3 = 0, v2 = 0, v1 = 0, none = 0;
|
|
208
|
+
for (const e of entries) {
|
|
209
|
+
const p = join(tasksDir, e);
|
|
210
|
+
try {
|
|
211
|
+
if (!fs.statSync(p).isDirectory()) continue;
|
|
212
|
+
if (existsSync(join(p, 'task.md'))) v3++;
|
|
213
|
+
else if (existsSync(join(p, 'tracking.md'))) v2++;
|
|
214
|
+
else if (fs.readdirSync(p).some((f) => f.endsWith('-progress.md'))) v1++;
|
|
215
|
+
else none++;
|
|
216
|
+
} catch { /* skip */ }
|
|
217
|
+
}
|
|
218
|
+
log(` Tasks : ${v3} v3 · ${v2} v2 · ${v1} v1 · ${none} no-tracking`);
|
|
219
|
+
if (v2 > 0) {
|
|
220
|
+
log(` ${v2} v2 task${v2 === 1 ? '' : 's'} can be migrated: \`dw task migrate --all --dry-run\``);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
info('Agent OS (ADR-0009)');
|
|
225
|
+
const claimSchema = join(projectDir, '.dw/core/schemas/agent-claim.schema.json');
|
|
226
|
+
const reportSchema = join(projectDir, '.dw/core/schemas/agent-report.schema.json');
|
|
227
|
+
if (existsSync(claimSchema) && existsSync(reportSchema)) {
|
|
228
|
+
ok('Agent OS schemas (claim + report) present');
|
|
229
|
+
} else {
|
|
230
|
+
warn('Agent OS schemas not in project — run `dw upgrade` or use v1.6+ project');
|
|
231
|
+
warnings++;
|
|
232
|
+
}
|
|
233
|
+
const claimsDir = join(projectDir, '.dw/cache/agents/claims');
|
|
234
|
+
if (existsSync(claimsDir)) {
|
|
235
|
+
const fs = await import('node:fs');
|
|
236
|
+
const claimFiles = fs.readdirSync(claimsDir).filter((f) => f.endsWith('.json'));
|
|
237
|
+
let active = 0, expired = 0, released = 0;
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
for (const f of claimFiles) {
|
|
240
|
+
try {
|
|
241
|
+
const c = JSON.parse(fs.readFileSync(join(claimsDir, f), 'utf8'));
|
|
242
|
+
const wallClock = Date.parse(c.lease_expires) || 0;
|
|
243
|
+
const relative = (Date.parse(c.created_at) || 0) + (c.lease_duration_seconds || 0) * 1000;
|
|
244
|
+
const effective = Math.max(wallClock, relative);
|
|
245
|
+
if (c.status === 'released' || c.status === 'invalidated') released++;
|
|
246
|
+
else if (effective < now) expired++;
|
|
247
|
+
else active++;
|
|
248
|
+
} catch { /* skip */ }
|
|
249
|
+
}
|
|
250
|
+
log(` Claims : ${active} active · ${expired} expired (not reaped) · ${released} released`);
|
|
251
|
+
if (expired > 0) {
|
|
252
|
+
log(` Run \`dw agent claims --status expired\` to inspect; \`dw agent expire <id>\` to reap.`);
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
log(' Claims : (no .dw/cache/agents/claims/ yet — first `dw agent claim` will initialize)');
|
|
256
|
+
}
|
|
257
|
+
|
|
194
258
|
info('Supply-Chain Guard (ADR-0005, opt-in)');
|
|
195
259
|
const sc = snapshotInfo(projectDir);
|
|
196
260
|
if (!sc.exists) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { readGoal, updateGoalFrontmatter, syncIndexEntry, todayIso } from '../lib/goal-store.mjs';
|
|
3
|
+
import { logGoalEvent } from '../lib/goal-events.mjs';
|
|
4
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
5
|
+
|
|
6
|
+
export async function goalBumpCommand(goalId, opts = {}) {
|
|
7
|
+
const rootDir = process.cwd();
|
|
8
|
+
|
|
9
|
+
if (!goalId) {
|
|
10
|
+
console.error(chalk.red('✗ Usage: dw goal bump <goal-id> --reason "..."'));
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
if (!opts.reason) {
|
|
14
|
+
console.error(chalk.red('✗ --reason "..." required (Q4: explicit reason for material change)'));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const goal = readGoal(goalId, rootDir);
|
|
19
|
+
if (!goal) {
|
|
20
|
+
console.error(chalk.red(`✗ Goal ${goalId} not found`));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const oldVersion = goal.fm.goal_version || 1;
|
|
25
|
+
const newVersion = oldVersion + 1;
|
|
26
|
+
|
|
27
|
+
updateGoalFrontmatter(goalId, (fm) => {
|
|
28
|
+
fm.goal_version = newVersion;
|
|
29
|
+
fm.last_updated = todayIso();
|
|
30
|
+
return fm;
|
|
31
|
+
}, rootDir);
|
|
32
|
+
syncIndexEntry(goalId, rootDir);
|
|
33
|
+
|
|
34
|
+
logGoalEvent({
|
|
35
|
+
event: 'goal_pivoted',
|
|
36
|
+
goal_id: goalId,
|
|
37
|
+
from_version: oldVersion,
|
|
38
|
+
to_version: newVersion,
|
|
39
|
+
summary: opts.reason,
|
|
40
|
+
changed_by: process.env.USER || process.env.USERNAME || 'unknown',
|
|
41
|
+
}, rootDir);
|
|
42
|
+
|
|
43
|
+
logEvent({ event: 'goal', action: 'bump', name: goalId, from_version: oldVersion, to_version: newVersion }, rootDir);
|
|
44
|
+
|
|
45
|
+
console.log();
|
|
46
|
+
console.log(chalk.green(` ✓ ${chalk.bold(goalId)} version ${oldVersion} → ${newVersion}`));
|
|
47
|
+
console.log(chalk.dim(` Reason: ${opts.reason}`));
|
|
48
|
+
console.log(chalk.dim(` Logged to .dw/events-global.jsonl as goal_pivoted`));
|
|
49
|
+
console.log();
|
|
50
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { parseFrontmatter, stringifyFrontmatter } from '../lib/frontmatter.mjs';
|
|
5
|
+
import { readGoal, removeIndexEntry, findLinkedTaskIds, goalDir, todayIso, nowUtc } from '../lib/goal-store.mjs';
|
|
6
|
+
import { logGoalEvent } from '../lib/goal-events.mjs';
|
|
7
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
8
|
+
|
|
9
|
+
const TASKS_DIR = '.dw/tasks';
|
|
10
|
+
|
|
11
|
+
function clearParentGoalOnTask(taskId, goalId, rootDir = process.cwd()) {
|
|
12
|
+
const file = join(rootDir, TASKS_DIR, taskId, 'task.md');
|
|
13
|
+
if (!existsSync(file)) return false;
|
|
14
|
+
const content = readFileSync(file, 'utf8');
|
|
15
|
+
const fm = parseFrontmatter(content);
|
|
16
|
+
let changed = false;
|
|
17
|
+
if (fm.parent_goal_id === goalId) { fm.parent_goal_id = 'none'; changed = true; }
|
|
18
|
+
if (Array.isArray(fm.contributing_goal_ids) && fm.contributing_goal_ids.includes(goalId)) {
|
|
19
|
+
fm.contributing_goal_ids = fm.contributing_goal_ids.filter((id) => id !== goalId);
|
|
20
|
+
changed = true;
|
|
21
|
+
}
|
|
22
|
+
if (!changed) return false;
|
|
23
|
+
fm.last_updated = todayIso();
|
|
24
|
+
const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
25
|
+
writeFileSync(file, stringifyFrontmatter(fm) + body, 'utf8');
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function goalDeleteCommand(goalId, opts = {}) {
|
|
30
|
+
const rootDir = process.cwd();
|
|
31
|
+
|
|
32
|
+
if (!goalId) {
|
|
33
|
+
console.error(chalk.red('✗ Usage: dw goal delete <goal-id> [--cascade] [--dry-run] [--hard]'));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const goal = readGoal(goalId, rootDir);
|
|
38
|
+
if (!goal) {
|
|
39
|
+
console.error(chalk.red(`✗ Goal ${goalId} not found`));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const linked = findLinkedTaskIds(goalId, rootDir);
|
|
44
|
+
|
|
45
|
+
if (opts.dryRun) {
|
|
46
|
+
console.log();
|
|
47
|
+
console.log(chalk.cyan(` Dry-run: delete ${chalk.bold(goalId)}`));
|
|
48
|
+
console.log(chalk.dim(` Mode: ${opts.hard ? 'HARD (remove .dw/goals/' + goalId + '/)' : 'SOFT (status → Abandoned, archived_at set)'}`));
|
|
49
|
+
console.log(chalk.dim(` Linked tasks (${linked.length}): ${linked.join(', ') || '(none)'}`));
|
|
50
|
+
if (opts.cascade && linked.length > 0) {
|
|
51
|
+
console.log(chalk.dim(` Would clear parent_goal_id/contributing_goal_ids on ${linked.length} task(s)`));
|
|
52
|
+
} else if (!opts.cascade && linked.length > 0) {
|
|
53
|
+
console.log(chalk.yellow(` ⚠ ${linked.length} tasks reference this goal — add --cascade or unlink manually first`));
|
|
54
|
+
}
|
|
55
|
+
console.log();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (linked.length > 0 && !opts.cascade) {
|
|
60
|
+
console.error(chalk.red(`✗ ${linked.length} task(s) link to ${goalId} — re-run with --cascade or unlink manually`));
|
|
61
|
+
console.error(chalk.dim(` Linked: ${linked.join(', ')}`));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (opts.cascade) {
|
|
66
|
+
for (const t of linked) clearParentGoalOnTask(t, goalId, rootDir);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (opts.hard) {
|
|
70
|
+
rmSync(goalDir(goalId, rootDir), { recursive: true, force: true });
|
|
71
|
+
removeIndexEntry(goalId, rootDir);
|
|
72
|
+
logGoalEvent({
|
|
73
|
+
event: 'goal_archived',
|
|
74
|
+
goal_id: goalId,
|
|
75
|
+
archived_at: nowUtc(),
|
|
76
|
+
mode: 'hard',
|
|
77
|
+
cascade_task_count: linked.length,
|
|
78
|
+
}, rootDir);
|
|
79
|
+
logEvent({ event: 'goal', action: 'delete.hard', name: goalId, cascade_count: linked.length }, rootDir);
|
|
80
|
+
console.log();
|
|
81
|
+
console.log(chalk.green(` ✓ Hard-deleted ${chalk.bold(goalId)} (folder removed)`));
|
|
82
|
+
console.log(chalk.dim(` Cleared parent_goal_id on ${linked.length} linked task(s)`));
|
|
83
|
+
console.log();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Soft delete: status → Abandoned, archived_at set, bump version
|
|
88
|
+
const content = readFileSync(goal.file, 'utf8');
|
|
89
|
+
const fm = parseFrontmatter(content);
|
|
90
|
+
const oldVersion = fm.goal_version || 1;
|
|
91
|
+
fm.status = 'Abandoned';
|
|
92
|
+
fm.archived_at = nowUtc();
|
|
93
|
+
fm.goal_version = oldVersion + 1;
|
|
94
|
+
fm.last_updated = todayIso();
|
|
95
|
+
const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
96
|
+
writeFileSync(goal.file, stringifyFrontmatter(fm) + body, 'utf8');
|
|
97
|
+
|
|
98
|
+
logGoalEvent({
|
|
99
|
+
event: 'goal_status_changed',
|
|
100
|
+
goal_id: goalId,
|
|
101
|
+
from_status: goal.fm.status,
|
|
102
|
+
to_status: 'Abandoned',
|
|
103
|
+
changed_by: process.env.USER || process.env.USERNAME || 'unknown',
|
|
104
|
+
}, rootDir);
|
|
105
|
+
logGoalEvent({
|
|
106
|
+
event: 'goal_archived',
|
|
107
|
+
goal_id: goalId,
|
|
108
|
+
archived_at: fm.archived_at,
|
|
109
|
+
mode: 'soft',
|
|
110
|
+
cascade_task_count: linked.length,
|
|
111
|
+
}, rootDir);
|
|
112
|
+
|
|
113
|
+
logEvent({ event: 'goal', action: 'delete.soft', name: goalId, cascade_count: linked.length }, rootDir);
|
|
114
|
+
|
|
115
|
+
console.log();
|
|
116
|
+
console.log(chalk.green(` ✓ Soft-deleted ${chalk.bold(goalId)} (status=Abandoned, archived_at=${fm.archived_at})`));
|
|
117
|
+
console.log(chalk.dim(` Cleared parent_goal_id on ${linked.length} linked task(s)`));
|
|
118
|
+
console.log(chalk.dim(` To hard-delete folder: dw goal delete ${goalId} --hard`));
|
|
119
|
+
console.log();
|
|
120
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { parseFrontmatter, stringifyFrontmatter } from '../lib/frontmatter.mjs';
|
|
5
|
+
import { readGoal, syncIndexEntry, todayIso } from '../lib/goal-store.mjs';
|
|
6
|
+
import { logGoalEvent } from '../lib/goal-events.mjs';
|
|
7
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
8
|
+
|
|
9
|
+
const TASKS_DIR = '.dw/tasks';
|
|
10
|
+
|
|
11
|
+
function taskFile(taskId, rootDir = process.cwd()) {
|
|
12
|
+
return join(rootDir, TASKS_DIR, taskId, 'task.md');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeTaskFrontmatter(taskId, mutator, rootDir = process.cwd()) {
|
|
16
|
+
const file = taskFile(taskId, rootDir);
|
|
17
|
+
const content = readFileSync(file, 'utf8');
|
|
18
|
+
const fm = parseFrontmatter(content);
|
|
19
|
+
const updated = mutator({ ...fm });
|
|
20
|
+
if (updated.schema_version === 'v3.0') updated.schema_version = 'v3.1';
|
|
21
|
+
updated.last_updated = todayIso();
|
|
22
|
+
const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
23
|
+
writeFileSync(file, stringifyFrontmatter(updated) + body, 'utf8');
|
|
24
|
+
return updated;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function goalLinkCommand(goalId, taskId, opts = {}) {
|
|
28
|
+
const rootDir = process.cwd();
|
|
29
|
+
|
|
30
|
+
if (!goalId || !taskId) {
|
|
31
|
+
console.error(chalk.red('✗ Usage: dw goal link <goal-id> <task-id> [--contributing]'));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!readGoal(goalId, rootDir)) {
|
|
36
|
+
console.error(chalk.red(`✗ Goal ${goalId} not found`));
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
if (!existsSync(taskFile(taskId, rootDir))) {
|
|
40
|
+
console.error(chalk.red(`✗ Task ${taskId} not found at .dw/tasks/${taskId}/task.md`));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const role = opts.contributing ? 'contributing' : 'primary';
|
|
45
|
+
|
|
46
|
+
const updated = writeTaskFrontmatter(taskId, (fm) => {
|
|
47
|
+
if (role === 'primary') {
|
|
48
|
+
if (fm.parent_goal_id && fm.parent_goal_id !== 'none' && fm.parent_goal_id !== goalId && !opts.force) {
|
|
49
|
+
console.error(chalk.red(`✗ Task ${taskId} already has parent_goal_id=${fm.parent_goal_id} (use --force to override)`));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
fm.parent_goal_id = goalId;
|
|
53
|
+
} else {
|
|
54
|
+
const existing = Array.isArray(fm.contributing_goal_ids) ? fm.contributing_goal_ids : [];
|
|
55
|
+
if (!existing.includes(goalId)) existing.push(goalId);
|
|
56
|
+
fm.contributing_goal_ids = existing;
|
|
57
|
+
}
|
|
58
|
+
return fm;
|
|
59
|
+
}, rootDir);
|
|
60
|
+
|
|
61
|
+
syncIndexEntry(goalId, rootDir);
|
|
62
|
+
|
|
63
|
+
logGoalEvent({
|
|
64
|
+
event: 'goal_task_linked',
|
|
65
|
+
goal_id: goalId,
|
|
66
|
+
task_id: taskId,
|
|
67
|
+
role,
|
|
68
|
+
}, rootDir);
|
|
69
|
+
|
|
70
|
+
logEvent({ event: 'goal', action: 'link', name: goalId, task_id: taskId, role }, rootDir);
|
|
71
|
+
|
|
72
|
+
console.log();
|
|
73
|
+
console.log(chalk.green(` ✓ Linked ${chalk.bold(taskId)} → ${chalk.bold(goalId)} (${role})`));
|
|
74
|
+
if (role === 'primary') {
|
|
75
|
+
console.log(chalk.dim(` task.md frontmatter: parent_goal_id=${goalId}`));
|
|
76
|
+
} else {
|
|
77
|
+
console.log(chalk.dim(` task.md frontmatter: contributing_goal_ids includes ${goalId}`));
|
|
78
|
+
}
|
|
79
|
+
console.log();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function goalUnlinkCommand(goalId, taskId) {
|
|
83
|
+
const rootDir = process.cwd();
|
|
84
|
+
|
|
85
|
+
if (!goalId || !taskId) {
|
|
86
|
+
console.error(chalk.red('✗ Usage: dw goal unlink <goal-id> <task-id>'));
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!existsSync(taskFile(taskId, rootDir))) {
|
|
91
|
+
console.error(chalk.red(`✗ Task ${taskId} not found`));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let changed = false;
|
|
96
|
+
writeTaskFrontmatter(taskId, (fm) => {
|
|
97
|
+
if (fm.parent_goal_id === goalId) {
|
|
98
|
+
fm.parent_goal_id = 'none';
|
|
99
|
+
changed = true;
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(fm.contributing_goal_ids) && fm.contributing_goal_ids.includes(goalId)) {
|
|
102
|
+
fm.contributing_goal_ids = fm.contributing_goal_ids.filter((id) => id !== goalId);
|
|
103
|
+
changed = true;
|
|
104
|
+
}
|
|
105
|
+
return fm;
|
|
106
|
+
}, rootDir);
|
|
107
|
+
|
|
108
|
+
if (!changed) {
|
|
109
|
+
console.log(chalk.dim(` · Task ${taskId} was not linked to ${goalId}`));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (readGoal(goalId, rootDir)) syncIndexEntry(goalId, rootDir);
|
|
114
|
+
|
|
115
|
+
logGoalEvent({
|
|
116
|
+
event: 'goal_task_unlinked',
|
|
117
|
+
goal_id: goalId,
|
|
118
|
+
task_id: taskId,
|
|
119
|
+
}, rootDir);
|
|
120
|
+
|
|
121
|
+
logEvent({ event: 'goal', action: 'unlink', name: goalId, task_id: taskId }, rootDir);
|
|
122
|
+
|
|
123
|
+
console.log();
|
|
124
|
+
console.log(chalk.green(` ✓ Unlinked ${chalk.bold(taskId)} from ${chalk.bold(goalId)}`));
|
|
125
|
+
console.log();
|
|
126
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { parseFrontmatter, loadSchema, validateFrontmatter } from '../lib/frontmatter.mjs';
|
|
5
|
+
import { listGoalIds, readGoal, readGoalIndex, findLinkedTaskIds, goalFile } from '../lib/goal-store.mjs';
|
|
6
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
7
|
+
|
|
8
|
+
const TASKS_DIR = '.dw/tasks';
|
|
9
|
+
const MAX_SUMMARY_CHARS = 1000;
|
|
10
|
+
|
|
11
|
+
function loadGoalSchema(rootDir = process.cwd()) {
|
|
12
|
+
const path = join(rootDir, '.dw', 'core', 'schemas', 'goal-frontmatter.schema.json');
|
|
13
|
+
if (!existsSync(path)) return null;
|
|
14
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function scanTaskParentGoalRefs(rootDir = process.cwd()) {
|
|
18
|
+
const tasksRoot = join(rootDir, TASKS_DIR);
|
|
19
|
+
if (!existsSync(tasksRoot)) return [];
|
|
20
|
+
const refs = [];
|
|
21
|
+
for (const entry of readdirSync(tasksRoot)) {
|
|
22
|
+
if (entry === 'archive' || entry === 'ACTIVE.md') continue;
|
|
23
|
+
const file = join(tasksRoot, entry, 'task.md');
|
|
24
|
+
if (!existsSync(file)) continue;
|
|
25
|
+
try {
|
|
26
|
+
const fm = parseFrontmatter(readFileSync(file, 'utf8'));
|
|
27
|
+
const primary = fm.parent_goal_id && fm.parent_goal_id !== 'none' ? fm.parent_goal_id : null;
|
|
28
|
+
const contributing = Array.isArray(fm.contributing_goal_ids) ? fm.contributing_goal_ids : [];
|
|
29
|
+
if (primary || contributing.length) {
|
|
30
|
+
refs.push({ task: entry, primary, contributing });
|
|
31
|
+
}
|
|
32
|
+
} catch { /* skip */ }
|
|
33
|
+
}
|
|
34
|
+
return refs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function goalLintCommand(goalId, opts = {}) {
|
|
38
|
+
const rootDir = process.cwd();
|
|
39
|
+
const goalSchema = loadGoalSchema(rootDir);
|
|
40
|
+
|
|
41
|
+
const ids = goalId ? [goalId] : listGoalIds(rootDir);
|
|
42
|
+
if (ids.length === 0) {
|
|
43
|
+
console.log(chalk.dim(' No goals to lint.'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let errorCount = 0;
|
|
48
|
+
let warnCount = 0;
|
|
49
|
+
let infoCount = 0;
|
|
50
|
+
let cleanCount = 0;
|
|
51
|
+
|
|
52
|
+
console.log();
|
|
53
|
+
console.log(chalk.bold(` Linting ${ids.length} goal${ids.length === 1 ? '' : 's'}:`));
|
|
54
|
+
|
|
55
|
+
const index = readGoalIndex(rootDir);
|
|
56
|
+
const allTaskRefs = scanTaskParentGoalRefs(rootDir);
|
|
57
|
+
|
|
58
|
+
for (const id of ids) {
|
|
59
|
+
const goal = readGoal(id, rootDir);
|
|
60
|
+
if (!goal) {
|
|
61
|
+
console.log(chalk.red(` ✗ ${id} — goal.md not found`));
|
|
62
|
+
errorCount++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const issues = [];
|
|
67
|
+
|
|
68
|
+
if (goalSchema) {
|
|
69
|
+
const v = validateFrontmatter(goal.fm, goalSchema);
|
|
70
|
+
if (!v.ok) {
|
|
71
|
+
for (const e of v.errors) {
|
|
72
|
+
issues.push({ level: 'error', msg: `frontmatter ${e.path}: ${e.message}` });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (goal.fm.summary && goal.fm.summary.length > MAX_SUMMARY_CHARS) {
|
|
78
|
+
issues.push({ level: 'error', msg: `summary > ${MAX_SUMMARY_CHARS} chars (got ${goal.fm.summary.length})` });
|
|
79
|
+
}
|
|
80
|
+
if (!goal.fm.summary) {
|
|
81
|
+
issues.push({ level: 'info', msg: 'no summary set — add via `dw goal summary <id> --write "..."` for agent reaction perf' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const indexEntry = index.goals && index.goals[id];
|
|
85
|
+
if (!indexEntry) {
|
|
86
|
+
issues.push({ level: 'error', msg: 'missing from goals-index.json — re-run `dw goal show ' + id + '` to repair' });
|
|
87
|
+
} else {
|
|
88
|
+
if ((indexEntry.summary || null) !== (goal.fm.summary || null)) {
|
|
89
|
+
issues.push({ level: 'error', msg: 'summary drift: goal.md ↔ goals-index.json (write via dw goal summary syncs atomically)' });
|
|
90
|
+
}
|
|
91
|
+
if ((indexEntry.status || 'Draft') !== (goal.fm.status || 'Draft')) {
|
|
92
|
+
issues.push({ level: 'error', msg: `status drift: index=${indexEntry.status} vs file=${goal.fm.status}` });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (goal.fm.parent_goal_id && goal.fm.parent_goal_id !== 'none') {
|
|
97
|
+
if (!existsSync(goalFile(goal.fm.parent_goal_id, rootDir))) {
|
|
98
|
+
issues.push({ level: 'error', msg: `dangling parent_goal_id=${goal.fm.parent_goal_id} (target goal does not exist)` });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const issue of issues) {
|
|
103
|
+
if (issue.level === 'error') errorCount++;
|
|
104
|
+
else if (issue.level === 'warn') warnCount++;
|
|
105
|
+
else infoCount++;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (issues.length === 0) {
|
|
109
|
+
console.log(chalk.green(` ✓ ${id} — clean`));
|
|
110
|
+
cleanCount++;
|
|
111
|
+
} else {
|
|
112
|
+
console.log(` ${chalk.bold(id)}`);
|
|
113
|
+
for (const issue of issues) {
|
|
114
|
+
const tag = issue.level === 'error' ? chalk.red('[error]') : issue.level === 'warn' ? chalk.yellow('[warn]') : chalk.blue('[info]');
|
|
115
|
+
console.log(` ${tag} ${issue.msg}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Reverse-lookup: task references to nonexistent goals
|
|
121
|
+
const existingGoalIds = new Set(listGoalIds(rootDir));
|
|
122
|
+
for (const ref of allTaskRefs) {
|
|
123
|
+
if (ref.primary && !existingGoalIds.has(ref.primary)) {
|
|
124
|
+
console.log(` ${chalk.bold('task:' + ref.task)}`);
|
|
125
|
+
console.log(` ${chalk.red('[error]')} parent_goal_id=${ref.primary} but goal does not exist`);
|
|
126
|
+
errorCount++;
|
|
127
|
+
}
|
|
128
|
+
for (const c of ref.contributing) {
|
|
129
|
+
if (!existingGoalIds.has(c)) {
|
|
130
|
+
console.log(` ${chalk.bold('task:' + ref.task)}`);
|
|
131
|
+
console.log(` ${chalk.red('[error]')} contributing_goal_ids includes ${c} but goal does not exist`);
|
|
132
|
+
errorCount++;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log();
|
|
138
|
+
console.log(` ${cleanCount} clean · ${warnCount} warnings · ${errorCount} errors · ${infoCount} info`);
|
|
139
|
+
console.log();
|
|
140
|
+
|
|
141
|
+
logEvent({
|
|
142
|
+
event: 'goal',
|
|
143
|
+
action: 'lint',
|
|
144
|
+
count: ids.length,
|
|
145
|
+
clean: cleanCount,
|
|
146
|
+
warnings: warnCount,
|
|
147
|
+
errors: errorCount,
|
|
148
|
+
info: infoCount,
|
|
149
|
+
}, rootDir);
|
|
150
|
+
|
|
151
|
+
if (errorCount > 0) process.exit(1);
|
|
152
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { readGoal, writeGoal, syncIndexEntry, todayIso, validateGoalId } from '../lib/goal-store.mjs';
|
|
6
|
+
import { logGoalEvent } from '../lib/goal-events.mjs';
|
|
7
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
const TEMPLATE_PATH = join(__dirname, '..', '..', '.dw', 'core', 'templates', 'v3', 'goal.md');
|
|
12
|
+
|
|
13
|
+
export async function goalNewCommand(goalId, opts = {}) {
|
|
14
|
+
const rootDir = process.cwd();
|
|
15
|
+
|
|
16
|
+
if (!goalId) {
|
|
17
|
+
console.error(chalk.red('✗ Goal ID required. Example: dw goal new G-001'));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!validateGoalId(goalId)) {
|
|
22
|
+
console.error(chalk.red(`✗ Invalid goal ID format: ${goalId}`));
|
|
23
|
+
console.error(chalk.dim(' Must match: G-{alphanumeric, ≤32 chars} (e.g. G-001, G-ship-v1)'));
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (readGoal(goalId, rootDir)) {
|
|
28
|
+
console.error(chalk.red(`✗ Goal ${goalId} already exists at .dw/goals/${goalId}/goal.md`));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!existsSync(TEMPLATE_PATH)) {
|
|
33
|
+
console.error(chalk.red(`✗ Template not found: ${TEMPLATE_PATH}`));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const title = opts.title || 'New Goal';
|
|
38
|
+
const owner = opts.owner || process.env.USER || process.env.USERNAME || 'unknown';
|
|
39
|
+
const today = todayIso();
|
|
40
|
+
const summary = opts.summary || null;
|
|
41
|
+
const icon = opts.icon || '🎯';
|
|
42
|
+
const cycle = opts.cycle || null;
|
|
43
|
+
|
|
44
|
+
const template = readFileSync(TEMPLATE_PATH, 'utf8');
|
|
45
|
+
const body = template
|
|
46
|
+
.replace(/^---\n[\s\S]*?\n---\n?/, '')
|
|
47
|
+
.replace(/\{Title\}/g, title);
|
|
48
|
+
|
|
49
|
+
const frontmatter = {
|
|
50
|
+
goal_id: goalId,
|
|
51
|
+
schema_version: 'goal@v1',
|
|
52
|
+
created: today,
|
|
53
|
+
last_updated: today,
|
|
54
|
+
status: 'Draft',
|
|
55
|
+
owner,
|
|
56
|
+
target_date: opts.targetDate || 'TBD',
|
|
57
|
+
goal_version: 1,
|
|
58
|
+
archived_at: null,
|
|
59
|
+
parent_goal_id: opts.parentGoalId || 'none',
|
|
60
|
+
icon,
|
|
61
|
+
cycle,
|
|
62
|
+
summary,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
writeGoal(goalId, frontmatter, body, rootDir);
|
|
66
|
+
syncIndexEntry(goalId, rootDir);
|
|
67
|
+
|
|
68
|
+
logGoalEvent({
|
|
69
|
+
event: 'goal_created',
|
|
70
|
+
goal_id: goalId,
|
|
71
|
+
created_by: owner,
|
|
72
|
+
}, rootDir);
|
|
73
|
+
|
|
74
|
+
logEvent({ event: 'goal', action: 'new', name: goalId }, rootDir);
|
|
75
|
+
|
|
76
|
+
console.log();
|
|
77
|
+
console.log(chalk.green(` ✓ Created goal ${goalId}`));
|
|
78
|
+
console.log(chalk.dim(` .dw/goals/${goalId}/goal.md`));
|
|
79
|
+
console.log(chalk.dim(` status: Draft · owner: ${owner} · version: 1`));
|
|
80
|
+
console.log();
|
|
81
|
+
console.log(chalk.cyan(' Next:'));
|
|
82
|
+
console.log(chalk.dim(` dw goal show ${goalId} # ANSI snapshot`));
|
|
83
|
+
console.log(chalk.dim(` dw goal link ${goalId} <task> # cross-link tasks`));
|
|
84
|
+
console.log(chalk.dim(` dw goal summary ${goalId} --write "..." # add 1000-char summary`));
|
|
85
|
+
console.log();
|
|
86
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { readGoalIndex, listGoalIds, syncIndexEntry } from '../lib/goal-store.mjs';
|
|
3
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
4
|
+
|
|
5
|
+
function statusColor(status) {
|
|
6
|
+
switch (status) {
|
|
7
|
+
case 'Active': return chalk.cyan;
|
|
8
|
+
case 'Achieved': return chalk.green;
|
|
9
|
+
case 'Abandoned': return chalk.red;
|
|
10
|
+
case 'Pivoted': return chalk.yellow;
|
|
11
|
+
default: return chalk.dim;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function goalPortfolioCommand(opts = {}) {
|
|
16
|
+
const rootDir = process.cwd();
|
|
17
|
+
|
|
18
|
+
// Repair: ensure index matches filesystem (idempotent)
|
|
19
|
+
for (const id of listGoalIds(rootDir)) {
|
|
20
|
+
syncIndexEntry(id, rootDir);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const index = readGoalIndex(rootDir);
|
|
24
|
+
const entries = Object.entries(index.goals || {});
|
|
25
|
+
|
|
26
|
+
if (entries.length === 0) {
|
|
27
|
+
console.log();
|
|
28
|
+
console.log(chalk.dim(' No goals in portfolio. Create one: dw goal new G-001'));
|
|
29
|
+
console.log();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const filter = opts.status ? opts.status : null;
|
|
34
|
+
const filtered = filter
|
|
35
|
+
? entries.filter(([, g]) => g.status === filter)
|
|
36
|
+
: entries;
|
|
37
|
+
|
|
38
|
+
console.log();
|
|
39
|
+
console.log(chalk.bold(` Goals portfolio (${filtered.length}${filter ? ` of ${entries.length}` : ''}):`));
|
|
40
|
+
console.log(chalk.dim(` Source: .dw/goals/goals-index.json (O(1) read; W-1 fix)`));
|
|
41
|
+
console.log();
|
|
42
|
+
|
|
43
|
+
// Group by cycle (named first, no-cycle last)
|
|
44
|
+
const groups = new Map();
|
|
45
|
+
for (const [id, g] of filtered) {
|
|
46
|
+
const cycle = g.cycle || '(no cycle)';
|
|
47
|
+
if (!groups.has(cycle)) groups.set(cycle, []);
|
|
48
|
+
groups.get(cycle).push([id, g]);
|
|
49
|
+
}
|
|
50
|
+
const sortedGroups = [...groups.entries()].sort(([a], [b]) => {
|
|
51
|
+
if (a === '(no cycle)') return 1;
|
|
52
|
+
if (b === '(no cycle)') return -1;
|
|
53
|
+
return a.localeCompare(b);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
for (const [cycle, items] of sortedGroups) {
|
|
57
|
+
console.log(chalk.cyan(chalk.bold(` ▸ ${cycle} `)) + chalk.dim(`(${items.length})`));
|
|
58
|
+
console.log();
|
|
59
|
+
for (const [id, g] of items) {
|
|
60
|
+
const color = statusColor(g.status);
|
|
61
|
+
const badge = color(`[${g.status || 'Draft'}]`);
|
|
62
|
+
const archived = g.archived_at ? chalk.red(' (archived)') : '';
|
|
63
|
+
const icon = g.icon || '🎯';
|
|
64
|
+
const prog = g.progress || { percent: 0, total: 0, done: 0 };
|
|
65
|
+
const progStr = prog.total > 0
|
|
66
|
+
? chalk.dim(' · ') + chalk.green(`${prog.percent}%`) + chalk.dim(` (${prog.done}/${prog.total})`)
|
|
67
|
+
: '';
|
|
68
|
+
console.log(` ${badge} ${icon} ${chalk.bold(id)} ${g.title || ''}${archived}${progStr}`);
|
|
69
|
+
console.log(` ${chalk.dim('Owner:')} ${g.owner || '?'} ${chalk.dim('·')} ${chalk.dim('Target:')} ${g.target_date || 'TBD'} ${chalk.dim('·')} ${chalk.dim('v')}${g.goal_version || 1} ${chalk.dim('·')} ${chalk.dim('Tasks:')} ${(g.linked_task_ids || []).length}`);
|
|
70
|
+
if (g.summary) {
|
|
71
|
+
console.log(` ${chalk.dim('Summary:')} ${chalk.white(g.summary.length > 200 ? g.summary.slice(0, 197) + '...' : g.summary)}`);
|
|
72
|
+
}
|
|
73
|
+
console.log();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
logEvent({
|
|
78
|
+
event: 'goal',
|
|
79
|
+
action: 'portfolio',
|
|
80
|
+
count: filtered.length,
|
|
81
|
+
total: entries.length,
|
|
82
|
+
filter,
|
|
83
|
+
}, rootDir);
|
|
84
|
+
}
|