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
package/src/cli.mjs CHANGED
@@ -93,6 +93,318 @@ export function run(argv) {
93
93
  console.log(chalk.green('✓') + ` Wrote ${target}`);
94
94
  });
95
95
 
96
+ const taskCmd = program
97
+ .command('task')
98
+ .description('v3 task docs operations (ADR-0008): show · new · migrate · lint');
99
+
100
+ taskCmd
101
+ .command('show [task-name]')
102
+ .description('Print ANSI snapshot (status + subtask tracker) of a v3 task')
103
+ .option('-v, --verbose', 'Include Section 1 Snapshot body')
104
+ .action(async (taskName, opts) => {
105
+ const { taskShowCommand } = await import('./commands/task-show.mjs');
106
+ await taskShowCommand(taskName, opts);
107
+ });
108
+
109
+ taskCmd
110
+ .command('new <task-name>')
111
+ .description('Scaffold a new v3 task from template')
112
+ .option('-d, --depth <level>', 'quick | standard | thorough', 'standard')
113
+ .option('-t, --title <title>', 'Human-readable task title')
114
+ .option('-o, --owner <name>', 'Task owner', '')
115
+ .option('-a, --adr <ref>', 'Related ADR (ADR-NNNN or none)', 'none')
116
+ .option('--target <ship>', 'Target ship milestone', 'TBD')
117
+ .action(async (taskName, opts) => {
118
+ const { taskNewCommand } = await import('./commands/task-new.mjs');
119
+ await taskNewCommand(taskName, opts);
120
+ });
121
+
122
+ taskCmd
123
+ .command('migrate [task-name]')
124
+ .description('Migrate v2 spec.md + tracking.md → v3 task.md, OR bump v3.0 → v3.1 schema (--to-v3.1)')
125
+ .option('-n, --dry-run', 'Preview without writing')
126
+ .option('--diff', 'Show diff against existing task.md')
127
+ .option('--all', 'Scan and migrate all v2 tasks (or all v3.0 tasks when --to-v3.1)')
128
+ .option('--force', 'Overwrite existing task.md')
129
+ .option('--remove-v2', 'Delete spec.md/tracking.md after backup (default keeps them)')
130
+ .option('--rollback', 'Restore v2 files from .v2bak backups (requires task name)')
131
+ .option('--to-v3-1', 'Bump v3.0 → v3.1 schema (adds optional parent_goal_id, contributing_goal_ids, summary fields per ADR-0010)')
132
+ .action(async (taskName, opts) => {
133
+ const { taskMigrateCommand } = await import('./commands/task-migrate.mjs');
134
+ await taskMigrateCommand(taskName, opts);
135
+ });
136
+
137
+ taskCmd
138
+ .command('lint [task-name]')
139
+ .description('Lint v3 task.md: schema validate + drift markers + line caps')
140
+ .option('-l, --level <level>', 'strict | warn | off (overrides config)', '')
141
+ .action(async (taskName, opts) => {
142
+ const { lintTaskCommand } = await import('./commands/lint-task.mjs');
143
+ await lintTaskCommand(taskName, opts);
144
+ });
145
+
146
+ taskCmd
147
+ .command('rotate [task-name]')
148
+ .description('Auto-rotate Section 4 (Timeline/Changelog) > 400 lines to timeline-history.md (keeps last 8 entries)')
149
+ .option('-n, --dry-run', 'Preview without writing')
150
+ .option('-q, --quiet', 'Suppress output (hook usage)')
151
+ .action(async (taskName, opts) => {
152
+ const { taskRotateCommand } = await import('./commands/task-rotate.mjs');
153
+ await taskRotateCommand(taskName, opts);
154
+ });
155
+
156
+ taskCmd
157
+ .command('render [task-name]')
158
+ .description('Render timeline.svg sidecar via dw-kit-render sub-package; falls back to Mermaid block in Section 4 if absent')
159
+ .option('-n, --dry-run', 'Print manifest without writing files')
160
+ .option('-f, --format <kind>', 'svg | png | svg,png', 'svg')
161
+ .option('--mermaid-only', 'Skip SVG, only inject Mermaid fallback block')
162
+ .option('--always-mermaid', 'Inject Mermaid block AND SVG (both)')
163
+ .action(async (taskName, opts) => {
164
+ const formats = String(opts.format || 'svg').split(',').map((s) => s.trim()).filter(Boolean);
165
+ const { taskRenderCommand } = await import('./commands/task-render.mjs');
166
+ await taskRenderCommand(taskName, { ...opts, formats });
167
+ });
168
+
169
+ taskCmd
170
+ .command('view [task-name]')
171
+ .description('Render lean HTML dashboard (snapshot + tracker + recent activity + handoff) and open in browser')
172
+ .option('--no-open', 'Generate HTML without opening browser (prints path)')
173
+ .action(async (taskName, opts) => {
174
+ const { taskViewCommand } = await import('./commands/task-view.mjs');
175
+ await taskViewCommand(taskName, opts);
176
+ });
177
+
178
+ taskCmd
179
+ .command('watch [task-name]')
180
+ .description('Watch task.md for changes and live-reload the HTML preview in browser (local server, debounced)')
181
+ .option('-p, --port <port>', 'Local server port (auto-finds next available if busy)', parseInt)
182
+ .option('--rotate-token', 'Regenerate .dw/cache/watch.token at startup (invalidates old browser sessions; C-3)')
183
+ .action(async (taskName, opts) => {
184
+ const { taskWatchCommand } = await import('./commands/task-watch.mjs');
185
+ await taskWatchCommand(taskName, opts);
186
+ });
187
+
188
+ taskCmd
189
+ .command('summary <task-name>')
190
+ .description('Read or update task summary frontmatter field (≤1000 chars; agentchattr borrow)')
191
+ .option('--write <text>', 'Update summary (auto-bumps schema_version v3.0→v3.1 if needed)')
192
+ .action(async (taskName, opts) => {
193
+ const { taskSummaryCommand } = await import('./commands/task-summary.mjs');
194
+ await taskSummaryCommand(taskName, opts);
195
+ });
196
+
197
+ const agentCmd = program
198
+ .command('agent')
199
+ .description('Agent OS multi-agent orchestration (ADR-0009): claim · release · claims · reports · conflicts');
200
+
201
+ agentCmd
202
+ .command('claim <task-id>')
203
+ .description('Create a cooperative claim for a task (subtasks + write_scope)')
204
+ .option('-s, --subtasks <list>', 'Comma-separated subtask IDs (e.g. ST-1,ST-2)')
205
+ .option('-w, --write <paths>', 'Comma-separated write_scope glob paths')
206
+ .option('-r, --read <paths>', 'Comma-separated read_scope hint paths (informational)')
207
+ .option('-a, --agent <id>', 'Agent identifier (default: env DW_AGENT_ID or auto-generated)')
208
+ .option('-v, --vendor <name>', 'Vendor: claude | codex | gemini | human | other', 'claude')
209
+ .option('--role <role>', 'worker | reader | orchestrator | reviewer', 'worker')
210
+ .option('-l, --lease <duration>', 'Lease duration (e.g. 30m, 1h, 4h, 1d)', '1h')
211
+ .option('--worktree <path>', 'Worktree path if agent uses git worktree (R2-3)')
212
+ .option('-f, --force', 'Override conflict detection (use sparingly)')
213
+ .action(async (taskId, opts) => {
214
+ const { agentClaimCommand } = await import('./commands/agent-claim.mjs');
215
+ await agentClaimCommand(taskId, opts);
216
+ });
217
+
218
+ agentCmd
219
+ .command('release <claim-id>')
220
+ .description('Release a claim (clean exit; transitions status to "released")')
221
+ .option('-r, --reason <text>', 'Optional reason for release')
222
+ .action(async (claimId, opts) => {
223
+ const { agentReleaseCommand } = await import('./commands/agent-claim.mjs');
224
+ await agentReleaseCommand(claimId, opts);
225
+ });
226
+
227
+ agentCmd
228
+ .command('expire <claim-id>')
229
+ .description('Mark a claim as expired or invalidated (orchestrator action)')
230
+ .option('-r, --reason <text>', 'Reason for expiry')
231
+ .option('--invalidate', 'Invalidate instead of expire (orchestrator override)')
232
+ .action(async (claimId, opts) => {
233
+ const { agentExpireCommand } = await import('./commands/agent-claim.mjs');
234
+ await agentExpireCommand(claimId, opts);
235
+ });
236
+
237
+ agentCmd
238
+ .command('claims')
239
+ .description('List claims (default: active only). Filter by task / status / --all to include released')
240
+ .option('-t, --task <id>', 'Filter by task ID')
241
+ .option('-s, --status <status>', 'Filter by status: created | active | released | expired | invalidated')
242
+ .option('--all', 'Include released and invalidated claims')
243
+ .action(async (opts) => {
244
+ const { agentClaimsCommand } = await import('./commands/agent-inspect.mjs');
245
+ await agentClaimsCommand(opts);
246
+ });
247
+
248
+ agentCmd
249
+ .command('reports')
250
+ .description('List agent reports across tasks (read-only audit view)')
251
+ .option('-t, --task <id>', 'Filter by task ID')
252
+ .option('-a, --agent <id>', 'Filter by agent ID')
253
+ .action(async (opts) => {
254
+ const { agentReportsCommand } = await import('./commands/agent-inspect.mjs');
255
+ await agentReportsCommand(opts);
256
+ });
257
+
258
+ agentCmd
259
+ .command('conflicts')
260
+ .description('Detect overlapping active claims (subtask or write_scope). Exits 1 if conflicts found (use --no-strict to suppress).')
261
+ .option('-t, --task <id>', 'Filter by task ID')
262
+ .option('--no-strict', 'Do not exit 1 on conflict (just report)')
263
+ .action(async (opts) => {
264
+ const { agentConflictsCommand } = await import('./commands/agent-inspect.mjs');
265
+ await agentConflictsCommand(opts);
266
+ });
267
+
268
+ const goalCmd = program
269
+ .command('goal')
270
+ .description('Goals Management Layer (ADR-0010): strategic layer above tasks. new · show · link · summary · portfolio · lint · bump · delete · view');
271
+
272
+ goalCmd
273
+ .command('new <goal-id>')
274
+ .description('Create a new goal from template (ID format G-{slug})')
275
+ .option('--title <text>', 'Goal title (default: "New Goal")')
276
+ .option('--owner <name>', 'Owner (default: current user)')
277
+ .option('--target-date <YYYY-MM-DD>', 'Target date (default: TBD)')
278
+ .option('--icon <emoji>', 'Icon/emoji for portfolio cards (default: 🎯)')
279
+ .option('--cycle <label>', 'Cycle label e.g. "Q2 2026", "v1.7-cycle"')
280
+ .option('--summary <text>', 'Initial ≤1000-char summary')
281
+ .option('--parent-goal-id <id>', 'Parent goal ID for sub-goals')
282
+ .action(async (goalId, opts) => {
283
+ const { goalNewCommand } = await import('./commands/goal-new.mjs');
284
+ await goalNewCommand(goalId, opts);
285
+ });
286
+
287
+ goalCmd
288
+ .command('set <goal-id>')
289
+ .description('Update goal metadata fields (icon, cycle, owner, target_date, parent_goal_id)')
290
+ .option('--icon <emoji>', 'Icon/emoji (empty string removes)')
291
+ .option('--cycle <label>', 'Cycle label (empty string removes)')
292
+ .option('--owner <name>', 'New owner')
293
+ .option('--target-date <YYYY-MM-DD>', 'New target date or "TBD"')
294
+ .option('--parent-goal-id <id>', 'Parent goal ID or "none"')
295
+ .action(async (goalId, opts) => {
296
+ const { goalSetCommand } = await import('./commands/goal-set.mjs');
297
+ await goalSetCommand(goalId, opts);
298
+ });
299
+
300
+ goalCmd
301
+ .command('show [goal-id]')
302
+ .description('ANSI snapshot of a goal (no arg = list all)')
303
+ .action(async (goalId, opts) => {
304
+ const { goalShowCommand } = await import('./commands/goal-show.mjs');
305
+ await goalShowCommand(goalId, opts);
306
+ });
307
+
308
+ goalCmd
309
+ .command('link <goal-id> <task-id>')
310
+ .description('Link a task → goal (primary parent_goal_id; --contributing for secondary)')
311
+ .option('--contributing', 'Add to contributing_goal_ids instead of parent_goal_id (Q1)')
312
+ .option('-f, --force', 'Override existing parent_goal_id')
313
+ .action(async (goalId, taskId, opts) => {
314
+ const { goalLinkCommand } = await import('./commands/goal-link.mjs');
315
+ await goalLinkCommand(goalId, taskId, opts);
316
+ });
317
+
318
+ goalCmd
319
+ .command('unlink <goal-id> <task-id>')
320
+ .description('Remove task→goal mapping (clears both parent_goal_id and contributing_goal_ids)')
321
+ .action(async (goalId, taskId) => {
322
+ const { goalUnlinkCommand } = await import('./commands/goal-link.mjs');
323
+ await goalUnlinkCommand(goalId, taskId);
324
+ });
325
+
326
+ goalCmd
327
+ .command('summary <goal-id>')
328
+ .description('Read or update goal summary (≤1000 chars; agentchattr borrow)')
329
+ .option('--write <text>', 'Update summary (≤1000 chars); auto-syncs to goals-index.json')
330
+ .action(async (goalId, opts) => {
331
+ const { goalSummaryCommand } = await import('./commands/goal-summary.mjs');
332
+ await goalSummaryCommand(goalId, opts);
333
+ });
334
+
335
+ goalCmd
336
+ .command('bump <goal-id>')
337
+ .description('Manually bump goal_version (Q4 material change; requires --reason)')
338
+ .option('--reason <text>', 'Reason for the version bump (required)')
339
+ .action(async (goalId, opts) => {
340
+ const { goalBumpCommand } = await import('./commands/goal-bump.mjs');
341
+ await goalBumpCommand(goalId, opts);
342
+ });
343
+
344
+ goalCmd
345
+ .command('delete <goal-id>')
346
+ .description('Delete a goal (C-2: soft by default, --hard removes folder)')
347
+ .option('--cascade', 'Clear parent_goal_id / contributing_goal_ids on linked tasks')
348
+ .option('--hard', 'Hard-delete: remove .dw/goals/{id}/ folder (vs soft = status→Abandoned)')
349
+ .option('-n, --dry-run', 'Preview without writing')
350
+ .action(async (goalId, opts) => {
351
+ const { goalDeleteCommand } = await import('./commands/goal-delete.mjs');
352
+ await goalDeleteCommand(goalId, opts);
353
+ });
354
+
355
+ goalCmd
356
+ .command('lint [goal-id]')
357
+ .description('Lint goal.md: frontmatter schema + summary length + index drift + orphan cross-refs')
358
+ .action(async (goalId, opts) => {
359
+ const { goalLintCommand } = await import('./commands/goal-lint.mjs');
360
+ await goalLintCommand(goalId, opts);
361
+ });
362
+
363
+ goalCmd
364
+ .command('portfolio')
365
+ .description('Show all goals at-a-glance (O(1) read via goals-index.json; W-1)')
366
+ .option('--status <status>', 'Filter by status (Draft | Active | Achieved | Abandoned | Pivoted)')
367
+ .action(async (opts) => {
368
+ const { goalPortfolioCommand } = await import('./commands/goal-portfolio.mjs');
369
+ await goalPortfolioCommand(opts);
370
+ });
371
+
372
+ goalCmd
373
+ .command('view [goal-id]')
374
+ .description('Generate HTML portfolio (P1+P2 joint MVP per W-7)')
375
+ .option('--no-open', 'Do not auto-open browser')
376
+ .action(async (goalId, opts) => {
377
+ const { goalViewCommand } = await import('./commands/goal-view.mjs');
378
+ await goalViewCommand(goalId, opts);
379
+ });
380
+
381
+ goalCmd
382
+ .command('migrate [goal-id]')
383
+ .description('Stub for future schema bump (S-1; goal@v1 → goal@v2)')
384
+ .option('--to <schema>', 'Target schema (default: goal@v2)')
385
+ .action(async (goalId, opts) => {
386
+ const { goalMigrateCommand } = await import('./commands/goal-stubs.mjs');
387
+ await goalMigrateCommand(goalId, opts);
388
+ });
389
+
390
+ goalCmd
391
+ .command('suggest-krs <goal-id>')
392
+ .description('Build SMART-formatted prompt to paste into AI agent chat for KR brainstorming (W-5)')
393
+ .option('--count <n>', 'Requested number of KRs (default: 3)', '3')
394
+ .option('--json', 'Output structured JSON (for agent piping)')
395
+ .action(async (goalId, opts) => {
396
+ const { goalSuggestKrsCommand } = await import('./commands/goal-suggest-krs.mjs');
397
+ await goalSuggestKrsCommand(goalId, opts);
398
+ });
399
+
400
+ goalCmd
401
+ .command('render [goal-id]')
402
+ .description('Render constellation SVG for goal(s) — goal center + KR satellites + task leaves (R-G6 shipped)')
403
+ .action(async (goalId, opts) => {
404
+ const { goalRenderCommand } = await import('./commands/goal-render.mjs');
405
+ await goalRenderCommand(goalId, opts);
406
+ });
407
+
96
408
  program
