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.
Files changed (65) hide show
  1. package/.claude/agents/executor.md +80 -80
  2. package/.claude/hooks/pre-commit-gate.sh +59 -0
  3. package/.claude/hooks/stop-check.sh +111 -31
  4. package/.claude/rules/commit-standards.md +48 -37
  5. package/.claude/rules/dw.md +47 -11
  6. package/.claude/skills/dw-commit/SKILL.md +7 -4
  7. package/.claude/skills/dw-decision/SKILL.md +5 -4
  8. package/.claude/skills/dw-execute/SKILL.md +18 -5
  9. package/.claude/skills/dw-handoff/SKILL.md +8 -3
  10. package/.claude/skills/dw-plan/SKILL.md +15 -2
  11. package/.claude/skills/dw-research/SKILL.md +7 -5
  12. package/.claude/skills/dw-retroactive/SKILL.md +75 -63
  13. package/.claude/skills/dw-task-init/SKILL.md +40 -35
  14. package/.dw/adapters/generic/AGENT.md +171 -169
  15. package/.dw/core/WORKFLOW.md +450 -450
  16. package/.dw/core/schemas/agent-claim.schema.json +127 -0
  17. package/.dw/core/schemas/agent-report.schema.json +72 -0
  18. package/.dw/core/schemas/goal-frontmatter.schema.json +84 -0
  19. package/.dw/core/schemas/task-frontmatter.schema.json +97 -0
  20. package/.dw/core/templates/v3/goal.md +146 -0
  21. package/.dw/core/templates/v3/task.md +188 -0
  22. package/CLAUDE.md +2 -2
  23. package/MIGRATION-v1.5.md +330 -0
  24. package/README.md +17 -0
  25. package/package.json +3 -2
  26. package/src/cli.mjs +312 -0
  27. package/src/commands/agent-claim.mjs +235 -0
  28. package/src/commands/agent-inspect.mjs +123 -0
  29. package/src/commands/doctor.mjs +64 -0
  30. package/src/commands/goal-bump.mjs +50 -0
  31. package/src/commands/goal-delete.mjs +120 -0
  32. package/src/commands/goal-link.mjs +126 -0
  33. package/src/commands/goal-lint.mjs +152 -0
  34. package/src/commands/goal-new.mjs +86 -0
  35. package/src/commands/goal-portfolio.mjs +84 -0
  36. package/src/commands/goal-render.mjs +49 -0
  37. package/src/commands/goal-set.mjs +62 -0
  38. package/src/commands/goal-show.mjs +94 -0
  39. package/src/commands/goal-stubs.mjs +21 -0
  40. package/src/commands/goal-suggest-krs.mjs +139 -0
  41. package/src/commands/goal-summary.mjs +67 -0
  42. package/src/commands/goal-view.mjs +196 -0
  43. package/src/commands/lint-task.mjs +112 -0
  44. package/src/commands/task-migrate.mjs +471 -0
  45. package/src/commands/task-new.mjs +90 -0
  46. package/src/commands/task-render.mjs +235 -0
  47. package/src/commands/task-rotate.mjs +168 -0
  48. package/src/commands/task-show.mjs +137 -0
  49. package/src/commands/task-summary.mjs +68 -0
  50. package/src/commands/task-view.mjs +386 -0
  51. package/src/commands/task-watch.mjs +868 -0
  52. package/src/lib/active-index.mjs +19 -1
  53. package/src/lib/agent-claim.mjs +173 -0
  54. package/src/lib/agent-conflict.mjs +137 -0
  55. package/src/lib/agent-events.mjs +43 -0
  56. package/src/lib/agent-report.mjs +96 -0
  57. package/src/lib/frontmatter.mjs +72 -0
  58. package/src/lib/goal-events.mjs +79 -0
  59. package/src/lib/goal-store.mjs +202 -0
  60. package/src/lib/goal-svg.mjs +293 -0
  61. package/src/lib/goal-watch.mjs +133 -0
  62. package/src/lib/lint-rules.mjs +149 -0
  63. package/src/lib/sse-broker.mjs +91 -0
  64. package/src/lib/timeline-parser.mjs +80 -0
  65. package/src/lib/watch-auth.mjs +64 -0
