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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { renderGoalSvg } from '../lib/goal-svg.mjs';
|
|
5
|
+
import { readGoal, listGoalIds, goalDir } from '../lib/goal-store.mjs';
|
|
6
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
7
|
+
|
|
8
|
+
export async function goalRenderCommand(goalId, opts = {}) {
|
|
9
|
+
const rootDir = process.cwd();
|
|
10
|
+
|
|
11
|
+
const ids = goalId ? [goalId] : listGoalIds(rootDir);
|
|
12
|
+
if (ids.length === 0) {
|
|
13
|
+
console.log(chalk.dim(' No goals to render.'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log();
|
|
18
|
+
console.log(chalk.bold(` Rendering ${ids.length} goal SVG${ids.length === 1 ? '' : 's'}:`));
|
|
19
|
+
|
|
20
|
+
let rendered = 0;
|
|
21
|
+
let failed = 0;
|
|
22
|
+
for (const id of ids) {
|
|
23
|
+
const goal = readGoal(id, rootDir);
|
|
24
|
+
if (!goal) {
|
|
25
|
+
console.log(chalk.yellow(` ⚠ ${id} — goal.md not found, skipping`));
|
|
26
|
+
failed++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const svg = renderGoalSvg(id, rootDir);
|
|
31
|
+
const outFile = join(goalDir(id, rootDir), 'goal.svg');
|
|
32
|
+
const dir = dirname(outFile);
|
|
33
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
34
|
+
writeFileSync(outFile, svg, 'utf8');
|
|
35
|
+
console.log(chalk.green(` ✓ ${id} → .dw/goals/${id}/goal.svg`));
|
|
36
|
+
rendered++;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.log(chalk.red(` ✗ ${id} — render failed: ${e.message}`));
|
|
39
|
+
failed++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log();
|
|
44
|
+
console.log(chalk.dim(` ${rendered} rendered · ${failed} failed`));
|
|
45
|
+
console.log(chalk.dim(` View in browser via \`dw task watch\` then navigate to /goals/<id> (SVG inlined)`));
|
|
46
|
+
console.log();
|
|
47
|
+
|
|
48
|
+
logEvent({ event: 'goal', action: 'render', count: ids.length, success: rendered, failed }, rootDir);
|
|
49
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
const SETTABLE = ['icon', 'cycle', 'owner', 'target_date', 'parent_goal_id'];
|
|
7
|
+
|
|
8
|
+
export async function goalSetCommand(goalId, opts = {}) {
|
|
9
|
+
const rootDir = process.cwd();
|
|
10
|
+
|
|
11
|
+
if (!goalId) {
|
|
12
|
+
console.error(chalk.red('✗ Usage: dw goal set <goal-id> [--icon <emoji>] [--cycle <label>] [--owner <name>] [--target-date <YYYY-MM-DD>] [--parent-goal-id <G-id>]'));
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const goal = readGoal(goalId, rootDir);
|
|
17
|
+
if (!goal) {
|
|
18
|
+
console.error(chalk.red(`✗ Goal ${goalId} not found`));
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fields = {};
|
|
23
|
+
if (opts.icon !== undefined) fields.icon = opts.icon || null;
|
|
24
|
+
if (opts.cycle !== undefined) fields.cycle = opts.cycle || null;
|
|
25
|
+
if (opts.owner !== undefined) fields.owner = opts.owner;
|
|
26
|
+
if (opts.targetDate !== undefined) fields.target_date = opts.targetDate;
|
|
27
|
+
if (opts.parentGoalId !== undefined) fields.parent_goal_id = opts.parentGoalId || 'none';
|
|
28
|
+
|
|
29
|
+
if (Object.keys(fields).length === 0) {
|
|
30
|
+
console.error(chalk.red('✗ No field provided. Use --icon, --cycle, --owner, --target-date, or --parent-goal-id'));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const changedBy = process.env.USER || process.env.USERNAME || 'unknown';
|
|
35
|
+
const before = { ...goal.fm };
|
|
36
|
+
|
|
37
|
+
updateGoalFrontmatter(goalId, (fm) => {
|
|
38
|
+
for (const [k, v] of Object.entries(fields)) fm[k] = v;
|
|
39
|
+
fm.last_updated = todayIso();
|
|
40
|
+
return fm;
|
|
41
|
+
}, rootDir);
|
|
42
|
+
syncIndexEntry(goalId, rootDir);
|
|
43
|
+
|
|
44
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
45
|
+
logGoalEvent({
|
|
46
|
+
event: 'goal_field_updated',
|
|
47
|
+
goal_id: goalId,
|
|
48
|
+
field: k,
|
|
49
|
+
old: before[k] !== undefined ? before[k] : null,
|
|
50
|
+
new: v,
|
|
51
|
+
changed_by: changedBy,
|
|
52
|
+
}, rootDir);
|
|
53
|
+
}
|
|
54
|
+
logEvent({ event: 'goal', action: 'set', name: goalId, fields: Object.keys(fields) }, rootDir);
|
|
55
|
+
|
|
56
|
+
console.log();
|
|
57
|
+
console.log(chalk.green(` ✓ Updated ${chalk.bold(goalId)}:`));
|
|
58
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
59
|
+
console.log(chalk.dim(` ${k}: ${before[k] || '∅'} → ${v || '∅'}`));
|
|
60
|
+
}
|
|
61
|
+
console.log();
|
|
62
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { readGoal, listGoalIds, findLinkedTaskIds, computeGoalProgress } from '../lib/goal-store.mjs';
|
|
3
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
4
|
+
|
|
5
|
+
function progressBar(percent, width = 24) {
|
|
6
|
+
const filled = Math.round((percent / 100) * width);
|
|
7
|
+
return chalk.green('█'.repeat(filled)) + chalk.dim('░'.repeat(width - filled)) + chalk.dim(` ${percent}%`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function statusBadge(status) {
|
|
11
|
+
const badges = {
|
|
12
|
+
Draft: chalk.dim('[Draft]'),
|
|
13
|
+
Active: chalk.cyan('[Active]'),
|
|
14
|
+
Achieved: chalk.green('[Achieved]'),
|
|
15
|
+
Abandoned: chalk.red('[Abandoned]'),
|
|
16
|
+
Pivoted: chalk.yellow('[Pivoted]'),
|
|
17
|
+
};
|
|
18
|
+
return badges[status] || chalk.dim(`[${status}]`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function goalShowCommand(goalId, opts = {}) {
|
|
22
|
+
const rootDir = process.cwd();
|
|
23
|
+
|
|
24
|
+
if (!goalId) {
|
|
25
|
+
const ids = listGoalIds(rootDir);
|
|
26
|
+
if (ids.length === 0) {
|
|
27
|
+
console.log(chalk.dim(' No goals found in .dw/goals/. Create one: dw goal new G-001'));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
console.log();
|
|
31
|
+
console.log(chalk.bold(` Goals (${ids.length}):`));
|
|
32
|
+
for (const id of ids) {
|
|
33
|
+
const goal = readGoal(id, rootDir);
|
|
34
|
+
if (!goal) continue;
|
|
35
|
+
const icon = goal.fm.icon || '🎯';
|
|
36
|
+
const prog = computeGoalProgress(id, rootDir);
|
|
37
|
+
const cycle = goal.fm.cycle ? chalk.cyan(`[${goal.fm.cycle}]`) + ' ' : '';
|
|
38
|
+
console.log(` ${statusBadge(goal.fm.status)} ${icon} ${chalk.bold(id)} ${cycle}${chalk.dim(goal.fm.summary || '(no summary)')}`);
|
|
39
|
+
if (prog.total > 0) console.log(' ' + progressBar(prog.percent) + chalk.dim(` (${prog.done}/${prog.total})`));
|
|
40
|
+
}
|
|
41
|
+
console.log();
|
|
42
|
+
console.log(chalk.dim(' Show detail: dw goal show <id>'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const goal = readGoal(goalId, rootDir);
|
|
47
|
+
if (!goal) {
|
|
48
|
+
console.error(chalk.red(`✗ Goal ${goalId} not found`));
|
|
49
|
+
console.error(chalk.dim(` Expected: .dw/goals/${goalId}/goal.md`));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const linkedTasks = findLinkedTaskIds(goalId, rootDir);
|
|
54
|
+
const prog = computeGoalProgress(goalId, rootDir, linkedTasks);
|
|
55
|
+
const icon = goal.fm.icon || '🎯';
|
|
56
|
+
|
|
57
|
+
console.log();
|
|
58
|
+
console.log(chalk.bold(` ${statusBadge(goal.fm.status)} ${icon} ${goalId}`));
|
|
59
|
+
console.log(chalk.dim(` ${goal.file}`));
|
|
60
|
+
console.log();
|
|
61
|
+
console.log(` ${chalk.bold('Owner:')} ${goal.fm.owner || 'unknown'}`);
|
|
62
|
+
console.log(` ${chalk.bold('Target date:')} ${goal.fm.target_date || 'TBD'}`);
|
|
63
|
+
if (goal.fm.cycle) console.log(` ${chalk.bold('Cycle:')} ${chalk.cyan(goal.fm.cycle)}`);
|
|
64
|
+
console.log(` ${chalk.bold('Version:')} ${goal.fm.goal_version || 1}`);
|
|
65
|
+
console.log(` ${chalk.bold('Created:')} ${goal.fm.created || '?'}`);
|
|
66
|
+
console.log(` ${chalk.bold('Last updated:')} ${goal.fm.last_updated || '?'}`);
|
|
67
|
+
if (goal.fm.archived_at) console.log(` ${chalk.bold('Archived at:')} ${chalk.red(goal.fm.archived_at)}`);
|
|
68
|
+
if (goal.fm.parent_goal_id && goal.fm.parent_goal_id !== 'none') {
|
|
69
|
+
console.log(` ${chalk.bold('Parent:')} ${goal.fm.parent_goal_id}`);
|
|
70
|
+
}
|
|
71
|
+
console.log();
|
|
72
|
+
|
|
73
|
+
if (prog.total > 0) {
|
|
74
|
+
console.log(` ${chalk.bold('Progress:')} ` + progressBar(prog.percent, 32));
|
|
75
|
+
console.log(chalk.dim(` ${prog.done} done · ${prog.in_progress} in progress · ${prog.blocked} blocked · ${prog.pending} pending (of ${prog.total} subtasks across ${linkedTasks.length} task${linkedTasks.length === 1 ? '' : 's'})`));
|
|
76
|
+
console.log();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (goal.fm.summary) {
|
|
80
|
+
console.log(chalk.bold(' Summary:'));
|
|
81
|
+
console.log(chalk.dim(' ') + chalk.white(goal.fm.summary));
|
|
82
|
+
console.log();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log(chalk.bold(` Linked tasks (${linkedTasks.length}):`));
|
|
86
|
+
if (linkedTasks.length === 0) {
|
|
87
|
+
console.log(chalk.dim(' (none — link with `dw goal link <goal-id> <task-id>`)'));
|
|
88
|
+
} else {
|
|
89
|
+
for (const t of linkedTasks) console.log(chalk.dim(' · ') + t);
|
|
90
|
+
}
|
|
91
|
+
console.log();
|
|
92
|
+
|
|
93
|
+
logEvent({ event: 'goal', action: 'show', name: goalId }, rootDir);
|
|
94
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { readGoal } from '../lib/goal-store.mjs';
|
|
3
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
4
|
+
|
|
5
|
+
export async function goalMigrateCommand(goalId, opts = {}) {
|
|
6
|
+
const rootDir = process.cwd();
|
|
7
|
+
const target = opts.to || 'goal@v2';
|
|
8
|
+
|
|
9
|
+
console.log();
|
|
10
|
+
console.log(chalk.cyan(` dw goal migrate — stub (S-1)`));
|
|
11
|
+
console.log(chalk.dim(` Target schema: ${target}`));
|
|
12
|
+
console.log(chalk.dim(` No migration paths defined yet — goal@v1 is current.`));
|
|
13
|
+
console.log(chalk.dim(` This command reserves the CLI namespace for future schema bumps.`));
|
|
14
|
+
console.log();
|
|
15
|
+
|
|
16
|
+
logEvent({ event: 'goal', action: 'migrate.stub', name: goalId || 'all', target }, rootDir);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// goalSuggestKrsCommand moved to src/commands/goal-suggest-krs.mjs (real implementation)
|
|
20
|
+
|
|
21
|
+
// goalRenderCommand moved to src/commands/goal-render.mjs (real implementation, not stub)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { readGoal, findLinkedTaskIds } from '../lib/goal-store.mjs';
|
|
5
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
6
|
+
|
|
7
|
+
const TASKS_DIR = '.dw/tasks';
|
|
8
|
+
|
|
9
|
+
function extractKRs(content) {
|
|
10
|
+
const m = content.match(/###\s+Key Results[\s\S]*?(?=^###\s+|^##\s+|\Z)/m);
|
|
11
|
+
if (!m) return [];
|
|
12
|
+
const krs = [];
|
|
13
|
+
for (const line of m[0].split('\n')) {
|
|
14
|
+
if (!/^\|\s*KR-/.test(line)) continue;
|
|
15
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
16
|
+
if (cells.length < 5) continue;
|
|
17
|
+
krs.push({ id: cells[1], description: cells[2], target: cells[3], current: cells[4], status: cells[5] || '' });
|
|
18
|
+
}
|
|
19
|
+
return krs;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readTaskTitle(taskId, rootDir) {
|
|
23
|
+
const file = join(rootDir, TASKS_DIR, taskId, 'task.md');
|
|
24
|
+
if (!existsSync(file)) return taskId;
|
|
25
|
+
try {
|
|
26
|
+
const content = readFileSync(file, 'utf8');
|
|
27
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
28
|
+
const titleMatch = content.match(/^#\s+(?:Timeline:|Spec:)?\s*(.+?)\s*$/m);
|
|
29
|
+
return titleMatch ? titleMatch[1].trim() : taskId;
|
|
30
|
+
} catch { return taskId; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function goalSuggestKrsCommand(goalId, opts = {}) {
|
|
34
|
+
const rootDir = process.cwd();
|
|
35
|
+
|
|
36
|
+
if (!goalId) {
|
|
37
|
+
console.error(chalk.red('✗ Usage: dw goal suggest-krs <goal-id> [--count N] [--json]'));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const goal = readGoal(goalId, rootDir);
|
|
42
|
+
if (!goal) {
|
|
43
|
+
console.error(chalk.red(`✗ Goal ${goalId} not found`));
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const linkedTasks = findLinkedTaskIds(goalId, rootDir);
|
|
48
|
+
const existingKRs = extractKRs(goal.content);
|
|
49
|
+
const count = parseInt(opts.count || '3', 10);
|
|
50
|
+
|
|
51
|
+
const context = {
|
|
52
|
+
goal_id: goalId,
|
|
53
|
+
icon: goal.fm.icon || '🎯',
|
|
54
|
+
title: extractTitle(goal.content) || goalId,
|
|
55
|
+
status: goal.fm.status || 'Draft',
|
|
56
|
+
cycle: goal.fm.cycle || null,
|
|
57
|
+
target_date: goal.fm.target_date || null,
|
|
58
|
+
summary: goal.fm.summary || null,
|
|
59
|
+
existing_krs: existingKRs,
|
|
60
|
+
linked_tasks: linkedTasks.map((t) => ({ id: t, title: readTaskTitle(t, rootDir) })),
|
|
61
|
+
requested_count: count,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (opts.json) {
|
|
65
|
+
console.log(JSON.stringify({
|
|
66
|
+
context,
|
|
67
|
+
smart_template: SMART_TEMPLATE,
|
|
68
|
+
paste_target: `.dw/goals/${goalId}/goal.md Section 3 Key Results table`,
|
|
69
|
+
skill_pointer: '.claude/skills/dw-goal-sync/SKILL.md',
|
|
70
|
+
}, null, 2));
|
|
71
|
+
logEvent({ event: 'goal', action: 'suggest-krs.json', name: goalId }, rootDir);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log();
|
|
76
|
+
console.log(chalk.bold(` KR brainstorm context for ${chalk.cyan(goalId)}`));
|
|
77
|
+
console.log(chalk.dim(' Pipe this into Claude/Codex/Gemini chat with the SMART template; paste returned'));
|
|
78
|
+
console.log(chalk.dim(` KRs into .dw/goals/${goalId}/goal.md Section 3 (Key Results table).`));
|
|
79
|
+
console.log();
|
|
80
|
+
console.log(chalk.bold(' ━━━━━━━━━━━━━━━━━━━━━━ COPY BELOW ━━━━━━━━━━━━━━━━━━━━━━'));
|
|
81
|
+
console.log();
|
|
82
|
+
console.log(buildPrompt(context, count));
|
|
83
|
+
console.log();
|
|
84
|
+
console.log(chalk.bold(' ━━━━━━━━━━━━━━━━━━━━━━ END COPY ━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
85
|
+
console.log();
|
|
86
|
+
console.log(chalk.dim(` Tip: \`dw goal suggest-krs ${goalId} --json\` returns machine-readable context.`));
|
|
87
|
+
console.log();
|
|
88
|
+
|
|
89
|
+
logEvent({ event: 'goal', action: 'suggest-krs', name: goalId, existing_krs: existingKRs.length, count }, rootDir);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractTitle(content) {
|
|
93
|
+
const m = content.match(/^#\s+Goal:\s+(.+?)\s*$/m) || content.match(/^#\s+(.+?)\s*$/m);
|
|
94
|
+
return m ? m[1].trim() : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const SMART_TEMPLATE = `Each KR must satisfy SMART:
|
|
98
|
+
- Specific: names the exact outcome (not "improve X")
|
|
99
|
+
- Measurable: has a numeric metric and unit (≥, %, count, time)
|
|
100
|
+
- Achievable: within owner's control + reasonable in the cycle
|
|
101
|
+
- Relevant: clearly serves the goal statement above
|
|
102
|
+
- Time-bound: ties to the goal's target_date or shorter milestone`;
|
|
103
|
+
|
|
104
|
+
function buildPrompt(ctx, count) {
|
|
105
|
+
const existing = ctx.existing_krs.length > 0
|
|
106
|
+
? '\nExisting KRs (avoid duplication):\n' + ctx.existing_krs.map((kr) => ` - ${kr.id}: ${kr.description} (target ${kr.target}, current ${kr.current})`).join('\n')
|
|
107
|
+
: '\nNo Key Results defined yet.';
|
|
108
|
+
|
|
109
|
+
const tasks = ctx.linked_tasks.length > 0
|
|
110
|
+
? '\nLinked tasks (signals about scope):\n' + ctx.linked_tasks.map((t) => ` - ${t.id}: ${t.title}`).join('\n')
|
|
111
|
+
: '';
|
|
112
|
+
|
|
113
|
+
const cycleLine = ctx.cycle ? `Cycle: ${ctx.cycle}\n` : '';
|
|
114
|
+
const targetLine = ctx.target_date ? `Target date: ${ctx.target_date}\n` : '';
|
|
115
|
+
|
|
116
|
+
return `## Goal context
|
|
117
|
+
|
|
118
|
+
${ctx.icon} ${ctx.goal_id} — ${ctx.title}
|
|
119
|
+
Status: ${ctx.status}
|
|
120
|
+
${cycleLine}${targetLine}
|
|
121
|
+
Summary:
|
|
122
|
+
${ctx.summary || '(no summary set — consider adding one before drafting KRs)'}
|
|
123
|
+
${existing}${tasks}
|
|
124
|
+
|
|
125
|
+
## Task
|
|
126
|
+
|
|
127
|
+
Propose ${count} Key Results for this goal.
|
|
128
|
+
|
|
129
|
+
${SMART_TEMPLATE}
|
|
130
|
+
|
|
131
|
+
Return KRs as a markdown table ready to paste into goal.md Section 3:
|
|
132
|
+
|
|
133
|
+
| # | Key Result | Target | Current | Status | Notes |
|
|
134
|
+
|---|-----------|--------|---------|--------|-------|
|
|
135
|
+
| KR-XXX | … | … | 0% | ⬜ Pending | … |
|
|
136
|
+
|
|
137
|
+
After the table, briefly justify each KR (1-2 sentences) explaining how it satisfies
|
|
138
|
+
each SMART letter.`;
|
|
139
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
const MAX_SUMMARY_CHARS = 1000;
|
|
7
|
+
|
|
8
|
+
export async function goalSummaryCommand(goalId, opts = {}) {
|
|
9
|
+
const rootDir = process.cwd();
|
|
10
|
+
|
|
11
|
+
if (!goalId) {
|
|
12
|
+
console.error(chalk.red('✗ Usage: dw goal summary <goal-id> [--write "..."]'));
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const goal = readGoal(goalId, rootDir);
|
|
17
|
+
if (!goal) {
|
|
18
|
+
console.error(chalk.red(`✗ Goal ${goalId} not found`));
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (opts.write !== undefined) {
|
|
23
|
+
if (opts.write.length > MAX_SUMMARY_CHARS) {
|
|
24
|
+
console.error(chalk.red(`✗ Summary exceeds ${MAX_SUMMARY_CHARS} chars (got ${opts.write.length})`));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const oldSummary = goal.fm.summary || null;
|
|
28
|
+
const newSummary = opts.write || null;
|
|
29
|
+
|
|
30
|
+
updateGoalFrontmatter(goalId, (fm) => {
|
|
31
|
+
fm.summary = newSummary;
|
|
32
|
+
fm.last_updated = todayIso();
|
|
33
|
+
return fm;
|
|
34
|
+
}, rootDir);
|
|
35
|
+
syncIndexEntry(goalId, rootDir);
|
|
36
|
+
|
|
37
|
+
logGoalEvent({
|
|
38
|
+
event: 'goal_field_updated',
|
|
39
|
+
goal_id: goalId,
|
|
40
|
+
field: 'summary',
|
|
41
|
+
old: oldSummary,
|
|
42
|
+
new: newSummary,
|
|
43
|
+
changed_by: process.env.USER || process.env.USERNAME || 'unknown',
|
|
44
|
+
}, rootDir);
|
|
45
|
+
|
|
46
|
+
logEvent({ event: 'goal', action: 'summary.write', name: goalId, length: (newSummary || '').length }, rootDir);
|
|
47
|
+
|
|
48
|
+
console.log();
|
|
49
|
+
console.log(chalk.green(` ✓ Summary updated for ${chalk.bold(goalId)} (${(newSummary || '').length}/${MAX_SUMMARY_CHARS} chars)`));
|
|
50
|
+
console.log(chalk.dim(` Mirrored to .dw/goals/goals-index.json`));
|
|
51
|
+
console.log();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log();
|
|
56
|
+
if (!goal.fm.summary) {
|
|
57
|
+
console.log(chalk.dim(` (no summary set for ${goalId})`));
|
|
58
|
+
console.log(chalk.dim(` Add with: dw goal summary ${goalId} --write "..."`));
|
|
59
|
+
} else {
|
|
60
|
+
console.log(chalk.bold(` Summary for ${goalId} (${goal.fm.summary.length}/${MAX_SUMMARY_CHARS} chars):`));
|
|
61
|
+
console.log();
|
|
62
|
+
console.log(chalk.white(goal.fm.summary));
|
|
63
|
+
}
|
|
64
|
+
console.log();
|
|
65
|
+
|
|
66
|
+
logEvent({ event: 'goal', action: 'summary.read', name: goalId }, rootDir);
|
|
67
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { readGoalIndex, listGoalIds, syncIndexEntry } from '../lib/goal-store.mjs';
|
|
6
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
7
|
+
|
|
8
|
+
const CACHE_DIR = '.dw/cache/preview';
|
|
9
|
+
|
|
10
|
+
function statusBg(status) {
|
|
11
|
+
switch (status) {
|
|
12
|
+
case 'Active': return '#0ea5e9';
|
|
13
|
+
case 'Achieved': return '#10b981';
|
|
14
|
+
case 'Abandoned': return '#ef4444';
|
|
15
|
+
case 'Pivoted': return '#f59e0b';
|
|
16
|
+
default: return '#6b7280';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function esc(s) {
|
|
21
|
+
if (s == null) return '';
|
|
22
|
+
return String(s)
|
|
23
|
+
.replace(/&/g, '&')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(/"/g, '"')
|
|
27
|
+
.replace(/'/g, ''');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function statusColorBar(status) {
|
|
31
|
+
switch (status) {
|
|
32
|
+
case 'Active': return '#0ea5e9';
|
|
33
|
+
case 'Achieved': return '#10b981';
|
|
34
|
+
case 'Abandoned': return '#ef4444';
|
|
35
|
+
case 'Pivoted': return '#f59e0b';
|
|
36
|
+
default: return '#6b7280';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderCard(id, g) {
|
|
41
|
+
const linked = (g.linked_task_ids || []).length;
|
|
42
|
+
const archived = g.archived_at ? '<span class="archived">archived</span>' : '';
|
|
43
|
+
const icon = g.icon || '🎯';
|
|
44
|
+
const prog = g.progress || { percent: 0, total: 0, done: 0, in_progress: 0, blocked: 0, pending: 0 };
|
|
45
|
+
const progLabel = prog.total > 0
|
|
46
|
+
? `<div class="progress-row"><div class="progress-bar"><div class="progress-fill" style="width:${prog.percent}%;background:${statusColorBar(g.status)}"></div></div><span class="progress-label">${prog.percent}% · ${prog.done}/${prog.total}</span></div>`
|
|
47
|
+
: '<div class="progress-row muted">No linked subtasks yet</div>';
|
|
48
|
+
return `
|
|
49
|
+
<a class="card-link" href="/goals/${esc(id)}">
|
|
50
|
+
<article class="card">
|
|
51
|
+
<header>
|
|
52
|
+
<span class="icon">${esc(icon)}</span>
|
|
53
|
+
<span class="badge" style="background:${statusBg(g.status)}">${esc(g.status || 'Draft')}</span>
|
|
54
|
+
<h2>${esc(id)}</h2>
|
|
55
|
+
${archived}
|
|
56
|
+
<span class="edit-link" title="Open editor"><a href="/goals/${esc(id)}/edit" onclick="event.stopPropagation()">✎</a></span>
|
|
57
|
+
</header>
|
|
58
|
+
<h3>${esc(g.title || '')}</h3>
|
|
59
|
+
${g.summary ? `<p class="summary">${esc(g.summary)}</p>` : '<p class="summary muted">(no summary)</p>'}
|
|
60
|
+
${progLabel}
|
|
61
|
+
<footer>
|
|
62
|
+
<span>${esc(g.owner || '?')}</span>
|
|
63
|
+
<span>·</span>
|
|
64
|
+
<span>${esc(g.target_date || 'TBD')}</span>
|
|
65
|
+
<span>·</span>
|
|
66
|
+
<span>v${esc(g.goal_version || 1)}</span>
|
|
67
|
+
<span>·</span>
|
|
68
|
+
<span>${linked} task${linked === 1 ? '' : 's'}</span>
|
|
69
|
+
</footer>
|
|
70
|
+
</article>
|
|
71
|
+
</a>`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function groupByCycle(entries) {
|
|
75
|
+
const groups = new Map();
|
|
76
|
+
for (const [id, g] of entries) {
|
|
77
|
+
const cycle = g.cycle || '(no cycle)';
|
|
78
|
+
if (!groups.has(cycle)) groups.set(cycle, []);
|
|
79
|
+
groups.get(cycle).push([id, g]);
|
|
80
|
+
}
|
|
81
|
+
// Sort: named cycles first (alphabetical), no-cycle last
|
|
82
|
+
return [...groups.entries()].sort(([a], [b]) => {
|
|
83
|
+
if (a === '(no cycle)') return 1;
|
|
84
|
+
if (b === '(no cycle)') return -1;
|
|
85
|
+
return a.localeCompare(b);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderGoalPortfolioHtml(index) {
|
|
90
|
+
const entries = Object.entries(index.goals || {});
|
|
91
|
+
const total = entries.length;
|
|
92
|
+
const groups = groupByCycle(entries);
|
|
93
|
+
|
|
94
|
+
const sections = groups.map(([cycle, items]) => {
|
|
95
|
+
const cards = items.map(([id, g]) => renderCard(id, g)).join('\n');
|
|
96
|
+
return `
|
|
97
|
+
<section class="cycle">
|
|
98
|
+
<h2 class="cycle-title">${esc(cycle)} <span class="cycle-count">${items.length}</span></h2>
|
|
99
|
+
<div class="grid">${cards}</div>
|
|
100
|
+
</section>`;
|
|
101
|
+
}).join('\n');
|
|
102
|
+
|
|
103
|
+
return `<!doctype html>
|
|
104
|
+
<html><head>
|
|
105
|
+
<meta charset="utf-8">
|
|
106
|
+
<title>dw goal view — portfolio (${total})</title>
|
|
107
|
+
<style>
|
|
108
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
109
|
+
body { font: 14px/1.5 -apple-system, sans-serif; background: #0d1117; color: #c9d1d9; padding: 32px; }
|
|
110
|
+
@media (prefers-color-scheme: light) { body { background: #fff; color: #1f2328; } }
|
|
111
|
+
h1 { font-size: 24px; margin-bottom: 8px; }
|
|
112
|
+
.meta { color: #6b7280; font-size: 12px; margin-bottom: 24px; }
|
|
113
|
+
.cycle { margin-bottom: 32px; }
|
|
114
|
+
.cycle-title { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; color: #58a6ff; margin-bottom: 12px; display: flex; align-items: center; gap: 10px; padding-bottom: 6px; border-bottom: 1px solid #30363d; }
|
|
115
|
+
@media (prefers-color-scheme: light) { .cycle-title { border-bottom-color: #d0d7de; } }
|
|
116
|
+
.cycle-count { font-weight: 400; color: #6b7280; font-size: 12px; letter-spacing: 0; }
|
|
117
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; }
|
|
118
|
+
.card-link { text-decoration: none; color: inherit; display: block; }
|
|
119
|
+
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; transition: border-color 0.15s, transform 0.15s; }
|
|
120
|
+
.card-link:hover .card { border-color: #58a6ff; transform: translateY(-2px); }
|
|
121
|
+
@media (prefers-color-scheme: light) { .card { background: #f6f8fa; border-color: #d0d7de; } }
|
|
122
|
+
.edit-link { margin-left: auto; font-size: 16px; }
|
|
123
|
+
.edit-link a { color: #6b7280; text-decoration: none; padding: 2px 6px; border-radius: 4px; }
|
|
124
|
+
.edit-link a:hover { color: #58a6ff; background: rgba(88,166,255,0.12); }
|
|
125
|
+
.card header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
126
|
+
.card .icon { font-size: 20px; line-height: 1; }
|
|
127
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; color: white; letter-spacing: 0.5px; }
|
|
128
|
+
.archived { padding: 2px 6px; background: rgba(220,38,38,0.18); color: #ef4444; border-radius: 4px; font-size: 10px; font-weight: 600; margin-left: auto; }
|
|
129
|
+
.card h2 { font-size: 14px; font-family: ui-monospace, monospace; }
|
|
130
|
+
.card h3 { font-size: 13px; font-weight: 400; margin-bottom: 12px; color: #8b949e; }
|
|
131
|
+
@media (prefers-color-scheme: light) { .card h3 { color: #57606a; } }
|
|
132
|
+
.summary { font-size: 13px; line-height: 1.6; margin-bottom: 12px; }
|
|
133
|
+
.summary.muted { color: #6b7280; font-style: italic; }
|
|
134
|
+
.progress-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 11px; }
|
|
135
|
+
.progress-row.muted { color: #6b7280; font-style: italic; padding: 4px 0; }
|
|
136
|
+
.progress-bar { flex: 1; height: 6px; background: rgba(107,114,128,0.25); border-radius: 3px; overflow: hidden; }
|
|
137
|
+
.progress-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
|
138
|
+
.progress-label { color: #8b949e; font-family: ui-monospace, monospace; font-size: 10px; min-width: 70px; text-align: right; }
|
|
139
|
+
.card footer { display: flex; gap: 8px; font-size: 11px; color: #6b7280; flex-wrap: wrap; }
|
|
140
|
+
.empty { text-align: center; padding: 64px; color: #6b7280; }
|
|
141
|
+
</style>
|
|
142
|
+
</head><body>
|
|
143
|
+
<h1>🎯 Goals portfolio</h1>
|
|
144
|
+
<div class="meta">${total} goal${total === 1 ? '' : 's'} across ${groups.length} cycle${groups.length === 1 ? '' : 's'} · source: .dw/goals/goals-index.json · ${esc(index.last_updated || '')}</div>
|
|
145
|
+
${total === 0 ? '<div class="empty">No goals yet. Create one with <code>dw goal new G-001 --icon "🚀" --cycle "Q2 2026"</code></div>' : sections}
|
|
146
|
+
</body></html>
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function openInBrowser(url) {
|
|
151
|
+
const platform = process.platform;
|
|
152
|
+
try {
|
|
153
|
+
if (platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore', shell: true });
|
|
154
|
+
else if (platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
155
|
+
else execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
|
156
|
+
return true;
|
|
157
|
+
} catch { return false; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function goalViewCommand(goalId, opts = {}) {
|
|
161
|
+
const rootDir = process.cwd();
|
|
162
|
+
|
|
163
|
+
// Repair index from filesystem (idempotent)
|
|
164
|
+
for (const id of listGoalIds(rootDir)) {
|
|
165
|
+
syncIndexEntry(id, rootDir);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const index = readGoalIndex(rootDir);
|
|
169
|
+
const html = renderGoalPortfolioHtml(index);
|
|
170
|
+
|
|
171
|
+
const outDir = join(rootDir, CACHE_DIR);
|
|
172
|
+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
173
|
+
const outFile = join(outDir, 'goals-portfolio.html');
|
|
174
|
+
writeFileSync(outFile, html, 'utf8');
|
|
175
|
+
|
|
176
|
+
console.log();
|
|
177
|
+
console.log(chalk.green(` ✓ Goals portfolio HTML: ${outFile}`));
|
|
178
|
+
console.log(chalk.dim(` ${Object.keys(index.goals || {}).length} goals rendered`));
|
|
179
|
+
|
|
180
|
+
if (opts.open !== false && !opts.noOpen) {
|
|
181
|
+
const fileUrl = process.platform === 'win32'
|
|
182
|
+
? `file:///${outFile.replace(/\\/g, '/')}`
|
|
183
|
+
: `file://${outFile}`;
|
|
184
|
+
if (openInBrowser(fileUrl)) {
|
|
185
|
+
console.log(chalk.dim(` Opening in browser...`));
|
|
186
|
+
} else {
|
|
187
|
+
console.log(chalk.dim(` Open manually: ${fileUrl}`));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log();
|
|
192
|
+
console.log(chalk.dim(` Live update: serve via \`dw task watch\` (extends /goals route)`));
|
|
193
|
+
console.log();
|
|
194
|
+
|
|
195
|
+
logEvent({ event: 'goal', action: 'view.invoke', count: Object.keys(index.goals || {}).length }, rootDir);
|
|
196
|
+
}
|