dw-kit 1.4.0 → 1.6.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/task-frontmatter.schema.json +78 -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 +161 -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/lint-task.mjs +112 -0
- package/src/commands/task-migrate.mjs +366 -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-view.mjs +386 -0
- package/src/commands/task-watch.mjs +223 -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/lint-rules.mjs +149 -0
- package/src/lib/timeline-parser.mjs +80 -0
|
@@ -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
|
+
}
|
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,112 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
import { lintTimeline, findAllTaskDirs } from '../lib/lint-rules.mjs';
|
|
6
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
7
|
+
|
|
8
|
+
const TASKS_DIR = '.dw/tasks';
|
|
9
|
+
|
|
10
|
+
function getLintLevel(rootDir) {
|
|
11
|
+
try {
|
|
12
|
+
const cfgPath = join(rootDir, '.dw', 'config', 'dw.config.yml');
|
|
13
|
+
if (!existsSync(cfgPath)) return 'strict';
|
|
14
|
+
const cfg = yaml.load(readFileSync(cfgPath, 'utf8'), { schema: yaml.CORE_SCHEMA });
|
|
15
|
+
return cfg?.task?.lint ?? cfg?.task_lint ?? 'strict';
|
|
16
|
+
} catch {
|
|
17
|
+
return 'strict';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function severityColor(sev) {
|
|
22
|
+
if (sev === 'error') return chalk.red;
|
|
23
|
+
if (sev === 'warning') return chalk.yellow;
|
|
24
|
+
return chalk.dim;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printViolations(taskDir, result) {
|
|
28
|
+
if (result.violations.length === 0) {
|
|
29
|
+
console.log(chalk.green(` ✓ ${taskDir} — clean`));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
console.log();
|
|
33
|
+
console.log(chalk.bold(` ${taskDir}`));
|
|
34
|
+
for (const v of result.violations) {
|
|
35
|
+
const sev = severityColor(v.severity)(`[${v.severity}]`);
|
|
36
|
+
const rule = chalk.dim(`(${v.rule})`);
|
|
37
|
+
const lineRef = v.line ? chalk.dim(`:${v.line}`) : '';
|
|
38
|
+
console.log(` ${sev} ${v.message} ${rule}${lineRef}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function lintTaskCommand(taskName, opts = {}) {
|
|
43
|
+
const rootDir = process.cwd();
|
|
44
|
+
const level = opts.level || getLintLevel(rootDir);
|
|
45
|
+
|
|
46
|
+
if (level === 'off') {
|
|
47
|
+
console.log(chalk.dim(' Task lint disabled (task_lint: off in dw.config.yml). Skipping.'));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let targets;
|
|
52
|
+
if (taskName) {
|
|
53
|
+
targets = [join(rootDir, TASKS_DIR, taskName)];
|
|
54
|
+
} else {
|
|
55
|
+
targets = findAllTaskDirs(rootDir);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (targets.length === 0) {
|
|
59
|
+
console.log(chalk.dim(' No v3 tasks to lint.'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log();
|
|
64
|
+
console.log(chalk.bold(` Linting ${targets.length} task${targets.length === 1 ? '' : 's'} (level: ${level})`));
|
|
65
|
+
|
|
66
|
+
let totalErrors = 0;
|
|
67
|
+
let totalWarnings = 0;
|
|
68
|
+
let totalClean = 0;
|
|
69
|
+
|
|
70
|
+
for (const dir of targets) {
|
|
71
|
+
const result = lintTimeline(dir);
|
|
72
|
+
printViolations(dir, result);
|
|
73
|
+
const errs = result.violations.filter((v) => v.severity === 'error').length;
|
|
74
|
+
const warns = result.violations.filter((v) => v.severity === 'warning').length;
|
|
75
|
+
totalErrors += errs;
|
|
76
|
+
totalWarnings += warns;
|
|
77
|
+
if (result.violations.length === 0) totalClean++;
|
|
78
|
+
|
|
79
|
+
for (const v of result.violations) {
|
|
80
|
+
logEvent({
|
|
81
|
+
event: 'task',
|
|
82
|
+
action: 'lint.violation',
|
|
83
|
+
rule: v.rule,
|
|
84
|
+
severity: v.severity,
|
|
85
|
+
}, rootDir);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log();
|
|
90
|
+
const summary = ` ${totalClean} clean · ${totalWarnings} warnings · ${totalErrors} errors`;
|
|
91
|
+
if (totalErrors > 0) {
|
|
92
|
+
console.log(chalk.red(summary));
|
|
93
|
+
} else if (totalWarnings > 0) {
|
|
94
|
+
console.log(chalk.yellow(summary));
|
|
95
|
+
} else {
|
|
96
|
+
console.log(chalk.green(summary));
|
|
97
|
+
}
|
|
98
|
+
console.log();
|
|
99
|
+
|
|
100
|
+
logEvent({
|
|
101
|
+
event: 'task',
|
|
102
|
+
action: 'lint.run',
|
|
103
|
+
targets: targets.length,
|
|
104
|
+
errors: totalErrors,
|
|
105
|
+
warnings: totalWarnings,
|
|
106
|
+
level,
|
|
107
|
+
}, rootDir);
|
|
108
|
+
|
|
109
|
+
if (level === 'strict' && totalErrors > 0) {
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
}
|