@@ -0,0 +1,149 @@
1
+ import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parseFrontmatter, loadSchema, validateFrontmatter } from './frontmatter.mjs';
4
+ import { parseTimeline } from './timeline-parser.mjs';
5
+
6
+ const DRIFT_MARKERS = [
7
+ /✅\s*(COMPLETED|DONE)/i,
8
+ /🟡\s*(IN[\s_-]?PROGRESS|WIP)/i,
9
+ /\b(?:Status|Trạng thái):\s*(?:Done|Completed|In Progress|Blocked)\b/i,
10
+ /^\s*\*\*ST-\d+:.*?(?:✅|🟡|🔴|⏸)/m,
11
+ ];
12
+
13
+ const GENERIC_APPENDAGE_NAMES = new Set([
14
+ 'notes.md',
15
+ 'extra.md',
16
+ 'extra-2.md',
17
+ 'temp.md',
18
+ 'tmp.md',
19
+ 'wip.md',
20
+ 'misc.md',
21
+ 'todo.md',
22
+ ]);
23
+
24
+ const SECTION_LIMITS = {
25
+ 2: 200,
26
+ 3: 80,
27
+ 4: 400,
28
+ };
29
+
30
+ export function lintTimeline(taskDir, opts = {}) {
31
+ const violations = [];
32
+ const timelineFile = join(taskDir, 'task.md');
33
+
34
+ if (!existsSync(timelineFile)) {
35
+ return { ok: false, violations: [{ severity: 'error', rule: 'file', message: 'task.md not found', path: taskDir }] };
36
+ }
37
+
38
+ const content = readFileSync(timelineFile, 'utf8');
39
+ const fm = parseFrontmatter(content);
40
+ const parsed = parseTimeline(content);
41
+
42
+ const schema = loadSchema(process.cwd());
43
+ if (schema) {
44
+ const result = validateFrontmatter(fm, schema);
45
+ if (!result.ok) {
46
+ for (const e of result.errors) {
47
+ violations.push({
48
+ severity: 'error',
49
+ rule: 'frontmatter-schema',
50
+ message: `${e.path}: ${e.message}${e.keyword ? ` (${e.keyword})` : ''}`,
51
+ file: timelineFile,
52
+ });
53
+ }
54
+ }
55
+ }
56
+
57
+ const section2 = parsed.sections[2];
58
+ if (section2) {
59
+ const stripped = stripCodeAndQuotes(section2.text);
60
+ for (const re of DRIFT_MARKERS) {
61
+ if (re.test(stripped)) {
62
+ violations.push({
63
+ severity: 'error',
64
+ rule: 'section2-status-leak',
65
+ message: `Status markers in Section 2 (Intent & Scope). Status lives only in Section 3 (Tracker).`,
66
+ file: timelineFile,
67
+ line: findFirstMatchLine(content, re),
68
+ });
69
+ break;
70
+ }
71
+ }
72
+ }
73
+
74
+ for (const [num, max] of Object.entries(SECTION_LIMITS)) {
75
+ const sec = parsed.sections[Number(num)];
76
+ if (!sec) continue;
77
+ const lines = sec.text.split('\n').length;
78
+ if (lines > max) {
79
+ violations.push({
80
+ severity: num === '4' ? 'warning' : 'warning',
81
+ rule: 'section-line-cap',
82
+ message: `Section ${num} (${sec.name}) is ${lines} lines (limit ${max}). ${num === '4' ? 'Auto-rotate to timeline-history.md via stop-check hook.' : 'Consider trimming.'}`,
83
+ file: timelineFile,
84
+ });
85
+ }
86
+ }
87
+
88
+ const section3 = parsed.sections[3];
89
+ if (section3 && !/Status legend/i.test(section3.text)) {
90
+ violations.push({
91
+ severity: 'warning',
92
+ rule: 'tracker-legend-missing',
93
+ message: 'Section 3 missing "Status legend" line. Add: Status legend: ⬜ Pending · 🟡 In Progress · ✅ Done · 🔴 Blocked · ⏸ Paused',
94
+ file: timelineFile,
95
+ });
96
+ }
97
+
98
+ try {
99
+ const files = readdirSync(taskDir);
100
+ for (const f of files) {
101
+ if (GENERIC_APPENDAGE_NAMES.has(f.toLowerCase())) {
102
+ violations.push({
103
+ severity: 'warning',
104
+ rule: 'generic-appendage-name',
105
+ message: `Generic appendage name "${f}" — prefer specific names like baseline.md, experiment-{id}.md, debate-r{N}.md`,
106
+ file: join(taskDir, f),
107
+ });
108
+ }
109
+ }
110
+ } catch { /* skip */ }
111
+
112
+ if (parsed.body.includes('![Timeline](./timeline.svg)') === false && parsed.body.includes('timeline.svg') === false) {
113
+ violations.push({
114
+ severity: 'warning',
115
+ rule: 'svg-reference-missing',
116
+ message: 'Section 1 missing `![Timeline](./timeline.svg)` reference. Human GitHub preview will not render visual.',
117
+ file: timelineFile,
118
+ });
119
+ }
120
+
121
+ return { ok: violations.every((v) => v.severity !== 'error'), violations };
122
+ }
123
+
124
+ function findFirstMatchLine(content, re) {
125
+ const lines = content.split('\n');
126
+ for (let i = 0; i < lines.length; i++) {
127
+ if (re.test(stripCodeAndQuotes(lines[i]))) return i + 1;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function stripCodeAndQuotes(text) {
133
+ return text
134
+ .replace(/```[\s\S]*?```/g, '')
135
+ .replace(/`[^`\n]*`/g, '')
136
+ .replace(/^\s*>\s.*$/gm, '');
137
+ }
138
+
139
+ export function findAllTaskDirs(rootDir) {
140
+ const tasksRoot = join(rootDir, '.dw', 'tasks');
141
+ if (!existsSync(tasksRoot)) return [];
142
+ return readdirSync(tasksRoot)
143
+ .filter((e) => e !== 'archive' && e !== 'ACTIVE.md')
144
+ .map((e) => join(tasksRoot, e))
145
+ .filter((p) => {
146
+ try { return statSync(p).isDirectory() && existsSync(join(p, 'task.md')); }
147
+ catch { return false; }
148
+ });
149
+ }
@@ -0,0 +1,91 @@
1
+ import { existsSync, readFileSync, statSync, watch as fsWatch } from 'node:fs';
2
+ import { eventsGlobalFile, readGoalEvents } from './goal-events.mjs';
3
+
4
+ const MAX_CLIENTS_DEFAULT = 10;
5
+ const KEEPALIVE_MS = 30 * 1000;
6
+ const INITIAL_BACKFILL = 100;
7
+
8
+ export function createSseBroker(rootDir = process.cwd(), { maxClients = MAX_CLIENTS_DEFAULT } = {}) {
9
+ const clients = new Set();
10
+ let lastByteRead = 0;
11
+ let watcher = null;
12
+ let keepaliveTimer = null;
13
+
14
+ function broadcast(line) {
15
+ const frame = `data: ${line}\n\n`;
16
+ for (const res of clients) {
17
+ try { res.write(frame); } catch { /* dead conn; cleanup via close handler */ }
18
+ }
19
+ }
20
+
21
+ function startWatcher() {
22
+ const file = eventsGlobalFile(rootDir);
23
+ if (!existsSync(file)) return;
24
+ try { lastByteRead = statSync(file).size; } catch { lastByteRead = 0; }
25
+ try {
26
+ watcher = fsWatch(file, { persistent: true }, () => {
27
+ try {
28
+ const content = readFileSync(file, 'utf8');
29
+ if (content.length <= lastByteRead) {
30
+ lastByteRead = content.length;
31
+ return;
32
+ }
33
+ const newSegment = content.slice(lastByteRead);
34
+ lastByteRead = content.length;
35
+ const lines = newSegment.split('\n').filter(Boolean);
36
+ for (const line of lines) broadcast(line);
37
+ } catch { /* read error; skip cycle */ }
38
+ });
39
+ } catch { /* fs.watch unsupported on some platforms; SSE will only emit on new connections via backfill */ }
40
+ }
41
+
42
+ function startKeepalive() {
43
+ keepaliveTimer = setInterval(() => {
44
+ for (const res of clients) {
45
+ try { res.write(': ping\n\n'); } catch { /* cleanup via close */ }
46
+ }
47
+ }, KEEPALIVE_MS);
48
+ }
49
+
50
+ function addClient(req, res) {
51
+ if (clients.size >= maxClients) {
52
+ res.writeHead(503, { 'Content-Type': 'application/json' });
53
+ res.end(JSON.stringify({ error: 'max_clients', detail: `Max ${maxClients} SSE clients (W-4 cap)` }));
54
+ return false;
55
+ }
56
+ res.writeHead(200, {
57
+ 'Content-Type': 'text/event-stream',
58
+ 'Cache-Control': 'no-store, no-transform',
59
+ 'Connection': 'keep-alive',
60
+ 'X-Accel-Buffering': 'no',
61
+ });
62
+ res.write('retry: 5000\n\n');
63
+
64
+ // Initial backfill
65
+ const events = readGoalEvents(rootDir, { limit: INITIAL_BACKFILL });
66
+ for (const ev of events) {
67
+ try { res.write(`data: ${JSON.stringify(ev)}\n\n`); } catch { break; }
68
+ }
69
+
70
+ clients.add(res);
71
+ const cleanup = () => { clients.delete(res); };
72
+ res.on('close', cleanup);
73
+ res.on('error', cleanup);
74
+
75
+ if (!watcher) startWatcher();
76
+ if (!keepaliveTimer) startKeepalive();
77
+
78
+ return true;
79
+ }
80
+
81
+ function shutdown() {
82
+ if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; }
83
+ if (watcher) { try { watcher.close(); } catch { /* ignore */ } watcher = null; }
84
+ for (const res of clients) {
85
+ try { res.end(); } catch { /* ignore */ }
86
+ }
87
+ clients.clear();
88
+ }
89
+
90
+ return { addClient, shutdown, clientCount: () => clients.size };
91
+ }
@@ -0,0 +1,80 @@
1
+ import { extractFrontmatterBlock } from './frontmatter.mjs';
2
+
3
+ const SECTION_RE = /^##\s+(\d+)\.\s+(.+?)\s*$/gm;
4
+
5
+ export function parseTimeline(content) {
6
+ const { body, raw, hasFrontmatter } = extractFrontmatterBlock(content);
7
+
8
+ const titleMatch = body.match(/^#\s+(.+?)\s*$/m);
9
+ const title = titleMatch ? titleMatch[1] : '';
10
+
11
+ const sections = {};
12
+ const matches = [...body.matchAll(SECTION_RE)];
13
+ for (let i = 0; i < matches.length; i++) {
14
+ const m = matches[i];
15
+ const num = Number(m[1]);
16
+ const name = m[2];
17
+ const start = m.index + m[0].length;
18
+ const end = i + 1 < matches.length ? matches[i + 1].index : body.length;
19
+ const text = body.slice(start, end).trim();
20
+ sections[num] = { num, name, text };
21
+ }
22
+
23
+ return {
24
+ frontmatter: hasFrontmatter ? raw : '',
25
+ title,
26
+ body,
27
+ sections,
28
+ hasFrontmatter,
29
+ };
30
+ }
31
+
32
+ export function parseSubtaskTracker(sectionText) {
33
+ if (!sectionText) return [];
34
+ const lines = sectionText.split('\n').map((l) => l.trim()).filter(Boolean);
35
+ const rows = [];
36
+ let inTable = false;
37
+ for (const line of lines) {
38
+ if (line.startsWith('|---') || line.match(/^\|[\s|:-]+\|$/)) { inTable = true; continue; }
39
+ if (!line.startsWith('|')) { inTable = false; continue; }
40
+ const cells = line.split('|').map((c) => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length - 1);
41
+ if (cells.length < 2) continue;
42
+ if (!inTable && (cells[0].toLowerCase().startsWith('#') || cells[0].toLowerCase() === 'ws' || cells[0].toLowerCase() === 'st')) {
43
+ // header row
44
+ continue;
45
+ }
46
+ if (cells.length >= 3) {
47
+ rows.push({
48
+ id: cells[0],
49
+ name: cells[1],
50
+ status: cells[2],
51
+ date: cells[3] || '',
52
+ notes: cells[4] || '',
53
+ });
54
+ }
55
+ }
56
+ return rows;
57
+ }
58
+
59
+ export function countChangelogLines(sectionText) {
60
+ if (!sectionText) return 0;
61
+ return sectionText.split('\n').length;
62
+ }
63
+
64
+ export function splitChangelogEntries(sectionText) {
65
+ if (!sectionText) return [];
66
+ const lines = sectionText.split('\n');
67
+ const entries = [];
68
+ let current = null;
69
+ for (const line of lines) {
70
+ const headingMatch = line.match(/^###\s+(.+?)\s*$/);
71
+ if (headingMatch) {
72
+ if (current) entries.push(current);
73
+ current = { heading: headingMatch[1], lines: [] };
74
+ } else if (current) {
75
+ current.lines.push(line);
76
+ }
77
+ }
78
+ if (current) entries.push(current);
79
+ return entries;
80
+ }
@@ -0,0 +1,64 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+
5
+ const TOKEN_FILE = '.dw/cache/watch.token';
6
+
7
+ export function tokenFile(rootDir = process.cwd()) {
8
+ return join(rootDir, TOKEN_FILE);
9
+ }
10
+
11
+ export function ensureToken(rootDir = process.cwd(), { rotate = false } = {}) {
12
+ const file = tokenFile(rootDir);
13
+ const dir = dirname(file);
14
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
15
+ if (!rotate && existsSync(file)) {
16
+ const t = readFileSync(file, 'utf8').trim();
17
+ if (t.length === 64) return t;
18
+ }
19
+ const token = randomBytes(32).toString('hex');
20
+ writeFileSync(file, token + '\n', { encoding: 'utf8', mode: 0o600 });
21
+ return token;
22
+ }
23
+
24
+ export function readToken(rootDir = process.cwd()) {
25
+ const file = tokenFile(rootDir);
26
+ if (!existsSync(file)) return null;
27
+ return readFileSync(file, 'utf8').trim() || null;
28
+ }
29
+
30
+ export function extractRequestToken(req) {
31
+ const header = req.headers['x-watch-token'];
32
+ if (header) return String(header).trim();
33
+ const cookies = req.headers.cookie;
34
+ if (cookies) {
35
+ const m = cookies.match(/(?:^|;\s*)dw_watch_token=([a-f0-9]{64})\b/);
36
+ if (m) return m[1];
37
+ }
38
+ if (req.url) {
39
+ try {
40
+ const url = new URL(req.url, 'http://localhost');
41
+ const q = url.searchParams.get('token');
42
+ if (q) return q;
43
+ } catch { /* ignore */ }
44
+ }
45
+ return null;
46
+ }
47
+
48
+ export function isAuthorized(req, token) {
49
+ const provided = extractRequestToken(req);
50
+ if (!provided || !token) return false;
51
+ if (provided.length !== token.length) return false;
52
+ let mismatch = 0;
53
+ for (let i = 0; i < provided.length; i++) {
54
+ mismatch |= provided.charCodeAt(i) ^ token.charCodeAt(i);
55
+ }
56
+ return mismatch === 0;
57
+ }
58
+
59
+ export function isWriteEndpoint(req) {
60
+ const method = (req.method || '').toUpperCase();
61
+ if (method === 'GET' || method === 'HEAD') return false;
62
+ // Treat all non-read methods as writes
63
+ return true;
64
+ }