97
409
  .command('dashboard')
98
410
  .description('Show team dashboard — active tasks, ADRs, telemetry summary, health')
@@ -0,0 +1,235 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, openSync, closeSync, mkdirSync, unlinkSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { createClaim, persistClaim, validateClaim, transitionClaim, loadClaim, listClaims } from '../lib/agent-claim.mjs';
6
+ import { logAgentEvent } from '../lib/agent-events.mjs';
7
+ import { scopesOverlap } from '../lib/agent-conflict.mjs';
8
+ import { logEvent as logTelemetry } from '../lib/telemetry.mjs';
9
+
10
+ const LOCK_DIR = '.dw/cache/agents';
11
+ const LOCK_FILE = 'claim.lock';
12
+ const LOCK_TIMEOUT_MS = 5000;
13
+ const LOCK_POLL_MS = 50;
14
+
15
+ function acquireClaimLock(rootDir) {
16
+ const dir = join(rootDir, LOCK_DIR);
17
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
18
+ const lockPath = join(dir, LOCK_FILE);
19
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
20
+ let fd;
21
+ while (Date.now() < deadline) {
22
+ try {
23
+ fd = openSync(lockPath, 'wx');
24
+ return { fd, path: lockPath };
25
+ } catch (e) {
26
+ if (e.code !== 'EEXIST') throw e;
27
+ const wait = LOCK_POLL_MS + Math.floor(Math.random() * 50);
28
+ const end = Date.now() + wait;
29
+ while (Date.now() < end) { /* busy wait — node has no sync sleep */ }
30
+ }
31
+ }
32
+ return { fd: null, path: lockPath, timedOut: true };
33
+ }
34
+
35
+ function releaseClaimLock(lock) {
36
+ if (!lock || lock.fd === null) return;
37
+ try { closeSync(lock.fd); } catch { /* ignore */ }
38
+ try { unlinkSync(lock.path); } catch { /* ignore */ }
39
+ }
40
+
41
+ function parseList(value) {
42
+ if (!value) return [];
43
+ return String(value).split(',').map((s) => s.trim()).filter(Boolean);
44
+ }
45
+
46
+ function parseLease(s) {
47
+ const m = String(s || '').match(/^(\d+)([smhd]?)$/i);
48
+ if (!m) return 3600;
49
+ const n = parseInt(m[1], 10);
50
+ const unit = (m[2] || 's').toLowerCase();
51
+ const mult = { s: 1, m: 60, h: 3600, d: 86400 }[unit];
52
+ return n * mult;
53
+ }
54
+
55
+ export async function agentClaimCommand(taskId, opts = {}) {
56
+ const rootDir = process.cwd();
57
+
58
+ if (!taskId) {
59
+ console.error(chalk.red('✗ Task ID required: dw agent claim <task-id> --subtasks ST-1,ST-2 --write src/foo.js'));
60
+ process.exit(1);
61
+ }
62
+ if (!existsSync(join(rootDir, '.dw/tasks', taskId, 'task.md'))) {
63
+ console.error(chalk.red(`✗ No v3 task at .dw/tasks/${taskId}/task.md`));
64
+ process.exit(1);
65
+ }
66
+
67
+ const subtasks = parseList(opts.subtasks);
68
+ if (subtasks.length === 0) {
69
+ console.error(chalk.red('✗ --subtasks required (e.g. --subtasks ST-1,ST-2)'));
70
+ process.exit(1);
71
+ }
72
+
73
+ const role = opts.role || 'worker';
74
+ const writeScope = parseList(opts.write);
75
+ const readScope = parseList(opts.read);
76
+ const leaseSeconds = parseLease(opts.lease || '1h');
77
+
78
+ if (role !== 'reader' && writeScope.length === 0) {
79
+ console.error(chalk.red('✗ --write required for non-reader roles (e.g. --write "src/foo.js,test/**")'));
80
+ process.exit(1);
81
+ }
82
+
83
+ const agentId = opts.agent || process.env.DW_AGENT_ID || `${opts.vendor || 'claude'}-${Date.now().toString(36)}-${randomBytes(2).toString('hex')}`;
84
+ const vendor = opts.vendor || 'claude';
85
+
86
+ const claim = createClaim({
87
+ taskId,
88
+ agent: { id: agentId, vendor, role },
89
+ subtasks,
90
+ writeScope,
91
+ readScope,
92
+ leaseSeconds,
93
+ worktreePath: opts.worktree || null,
94
+ }, rootDir);
95
+
96
+ const v = validateClaim(claim, rootDir);
97
+ if (!v.ok) {
98
+ console.error(chalk.red('✗ Schema validation failed:'));
99
+ for (const e of v.errors) console.error(chalk.red(` ${e.path}: ${e.message}`));
100
+ process.exit(1);
101
+ }
102
+
103
+ // R2-1 + reviewer Critical #2: acquire exclusive lock around overlap-check + persist
104
+ // to close the TOCTOU window between two concurrent `dw agent claim` invocations.
105
+ const lock = acquireClaimLock(rootDir);
106
+ if (lock.timedOut) {
107
+ console.error(chalk.red(`✗ Could not acquire claim lock (${lock.path}) within ${LOCK_TIMEOUT_MS}ms.`));
108
+ console.error(chalk.dim(' Another `dw agent claim` may be running. Retry, or stale-remove the lock file.'));
109
+ process.exit(1);
110
+ }
111
+ let target;
112
+ try {
113
+ const wouldConflict = checkOverlapAgainstActive(claim, rootDir);
114
+ if (wouldConflict.length && !opts.force) {
115
+ console.error(chalk.red(`✗ Claim would overlap ${wouldConflict.length} existing active claim(s):`));
116
+ for (const c of wouldConflict) {
117
+ console.error(chalk.red(` - ${c.claim_id} (agent: ${c.agent_id}, overlap: ${c.reason})`));
118
+ }
119
+ console.error(chalk.dim(' Use --force to claim anyway, or release the conflicting claim first.'));
120
+ releaseClaimLock(lock);
121
+ process.exit(1);
122
+ }
123
+ target = persistClaim(claim, rootDir);
124
+ } finally {
125
+ releaseClaimLock(lock);
126
+ }
127
+ logAgentEvent(taskId, {
128
+ event: 'claim_created',
129
+ claim_id: claim.claim_id,
130
+ agent_id: agentId,
131
+ vendor,
132
+ role,
133
+ subtasks,
134
+ write_scope: writeScope,
135
+ lease_expires: claim.lease_expires,
136
+ }, rootDir);
137
+ logTelemetry({ event: 'agent', action: 'claim.create', name: taskId, vendor, role }, rootDir);
138
+
139
+ console.log();
140
+ console.log(chalk.green('✓') + ` Claim created: ${chalk.cyan(claim.claim_id)}`);
141
+ console.log(chalk.dim(` file: ${target}`));
142
+ console.log(chalk.dim(` task: ${taskId}`));
143
+ console.log(chalk.dim(` agent: ${agentId} (${vendor}/${role})`));
144
+ console.log(chalk.dim(` subtasks: ${subtasks.join(', ')}`));
145
+ console.log(chalk.dim(` write_scope: ${writeScope.join(', ') || '(read-only)'}`));
146
+ console.log(chalk.dim(` lease_expires: ${claim.lease_expires} (in ${leaseSeconds}s)`));
147
+ console.log();
148
+ console.log(chalk.dim(' Next: write your changes, then `dw agent release ' + claim.claim_id + '` when done.'));
149
+ console.log();
150
+ }
151
+
152
+ function checkOverlapAgainstActive(newClaim, rootDir) {
153
+ // Reviewer Warning #3: write_scope collisions must be detected REPO-WIDE,
154
+ // not just within the same task. Subtask overlap remains task-scoped (IDs only
155
+ // meaningful within a single task).
156
+ const allActive = listClaims(rootDir).filter((c) => {
157
+ const ls = c._live_status;
158
+ return ls === 'created' || ls === 'active';
159
+ });
160
+
161
+ const conflicts = [];
162
+ for (const e of allActive) {
163
+ const sameTask = e.task_id === newClaim.task_id;
164
+ const subtaskOverlap = sameTask
165
+ ? newClaim.subtasks.filter((s) => e.subtasks.includes(s))
166
+ : [];
167
+ const writeOverlap = [];
168
+ for (const pa of newClaim.write_scope) {
169
+ for (const pb of e.write_scope) {
170
+ if (scopesOverlap(pa, pb)) writeOverlap.push({ new: pa, existing: pb });
171
+ }
172
+ }
173
+ if (subtaskOverlap.length || writeOverlap.length) {
174
+ const reasons = [];
175
+ if (subtaskOverlap.length) reasons.push(`subtasks ${subtaskOverlap.join(',')}`);
176
+ if (writeOverlap.length) reasons.push(`write scope ${writeOverlap.map((o) => o.existing).join(',')}`);
177
+ if (!sameTask) reasons.push(`(cross-task: ${e.task_id})`);
178
+ conflicts.push({ claim_id: e.claim_id, agent_id: e.agent.id, reason: reasons.join('; ') });
179
+ }
180
+ }
181
+ return conflicts;
182
+ }
183
+
184
+ export async function agentReleaseCommand(claimId, opts = {}) {
185
+ const rootDir = process.cwd();
186
+ if (!claimId) {
187
+ console.error(chalk.red('✗ Claim ID required: dw agent release <claim-id>'));
188
+ process.exit(1);
189
+ }
190
+ const claim = loadClaim(claimId, rootDir);
191
+ if (!claim) {
192
+ console.error(chalk.red(`✗ Claim not found: ${claimId}`));
193
+ process.exit(1);
194
+ }
195
+ const result = transitionClaim(claimId, 'released', { reason: opts.reason }, rootDir);
196
+ if (!result.ok) {
197
+ console.error(chalk.red(`✗ Cannot release: ${result.reason}`));
198
+ process.exit(1);
199
+ }
200
+ logAgentEvent(claim.task_id, {
201
+ event: 'claim_released',
202
+ claim_id: claimId,
203
+ agent_id: claim.agent.id,
204
+ reason: opts.reason || 'clean exit',
205
+ }, rootDir);
206
+ logTelemetry({ event: 'agent', action: 'claim.release', name: claim.task_id }, rootDir);
207
+ console.log(chalk.green('✓') + ` Released: ${claimId}`);
208
+ }
209
+
210
+ export async function agentExpireCommand(claimId, opts = {}) {
211
+ const rootDir = process.cwd();
212
+ if (!claimId) {
213
+ console.error(chalk.red('✗ Claim ID required'));
214
+ process.exit(1);
215
+ }
216
+ const claim = loadClaim(claimId, rootDir);
217
+ if (!claim) {
218
+ console.error(chalk.red(`✗ Claim not found: ${claimId}`));
219
+ process.exit(1);
220
+ }
221
+ const status = opts.invalidate ? 'invalidated' : 'expired';
222
+ const result = transitionClaim(claimId, status, { reason: opts.reason || 'manual expire' }, rootDir);
223
+ if (!result.ok) {
224
+ console.error(chalk.red(`✗ Cannot ${status}: ${result.reason}`));
225
+ process.exit(1);
226
+ }
227
+ logAgentEvent(claim.task_id, {
228
+ event: status === 'invalidated' ? 'claim_invalidated' : 'claim_expired',
229
+ claim_id: claimId,
230
+ agent_id: claim.agent.id,
231
+ reason: opts.reason || 'manual',
232
+ }, rootDir);
233
+ logTelemetry({ event: 'agent', action: `claim.${status}`, name: claim.task_id }, rootDir);
234
+ console.log(chalk.yellow(`⚠ ${status}: ${claimId}`));
235
+ }
@@ -0,0 +1,123 @@
1
+ import chalk from 'chalk';
2
+ import { listClaims, computeEffectiveExpiry } from '../lib/agent-claim.mjs';
3
+ import { listReports } from '../lib/agent-report.mjs';
4
+ import { detectClaimOverlaps } from '../lib/agent-conflict.mjs';
5
+ import { logEvent as logTelemetry } from '../lib/telemetry.mjs';
6
+
7
+ const STATUS_COLOR = {
8
+ created: chalk.cyan,
9
+ active: chalk.yellow,
10
+ released: chalk.green,
11
+ expired: chalk.red,
12
+ invalidated: chalk.magenta,
13
+ };
14
+
15
+ function fmtRel(iso) {
16
+ if (!iso) return '—';
17
+ const diff = Date.parse(iso) - Date.now();
18
+ const abs = Math.abs(diff) / 1000;
19
+ const unit = abs < 60 ? `${Math.round(abs)}s` : abs < 3600 ? `${Math.round(abs / 60)}m` : abs < 86400 ? `${Math.round(abs / 3600)}h` : `${Math.round(abs / 86400)}d`;
20
+ return diff < 0 ? `${unit} ago` : `in ${unit}`;
21
+ }
22
+
23
+ export async function agentClaimsCommand(opts = {}) {
24
+ const rootDir = process.cwd();
25
+ const claims = listClaims(rootDir, {
26
+ taskId: opts.task,
27
+ status: opts.status,
28
+ includeStale: !!opts.all,
29
+ });
30
+
31
+ logTelemetry({ event: 'agent', action: 'claims.list', count: claims.length }, rootDir);
32
+
33
+ console.log();
34
+ if (claims.length === 0) {
35
+ console.log(chalk.dim(` No claims found${opts.task ? ` for task ${opts.task}` : ''}.`));
36
+ console.log(chalk.dim(' Create one: `dw agent claim <task> --subtasks ST-1 --write src/foo.js`'));
37
+ console.log();
38
+ return;
39
+ }
40
+
41
+ console.log(chalk.bold(` ${claims.length} claim${claims.length === 1 ? '' : 's'}${opts.task ? ` for ${opts.task}` : ''}`));
42
+ console.log();
43
+
44
+ for (const c of claims) {
45
+ const liveStatus = c._live_status;
46
+ const colorFn = STATUS_COLOR[liveStatus] || chalk.white;
47
+ const effectiveExp = computeEffectiveExpiry(c);
48
+ const wallClockExp = Date.parse(c.lease_expires);
49
+ const usingRelative = effectiveExp > wallClockExp;
50
+
51
+ console.log(` ${colorFn(`[${liveStatus.toUpperCase()}]`)} ${chalk.cyan(c.claim_id)}`);
52
+ console.log(` task: ${c.task_id}`);
53
+ console.log(` agent: ${c.agent.id} (${c.agent.vendor}/${c.agent.role})`);
54
+ console.log(` subtasks: ${c.subtasks.join(', ')}`);
55
+ console.log(` write_scope: ${c.write_scope.length ? c.write_scope.join(', ') : chalk.dim('(read-only)')}`);
56
+ console.log(` lease: ${c.lease_expires} (${fmtRel(c.lease_expires)})${usingRelative ? chalk.dim(' [+relative fallback]') : ''}`);
57
+ if (c.released_at) console.log(` released_at: ${c.released_at}${c.release_reason ? ' — ' + c.release_reason : ''}`);
58
+ if (c.worktree_path) console.log(` worktree: ${c.worktree_path}`);
59
+ console.log();
60
+ }
61
+ }
62
+
63
+ export async function agentReportsCommand(opts = {}) {
64
+ const rootDir = process.cwd();
65
+ const reports = listReports(rootDir, { taskId: opts.task, agentId: opts.agent });
66
+
67
+ logTelemetry({ event: 'agent', action: 'reports.list', count: reports.length }, rootDir);
68
+
69
+ console.log();
70
+ if (reports.length === 0) {
71
+ console.log(chalk.dim(` No reports found${opts.task ? ` for task ${opts.task}` : ''}.`));
72
+ console.log();
73
+ return;
74
+ }
75
+
76
+ console.log(chalk.bold(` ${reports.length} report${reports.length === 1 ? '' : 's'}`));
77
+ console.log();
78
+
79
+ for (const r of reports) {
80
+ const sevColor = r.status === 'completed' ? chalk.green : r.status === 'needs-review' ? chalk.red : r.status === 'partial' ? chalk.yellow : chalk.dim;
81
+ console.log(` ${sevColor(`[${(r.status || '?').toUpperCase()}]`)} ${chalk.cyan(r._file.split(/[\\/]/).pop())}`);
82
+ console.log(` task: ${r._task} · agent: ${r.agent_id || '?'} (${r.vendor || '?'})`);
83
+ console.log(` claim: ${r.claim_id || '?'}`);
84
+ if (r.subtasks_addressed && r.subtasks_addressed.length) console.log(` subtasks: ${r.subtasks_addressed.join(', ')}`);
85
+ if (r.files_changed && r.files_changed.length) console.log(` files: ${r.files_changed.slice(0, 5).join(', ')}${r.files_changed.length > 5 ? `, +${r.files_changed.length - 5} more` : ''}`);
86
+ if (r.files_outside_scope && r.files_outside_scope.length) console.log(chalk.red(` out-of-scope: ${r.files_outside_scope.join(', ')}`));
87
+ if (r.open_questions && r.open_questions.length) console.log(chalk.yellow(` open: ${r.open_questions[0]}${r.open_questions.length > 1 ? ` (+${r.open_questions.length - 1})` : ''}`));
88
+ console.log(chalk.dim(` ${r.created_at || ''}`));
89
+ console.log();
90
+ }
91
+ }
92
+
93
+ export async function agentConflictsCommand(opts = {}) {
94
+ const rootDir = process.cwd();
95
+ const conflicts = detectClaimOverlaps(rootDir, { taskId: opts.task });
96
+
97
+ logTelemetry({ event: 'agent', action: 'conflicts.check', count: conflicts.length }, rootDir);
98
+
99
+ console.log();
100
+ if (conflicts.length === 0) {
101
+ console.log(chalk.green(' ✓ No conflicts among active claims.'));
102
+ console.log();
103
+ return;
104
+ }
105
+
106
+ console.log(chalk.red(` ✗ ${conflicts.length} conflict${conflicts.length === 1 ? '' : 's'} detected:`));
107
+ console.log();
108
+
109
+ for (const c of conflicts) {
110
+ console.log(` ${chalk.cyan(c.claim_a)} (${c.agent_a})`);
111
+ console.log(` ${chalk.cyan(c.claim_b)} (${c.agent_b})`);
112
+ console.log(` task: ${c.task_id}`);
113
+ if (c.subtask_overlap.length) console.log(chalk.yellow(` subtask overlap: ${c.subtask_overlap.join(', ')}`));
114
+ for (const w of c.write_overlap) {
115
+ console.log(chalk.red(` write overlap: "${w.a}" ↔ "${w.b}"`));
116
+ }
117
+ console.log();
118
+ }
119
+
120
+ if (opts.strict !== false) {
121
+ process.exit(1);
122
+ }
123
+ }