dw-kit 1.7.0-rc.1 → 1.7.0-rc.2

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.
@@ -96,27 +96,25 @@ fi
96
96
  # v1.6 (ADR-0009 R2-2): Agent OS post-hoc check.
97
97
  # When ≥1 active claim exists, verify staged files fall within an active claim's write_scope.
98
98
  # Cooperative protocol — warn only (cannot prevent non-compliant agents).
99
- if [ -f "$DW_BIN" ] && command -v node >/dev/null 2>&1 && [ -n "$STAGED_FILES" ]; then
99
+ #
100
+ # Issue #13 Bug 3 fix: previous version inlined `node -e` importing
101
+ # `$CLAUDE_PROJECT_DIR/src/lib/agent-claim.mjs` — that path is cwd-relative and
102
+ # only exists in dw-kit's own repo, NOT in consumer projects. The block was
103
+ # silently no-op everywhere else. Replaced by `dw agent check-staged --stdin`
104
+ # which uses the installed binary. Prefer global `dw` on PATH; fall back to
105
+ # `node $DW_BIN` for in-repo local development (where DW_BIN points at the
106
+ # source bin script).
107
+ if [ -n "$STAGED_FILES" ]; then
100
108
  CLAIMS_DIR="$CLAUDE_PROJECT_DIR/.dw/cache/agents/claims"
101
109
  if [ -d "$CLAIMS_DIR" ] && ls "$CLAIMS_DIR"/*.json >/dev/null 2>&1; then
102
- OUT_OF_SCOPE=$(STAGED="$STAGED_FILES" node -e "
103
- const fs = require('fs');
104
- const path = require('path');
105
- (async () => {
106
- const { listClaims } = await import('$CLAUDE_PROJECT_DIR/src/lib/agent-claim.mjs');
107
- const { pathMatchesScope } = await import('$CLAUDE_PROJECT_DIR/src/lib/agent-conflict.mjs');
108
- const claims = listClaims(process.env.CLAUDE_PROJECT_DIR || '$CLAUDE_PROJECT_DIR').filter(c => c._live_status === 'created' || c._live_status === 'active');
109
- if (claims.length === 0) return;
110
- const allScopes = claims.flatMap(c => c.write_scope);
111
- const staged = (process.env.STAGED || '').split(/\r?\n/).filter(Boolean);
112
- const outside = staged.filter(f => !pathMatchesScope(f, allScopes));
113
- if (outside.length > 0) {
114
- console.log('Files NOT in any active claim write_scope:');
115
- for (const f of outside.slice(0, 10)) console.log(' ' + f);
116
- if (outside.length > 10) console.log(' ... +' + (outside.length - 10) + ' more');
117
- }
118
- })().catch(e => { /* fail-graceful */ });
119
- " 2>/dev/null || true)
110
+ if command -v dw >/dev/null 2>&1; then
111
+ OUT_OF_SCOPE=$(echo "$STAGED_FILES" | dw agent check-staged --stdin 2>/dev/null || true)
112
+ elif [ -f "$DW_BIN" ] && command -v node >/dev/null 2>&1; then
113
+ # Local dev fallback: in-repo dw-kit work where global `dw` not on PATH
114
+ OUT_OF_SCOPE=$(echo "$STAGED_FILES" | node "$DW_BIN" agent check-staged --stdin 2>/dev/null || true)
115
+ else
116
+ OUT_OF_SCOPE=""
117
+ fi
120
118
  if [ -n "$OUT_OF_SCOPE" ]; then
121
119
  echo "⚠️ Agent OS (ADR-0009 R2-2): post-hoc claim check" >&2
122
120
  echo "$OUT_OF_SCOPE" >&2
@@ -114,7 +114,27 @@
114
114
  },
115
115
  "worktree_path": {
116
116
  "type": "string",
117
- "description": "If agent operates in a git worktree per R2-3, the worktree path. .dw/cache/worktrees/{agent-id}/"
117
+ "description": "Absolute path to the git worktree this agent operates in (issue #15). dw does NOT create or delete this directory — the caller harness owns lifecycle (`git worktree add` / `git worktree remove`). When two claims declare distinct worktree_path values, dw conflict detection skips write_scope comparison between them (physical isolation). One or both null → conservative; assume overlap."
118
+ },
119
+ "original_lease_expires": {
120
+ "type": "string",
121
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$",
122
+ "description": "Issue #15: lease_expires at claim creation time (immutable). Renewals do NOT change this — caps total lease window via max_renewals."
123
+ },
124
+ "renewed_at": {
125
+ "type": "string",
126
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$",
127
+ "description": "Issue #15: timestamp of last `dw agent renew` call. Stamped on each renewal; previous value overwritten."
128
+ },
129
+ "renewal_count": {
130
+ "type": "integer",
131
+ "minimum": 0,
132
+ "description": "Issue #15: number of times this claim has been renewed. Bounded by MAX_RENEWALS (default 3) to prevent unbounded lease compounding."
133
+ },
134
+ "previous_lease_expires": {
135
+ "type": "string",
136
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$",
137
+ "description": "Issue #15: lease_expires value immediately before the most recent renewal. Provides self-describing renewal history without depending on events.jsonl."
118
138
  }
119
139
  },
120
140
  "allOf": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-kit",
3
- "version": "1.7.0-rc.1",
3
+ "version": "1.7.0-rc.2",
4
4
  "description": "AI development workflow toolkit — structured, quality-assured, team-ready. From requirements to dashboard.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -196,7 +196,7 @@ export function run(argv) {
196
196
 
197
197
  const agentCmd = program
198
198
  .command('agent')
199
- .description('Agent OS multi-agent orchestration (ADR-0009): claim · release · claims · reports · conflicts');
199
+ .description('Agent OS multi-agent orchestration (ADR-0009): claim · release · renew · expire · claims · reports · conflicts · check-staged · verify');
200
200
 
201
201
  agentCmd
202
202
  .command('claim <task-id>')
@@ -224,6 +224,15 @@ export function run(argv) {
224
224
  await agentReleaseCommand(claimId, opts);
225
225
  });
226
226
 
227
+ agentCmd
228
+ .command('renew <claim-id>')
229
+ .description('Extend lease on an active claim WITHOUT changing claim_id (preserves audit continuity vs expire+reclaim). Capped at 3 renewals (issue #15)')
230
+ .option('-l, --lease <duration>', 'New lease window (e.g. 30m, 1h, 4h)', '1h')
231
+ .action(async (claimId, opts) => {
232
+ const { agentRenewCommand } = await import('./commands/agent-claim.mjs');
233
+ await agentRenewCommand(claimId, opts);
234
+ });
235
+
227
236
  agentCmd
228
237
  .command('expire <claim-id>')
229
238
  .description('Mark a claim as expired or invalidated (orchestrator action)')
@@ -265,6 +274,25 @@ export function run(argv) {
265
274
  await agentConflictsCommand(opts);
266
275
  });
267
276
 
277
+ agentCmd
278
+ .command('check-staged [files...]')
279
+ .description('Check staged files against active claim write_scopes (R2-2 cooperative; used by pre-commit-gate hook). Always exits 0; warning on stdout.')
280
+ .option('--stdin', 'Read file list from stdin (one path per line) instead of positional args')
281
+ .action(async (files, opts) => {
282
+ const { agentCheckStagedCommand } = await import('./commands/agent-check-staged.mjs');
283
+ await agentCheckStagedCommand(files, opts);
284
+ });
285
+
286
+ agentCmd
287
+ .command('verify <task-id>')
288
+ .description('Check events.jsonl well-formedness for a task (detects malformed JSON lines; exits 1 if any). Note: not full tamper-evidence — use git log for that.')
289
+ .option('-v, --verbose', 'Show raw line content for malformed entries')
290
+ .option('--no-strict', 'Report malformed lines but always exit 0')
291
+ .action(async (taskId, opts) => {
292
+ const { agentVerifyCommand } = await import('./commands/agent-verify.mjs');
293
+ await agentVerifyCommand(taskId, opts);
294
+ });
295
+
268
296
  const goalCmd = program
269
297
  .command('goal')
270
298
  .description('Goals Management Layer (ADR-0010): strategic layer above tasks. new · show · link · summary · portfolio · lint · bump · delete · view');
@@ -0,0 +1,63 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { listClaims } from '../lib/agent-claim.mjs';
3
+ import { pathMatchesScope } from '../lib/agent-conflict.mjs';
4
+
5
+ /**
6
+ * `dw agent check-staged <files...>` or `dw agent check-staged --stdin`
7
+ *
8
+ * Issue #13 Bug 3: pre-commit-gate.sh R2-2 block used inline `node -e` with
9
+ * `import('$CLAUDE_PROJECT_DIR/src/lib/agent-claim.mjs')` — dead code in
10
+ * consumer projects (cwd-relative path resolves to non-existent file) and
11
+ * exposed path-injection via shell variable substitution into JS string.
12
+ * This subcommand replaces the inline JS — hooks now invoke `dw agent
13
+ * check-staged --stdin` cleanly via the installed binary.
14
+ *
15
+ * Output contract (cooperative-only per ADR-0009 R2):
16
+ * - stdout: warning text if any staged files fall outside active claim
17
+ * write_scopes; empty otherwise
18
+ * - exit code: ALWAYS 0 (cooperative protocol — never blocks commits)
19
+ *
20
+ * Hook detects out-of-scope by checking stdout is non-empty.
21
+ */
22
+ export async function agentCheckStagedCommand(files, opts = {}) {
23
+ const rootDir = process.cwd();
24
+ let inputFiles = Array.isArray(files) ? [...files] : [];
25
+
26
+ if (opts.stdin) {
27
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
28
+ for await (const line of rl) {
29
+ const trimmed = line.trim();
30
+ if (trimmed) inputFiles.push(trimmed);
31
+ }
32
+ }
33
+
34
+ // No files to check → silent success
35
+ if (inputFiles.length === 0) {
36
+ process.exit(0);
37
+ return;
38
+ }
39
+
40
+ // No active claims → nothing to check against; silent success
41
+ const claims = listClaims(rootDir).filter(
42
+ (c) => c._live_status === 'created' || c._live_status === 'active'
43
+ );
44
+ if (claims.length === 0) {
45
+ process.exit(0);
46
+ return;
47
+ }
48
+
49
+ // Aggregate all active write_scopes. pathMatchesScope normalizes separators
50
+ // internally (Bug 2 fix) so legacy backslash claims still match correctly.
51
+ const allScopes = claims.flatMap((c) => c.write_scope);
52
+ const outside = inputFiles.filter((f) => !pathMatchesScope(f, allScopes));
53
+
54
+ if (outside.length > 0) {
55
+ console.log('Files NOT in any active claim write_scope:');
56
+ for (const f of outside.slice(0, 10)) console.log(' ' + f);
57
+ if (outside.length > 10) console.log(' ... +' + (outside.length - 10) + ' more');
58
+ // Black-bot C-4: exit 0 here intentional (cooperative protocol; hook detects
59
+ // out-of-scope via non-empty stdout, not exit code). Single exit at end below.
60
+ }
61
+
62
+ process.exit(0);
63
+ }
@@ -2,9 +2,10 @@ import chalk from 'chalk';
2
2
  import { existsSync, openSync, closeSync, mkdirSync, unlinkSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { randomBytes } from 'node:crypto';
5
- import { createClaim, persistClaim, validateClaim, transitionClaim, loadClaim, listClaims } from '../lib/agent-claim.mjs';
5
+ import { createClaim, persistClaim, validateClaim, transitionClaim, loadClaim, listClaims, renewClaim, computeEffectiveExpiry, MAX_RENEWALS } from '../lib/agent-claim.mjs';
6
6
  import { logAgentEvent } from '../lib/agent-events.mjs';
7
- import { scopesOverlap } from '../lib/agent-conflict.mjs';
7
+ import { scopesOverlap, normalizeSep, pathMatchesScope, claimsShareFilesystem } from '../lib/agent-conflict.mjs';
8
+ import { spawnSync } from 'node:child_process';
8
9
  import { logEvent as logTelemetry } from '../lib/telemetry.mjs';
9
10
 
10
11
  const LOCK_DIR = '.dw/cache/agents';
@@ -71,8 +72,11 @@ export async function agentClaimCommand(taskId, opts = {}) {
71
72
  }
72
73
 
73
74
  const role = opts.role || 'worker';
74
- const writeScope = parseList(opts.write);
75
- const readScope = parseList(opts.read);
75
+ // Issue #13 Bug 2: normalize Windows backslash → forward slash at claim creation
76
+ // so persisted JSON is always forward-slash. Legacy claims handled via runtime
77
+ // normalization in pathMatchesScope.
78
+ const writeScope = parseList(opts.write).map(normalizeSep);
79
+ const readScope = parseList(opts.read).map(normalizeSep);
76
80
  const leaseSeconds = parseLease(opts.lease || '1h');
77
81
 
78
82
  if (role !== 'reader' && writeScope.length === 0) {
@@ -124,7 +128,10 @@ export async function agentClaimCommand(taskId, opts = {}) {
124
128
  } finally {
125
129
  releaseClaimLock(lock);
126
130
  }
127
- logAgentEvent(taskId, {
131
+ // Issue #14 Bug 3: check logAgentEvent return; warn on stderr if audit log write failed.
132
+ // Claim succeeds (write to .dw/cache/.../claims/*.json already done above); only the audit
133
+ // entry is at risk (Windows file lock, full disk, permission).
134
+ const auditResult = logAgentEvent(taskId, {
128
135
  event: 'claim_created',
129
136
  claim_id: claim.claim_id,
130
137
  agent_id: agentId,
@@ -134,7 +141,11 @@ export async function agentClaimCommand(taskId, opts = {}) {
134
141
  write_scope: writeScope,
135
142
  lease_expires: claim.lease_expires,
136
143
  }, rootDir);
137
- logTelemetry({ event: 'agent', action: 'claim.create', name: taskId, vendor, role }, rootDir);
144
+ if (!auditResult.ok) {
145
+ console.error(chalk.yellow(`⚠ Warning: failed to write claim_created to events.jsonl: ${auditResult.error?.message || 'unknown'}`));
146
+ console.error(chalk.dim(' Claim was created but audit log has a gap.'));
147
+ }
148
+ logTelemetry({ event: 'agent', action: 'claim.create', name: taskId, vendor, role, audit_ok: auditResult.ok }, rootDir);
138
149
 
139
150
  console.log();
140
151
  console.log(chalk.green('✓') + ` Claim created: ${chalk.cyan(claim.claim_id)}`);
@@ -149,6 +160,44 @@ export async function agentClaimCommand(taskId, opts = {}) {
149
160
  console.log();
150
161
  }
151
162
 
163
+ // Issue #14 Gap fix — write_reconciled event source. Enumerate staged files (or fall back to
164
+ // HEAD diff if staging is empty per black-bot H-5) and flag any outside the claim's write_scope.
165
+ // Uses spawnSync with 2s timeout (black-bot H-6: previous proposal had execSync 5s — too long
166
+ // on large repos with network FS).
167
+ function computeWriteReconcile(rootDir, writeScope) {
168
+ const args = ['diff', '--name-only', '--cached'];
169
+ let result;
170
+ try {
171
+ result = spawnSync('git', args, { cwd: rootDir, encoding: 'utf-8', timeout: 2000, shell: false });
172
+ } catch {
173
+ return { method: 'none', files: [], outOfScope: [] };
174
+ }
175
+ if (result.error || result.status !== 0) {
176
+ return { method: 'none', files: [], outOfScope: [] };
177
+ }
178
+ let files = (result.stdout || '').split('\n').map((f) => f.trim()).filter(Boolean);
179
+ let method = 'git-staged';
180
+ if (files.length === 0) {
181
+ // Black-bot H-5: staging empty (likely agent committed before release) → try HEAD diff
182
+ let headResult;
183
+ try {
184
+ headResult = spawnSync('git', ['diff', 'HEAD', '--name-only'], {
185
+ cwd: rootDir, encoding: 'utf-8', timeout: 2000, shell: false,
186
+ });
187
+ } catch {
188
+ return { method: 'none', files: [], outOfScope: [] };
189
+ }
190
+ if (headResult.error || headResult.status !== 0) {
191
+ return { method: 'none', files: [], outOfScope: [] };
192
+ }
193
+ files = (headResult.stdout || '').split('\n').map((f) => f.trim()).filter(Boolean);
194
+ if (files.length > 0) method = 'git-head-diff';
195
+ else return { method: 'none', files: [], outOfScope: [] };
196
+ }
197
+ const outOfScope = files.filter((f) => !pathMatchesScope(f, writeScope));
198
+ return { method, files, outOfScope };
199
+ }
200
+
152
201
  function checkOverlapAgainstActive(newClaim, rootDir) {
153
202
  // Reviewer Warning #3: write_scope collisions must be detected REPO-WIDE,
154
203
  // not just within the same task. Subtask overlap remains task-scoped (IDs only
@@ -164,10 +213,14 @@ function checkOverlapAgainstActive(newClaim, rootDir) {
164
213
  const subtaskOverlap = sameTask
165
214
  ? newClaim.subtasks.filter((s) => e.subtasks.includes(s))
166
215
  : [];
216
+ // Issue #15: skip write_scope comparison when claims operate in distinct worktrees
217
+ const worktreeIsolated = !claimsShareFilesystem(newClaim, e);
167
218
  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 });
219
+ if (!worktreeIsolated) {
220
+ for (const pa of newClaim.write_scope) {
221
+ for (const pb of e.write_scope) {
222
+ if (scopesOverlap(pa, pb)) writeOverlap.push({ new: pa, existing: pb });
223
+ }
171
224
  }
172
225
  }
173
226
  if (subtaskOverlap.length || writeOverlap.length) {
@@ -175,6 +228,7 @@ function checkOverlapAgainstActive(newClaim, rootDir) {
175
228
  if (subtaskOverlap.length) reasons.push(`subtasks ${subtaskOverlap.join(',')}`);
176
229
  if (writeOverlap.length) reasons.push(`write scope ${writeOverlap.map((o) => o.existing).join(',')}`);
177
230
  if (!sameTask) reasons.push(`(cross-task: ${e.task_id})`);
231
+ if (worktreeIsolated) reasons.push('(worktree-isolated, write scope ignored)');
178
232
  conflicts.push({ claim_id: e.claim_id, agent_id: e.agent.id, reason: reasons.join('; ') });
179
233
  }
180
234
  }
@@ -197,13 +251,36 @@ export async function agentReleaseCommand(claimId, opts = {}) {
197
251
  console.error(chalk.red(`✗ Cannot release: ${result.reason}`));
198
252
  process.exit(1);
199
253
  }
200
- logAgentEvent(claim.task_id, {
254
+ // Issue #14 Gap fix — write_reconciled: enumerate actual staged files at release time
255
+ // and flag any that fall outside this claim's write_scope.
256
+ // Black-bot H-5 fix: fall back to `git diff HEAD` when staging empty (agent committed first).
257
+ // Black-bot H-6 fix: spawnSync with shell:false + 2s timeout (was execSync 5s).
258
+ const reconcile = computeWriteReconcile(rootDir, claim.write_scope);
259
+ if (reconcile.method !== 'none') {
260
+ const recResult = logAgentEvent(claim.task_id, {
261
+ event: 'write_reconciled',
262
+ claim_id: claimId,
263
+ agent_id: claim.agent.id,
264
+ staged_files: reconcile.files,
265
+ out_of_scope: reconcile.outOfScope,
266
+ reconcile_method: reconcile.method,
267
+ }, rootDir);
268
+ if (!recResult.ok) {
269
+ console.error(chalk.yellow(`⚠ Warning: failed to write write_reconciled to events.jsonl: ${recResult.error?.message || 'unknown'}`));
270
+ }
271
+ }
272
+
273
+ // Issue #14 Bug 3: check audit log write for claim_released
274
+ const releaseAudit = logAgentEvent(claim.task_id, {
201
275
  event: 'claim_released',
202
276
  claim_id: claimId,
203
277
  agent_id: claim.agent.id,
204
278
  reason: opts.reason || 'clean exit',
205
279
  }, rootDir);
206
- logTelemetry({ event: 'agent', action: 'claim.release', name: claim.task_id }, rootDir);
280
+ if (!releaseAudit.ok) {
281
+ console.error(chalk.yellow(`⚠ Warning: failed to write claim_released to events.jsonl: ${releaseAudit.error?.message || 'unknown'}`));
282
+ }
283
+ logTelemetry({ event: 'agent', action: 'claim.release', name: claim.task_id, audit_ok: releaseAudit.ok }, rootDir);
207
284
  console.log(chalk.green('✓') + ` Released: ${claimId}`);
208
285
  }
209
286
 
@@ -224,12 +301,87 @@ export async function agentExpireCommand(claimId, opts = {}) {
224
301
  console.error(chalk.red(`✗ Cannot ${status}: ${result.reason}`));
225
302
  process.exit(1);
226
303
  }
227
- logAgentEvent(claim.task_id, {
304
+ // Issue #14 Bug 3: check audit log write
305
+ const expireAudit = logAgentEvent(claim.task_id, {
228
306
  event: status === 'invalidated' ? 'claim_invalidated' : 'claim_expired',
229
307
  claim_id: claimId,
230
308
  agent_id: claim.agent.id,
231
309
  reason: opts.reason || 'manual',
232
310
  }, rootDir);
233
- logTelemetry({ event: 'agent', action: `claim.${status}`, name: claim.task_id }, rootDir);
311
+ if (!expireAudit.ok) {
312
+ console.error(chalk.yellow(`⚠ Warning: failed to write claim_${status} to events.jsonl: ${expireAudit.error?.message || 'unknown'}`));
313
+ }
314
+ logTelemetry({ event: 'agent', action: `claim.${status}`, name: claim.task_id, audit_ok: expireAudit.ok }, rootDir);
234
315
  console.log(chalk.yellow(`⚠ ${status}: ${claimId}`));
235
316
  }
317
+
318
+ // Issue #15 Gap fix — dw agent renew <claim-id> [--lease <dur>]
319
+ // Extends lease in-place without invalidating claim_id (preserves events.jsonl audit continuity).
320
+ // Acquires claim.lock (black-bot C-2: avoid TOCTOU vs concurrent claim/release/renew).
321
+ export async function agentRenewCommand(claimId, opts = {}) {
322
+ const rootDir = process.cwd();
323
+ if (!claimId) {
324
+ console.error(chalk.red('✗ Claim ID required: dw agent renew <claim-id> --lease <dur>'));
325
+ process.exit(1);
326
+ }
327
+ const extensionSeconds = parseLease(opts.lease || '1h');
328
+ if (extensionSeconds < 60 || extensionSeconds > 86400) {
329
+ console.error(chalk.red(`✗ Invalid --lease ${opts.lease}: must be 60s..24h`));
330
+ process.exit(1);
331
+ }
332
+
333
+ const lock = acquireClaimLock(rootDir);
334
+ if (!lock) {
335
+ console.error(chalk.red(`✗ Could not acquire claim lock within ${LOCK_TIMEOUT_MS}ms.`));
336
+ process.exit(1);
337
+ }
338
+ let result;
339
+ let preClaim;
340
+ try {
341
+ preClaim = loadClaim(claimId, rootDir);
342
+ if (preClaim) {
343
+ // Warn near-expiry / already-expired for operator awareness
344
+ const remaining = computeEffectiveExpiry(preClaim) - Date.now();
345
+ if (remaining <= 0) {
346
+ console.error(chalk.yellow(`⚠ Claim lease was already expired at renewal time (wall-clock check); rejecting per claim safety.`));
347
+ } else if (remaining < 10 * 60 * 1000) {
348
+ console.error(chalk.yellow(`⚠ Lease had < 10 min remaining (${Math.floor(remaining / 1000)}s) before renewal.`));
349
+ }
350
+ }
351
+ result = renewClaim(claimId, extensionSeconds, rootDir);
352
+ } finally {
353
+ releaseClaimLock(lock);
354
+ }
355
+
356
+ if (!result.ok) {
357
+ console.error(chalk.red(`✗ Cannot renew: ${result.reason}`));
358
+ process.exit(1);
359
+ }
360
+
361
+ const auditResult = logAgentEvent(result.claim.task_id, {
362
+ event: 'claim_renewed',
363
+ claim_id: claimId,
364
+ agent_id: result.claim.agent.id,
365
+ previous_lease_expires: result.claim.previous_lease_expires,
366
+ new_lease_expires: result.claim.lease_expires,
367
+ extension_seconds: extensionSeconds,
368
+ renewal_count: result.claim.renewal_count,
369
+ }, rootDir);
370
+ if (!auditResult.ok) {
371
+ console.error(chalk.yellow(`⚠ Warning: failed to write claim_renewed to events.jsonl: ${auditResult.error?.message || 'unknown'}`));
372
+ }
373
+ logTelemetry({
374
+ event: 'agent', action: 'claim.renew', name: result.claim.task_id,
375
+ renewal_count: result.claim.renewal_count, max_renewals: MAX_RENEWALS,
376
+ audit_ok: auditResult.ok,
377
+ }, rootDir);
378
+
379
+ console.log(chalk.green('✓') + ` Renewed: ${chalk.cyan(claimId)}`);
380
+ console.log(chalk.dim(` previous lease_expires: ${result.claim.previous_lease_expires}`));
381
+ console.log(chalk.dim(` new lease_expires: ${result.claim.lease_expires}`));
382
+ console.log(chalk.dim(` extension: ${extensionSeconds}s`));
383
+ console.log(chalk.dim(` renewal_count: ${result.claim.renewal_count} / ${MAX_RENEWALS}`));
384
+ if (result.claim.renewal_count >= MAX_RENEWALS) {
385
+ console.log(chalk.yellow(` ⚠ Max renewals reached. Next renewal will be rejected — release this claim and reclaim if you need more time.`));
386
+ }
387
+ }
@@ -114,6 +114,10 @@ export async function agentConflictsCommand(opts = {}) {
114
114
  for (const w of c.write_overlap) {
115
115
  console.log(chalk.red(` write overlap: "${w.a}" ↔ "${w.b}"`));
116
116
  }
117
+ // Issue #15 black-bot H-2: explain when write_overlap is empty due to worktree isolation
118
+ if (c.worktree_isolated && c.write_overlap.length === 0) {
119
+ console.log(chalk.dim(' note: write scopes skipped (claims operate in distinct worktrees — physically isolated)'));
120
+ }
117
121
  console.log();
118
122
  }
119
123
 
@@ -0,0 +1,75 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { eventsFile, readEvents } from '../lib/agent-events.mjs';
4
+ import { logEvent } from '../lib/telemetry.mjs';
5
+
6
+ /**
7
+ * `dw agent verify <task-id>` — well-formedness check on events.jsonl.
8
+ *
9
+ * IMPORTANT (black-bot H-7 honesty): this command detects ONLY malformed JSON lines.
10
+ * It does NOT detect:
11
+ * - Deleted lines (undetectable without sequence numbers / hash chain)
12
+ * - Reordered lines with valid JSON
13
+ * - In-place edits that produce valid but different JSON
14
+ *
15
+ * For stronger tamper-evidence, rely on git: `events.jsonl` is committed, so
16
+ * `git log --follow -- .dw/tasks/<task>/events.jsonl` reveals all modifications.
17
+ * Hash-chain integrity was explicitly deferred per lean mandate (ADR-0001).
18
+ */
19
+ export async function agentVerifyCommand(taskId, opts = {}) {
20
+ const rootDir = process.cwd();
21
+
22
+ if (!taskId) {
23
+ console.error(chalk.red('✗ Task ID required: dw agent verify <task-id>'));
24
+ process.exit(1);
25
+ }
26
+
27
+ const file = eventsFile(taskId, rootDir);
28
+ if (!existsSync(file)) {
29
+ console.log(chalk.dim(` No events.jsonl for task ${taskId}`));
30
+ return;
31
+ }
32
+
33
+ const allLines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
34
+ const parsed = readEvents(taskId, rootDir, { strict: true });
35
+
36
+ const malformed = parsed.filter((e) => e._malformed);
37
+ const valid = parsed.filter((e) => !e._malformed);
38
+
39
+ logEvent({
40
+ event: 'agent',
41
+ action: 'verify',
42
+ name: taskId,
43
+ total: allLines.length,
44
+ valid: valid.length,
45
+ malformed: malformed.length,
46
+ }, rootDir);
47
+
48
+ console.log();
49
+ console.log(chalk.bold(` events.jsonl — ${taskId}`));
50
+ console.log(chalk.dim(` file: ${file}`));
51
+ console.log(chalk.dim(` total lines: ${allLines.length}`));
52
+ console.log(chalk.dim(` parsed ok: ${valid.length}`));
53
+
54
+ if (malformed.length === 0) {
55
+ console.log(chalk.green(' ✓ All lines are valid JSON (well-formedness check passed)'));
56
+ console.log(chalk.dim(' Note: this is well-formedness, not tamper-evidence. Use'));
57
+ console.log(chalk.dim(` \`git log --follow -- ${file.replace(rootDir, '').replace(/^[/\\]/, '')}\` for full history.`));
58
+ console.log();
59
+ return;
60
+ }
61
+
62
+ console.log(chalk.red(` ✗ ${malformed.length} malformed line(s) detected:`));
63
+ console.log();
64
+ for (const m of malformed) {
65
+ console.log(chalk.red(` line ${m._line}: ${m._error}`));
66
+ if (opts.verbose) console.log(chalk.dim(` raw: ${m._raw.slice(0, 120)}${m._raw.length > 120 ? '…' : ''}`));
67
+ }
68
+ console.log();
69
+ console.log(chalk.dim(' Tip: `git log -- ' + file.replace(rootDir, '').replace(/^[/\\]/, '') + '` to inspect commit history.'));
70
+ console.log();
71
+
72
+ if (opts.strict !== false) {
73
+ process.exit(1);
74
+ }
75
+ }
@@ -79,7 +79,9 @@ function ensureBrowserClaim(goalId, rootDir) {
79
79
  }, rootDir);
80
80
  persistClaim(claim, rootDir);
81
81
  browserClaims.set(goalId, claim.claim_id);
82
- logAgentEvent(goalToTaskId(goalId), {
82
+ // Issue #14 Bug 3 + black-bot H-4: handle audit log write failure in HTTP context
83
+ // (no terminal to print to — use telemetry instead).
84
+ const auditResult = logAgentEvent(goalToTaskId(goalId), {
83
85
  event: 'claim_created',
84
86
  claim_id: claim.claim_id,
85
87
  agent: claim.agent,
@@ -87,6 +89,13 @@ function ensureBrowserClaim(goalId, rootDir) {
87
89
  write_scope: claim.write_scope,
88
90
  via: 'browser_implicit',
89
91
  }, rootDir);
92
+ if (!auditResult.ok) {
93
+ logEvent({
94
+ event: 'audit', action: 'write_failed',
95
+ context: 'browser_claim_created', goal_id: goalId,
96
+ error: String(auditResult.error?.message || 'unknown'),
97
+ }, rootDir);
98
+ }
90
99
  return claim.claim_id;
91
100
  }
92
101
 
@@ -113,12 +122,19 @@ function releaseBrowserClaim(goalId, rootDir, reason = 'browser closed') {
113
122
  if (!claimId) return null;
114
123
  try {
115
124
  transitionClaim(claimId, 'released', { reason }, rootDir);
116
- logAgentEvent(goalToTaskId(goalId), {
125
+ const auditResult = logAgentEvent(goalToTaskId(goalId), {
117
126
  event: 'claim_released',
118
127
  claim_id: claimId,
119
128
  reason,
120
129
  via: 'browser_implicit',
121
130
  }, rootDir);
131
+ if (!auditResult.ok) {
132
+ logEvent({
133
+ event: 'audit', action: 'write_failed',
134
+ context: 'browser_claim_released', goal_id: goalId,
135
+ error: String(auditResult.error?.message || 'unknown'),
136
+ }, rootDir);
137
+ }
122
138
  } catch { /* claim already gone */ }
123
139
  browserClaims.delete(goalId);
124
140
  return claimId;
@@ -78,11 +78,58 @@ export function createClaim({ taskId, agent, subtasks, writeScope = [], readScop
78
78
  lease_duration_seconds: leaseSeconds,
79
79
  status: 'created',
80
80
  created_at: created,
81
+ // Issue #15 black-bot H-3: immutable creation-time expiry caps unbounded renewal compounding
82
+ original_lease_expires: expires,
81
83
  };
82
84
  if (worktreePath) claim.worktree_path = worktreePath;
83
85
  return claim;
84
86
  }
85
87
 
88
+ // Issue #15 Gap fix — extend lease in-place on active/created claim WITHOUT changing claim_id
89
+ // (preserves audit continuity in events.jsonl). Caller MUST hold claim.lock per black-bot C-2.
90
+ // Returns same shape as transitionClaim for consistency.
91
+ export const MAX_RENEWALS = 3;
92
+
93
+ export function renewClaim(claimId, extensionSeconds, rootDir = process.cwd()) {
94
+ const claim = loadClaim(claimId, rootDir);
95
+ if (!claim) return { ok: false, reason: 'not-found' };
96
+
97
+ // Black-bot C-3: check wall-clock expiry, not just persisted status. A claim with
98
+ // status="active" but elapsed lease should not be silently renewed.
99
+ if (claim.status === 'released' || claim.status === 'expired' || claim.status === 'invalidated') {
100
+ return { ok: false, reason: `invalid-state: cannot renew ${claim.status} claim` };
101
+ }
102
+ if (computeEffectiveExpiry(claim) < Date.now()) {
103
+ return { ok: false, reason: 'already-expired-by-wall-clock' };
104
+ }
105
+
106
+ if (!Number.isFinite(extensionSeconds) || extensionSeconds < 60 || extensionSeconds > 86400) {
107
+ return { ok: false, reason: 'invalid-duration: must be 60–86400 seconds' };
108
+ }
109
+
110
+ // Black-bot H-3 cap: prevent unbounded lease compounding
111
+ const renewalCount = claim.renewal_count || 0;
112
+ if (renewalCount >= MAX_RENEWALS) {
113
+ return { ok: false, reason: `max-renewals-exceeded: ${renewalCount}/${MAX_RENEWALS}` };
114
+ }
115
+
116
+ // Black-bot H-4: preserve audit history on claim file itself
117
+ claim.previous_lease_expires = claim.lease_expires;
118
+ claim.renewed_at = nowIsoUtc();
119
+ claim.renewal_count = renewalCount + 1;
120
+ claim.lease_expires = new Date(Date.now() + extensionSeconds * 1000).toISOString().replace(/\.\d+Z$/, 'Z');
121
+ claim.lease_duration_seconds = extensionSeconds;
122
+
123
+ // Black-bot M-2: validate after mutation to catch programmatic misuse
124
+ const v = validateClaim(claim, rootDir);
125
+ if (!v.ok) {
126
+ return { ok: false, reason: 'schema-invalid: ' + (v.errors || []).map((e) => e.message).join(', ') };
127
+ }
128
+
129
+ persistClaim(claim, rootDir);
130
+ return { ok: true, claim };
131
+ }
132
+
86
133
  export function persistClaim(claim, rootDir = process.cwd()) {
87
134
  const dir = claimsDir(rootDir);
88
135
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
@@ -1,10 +1,53 @@
1
1
  import { listClaims, loadClaim, computeEffectiveExpiry } from './agent-claim.mjs';
2
+ import { resolve } from 'node:path';
3
+ import { realpathSync } from 'node:fs';
4
+
5
+ // Issue #13 Bug 2: normalize Windows backslash → forward slash at caller boundary
6
+ // (NOT inside globToRegex — keeps globToRegex's single-responsibility intact).
7
+ // Applied at: scopesOverlap entry, pathMatchesScope per-pattern AND per-path,
8
+ // agent-claim writeScope parse time (in agentClaimCommand).
9
+ export function normalizeSep(s) {
10
+ if (s == null) return s;
11
+ return String(s).replace(/\\/g, '/');
12
+ }
13
+
14
+ // Issue #15 black-bot C-1 fix: full path canonicalization for worktree comparison.
15
+ // normalizeSep alone misses trailing slash, relative paths, Windows case-insensitivity,
16
+ // and symlinks. Two claims in the same physical worktree with different path
17
+ // representations would bypass the isolation guard otherwise.
18
+ export function canonicalizeWorktreePath(p) {
19
+ if (p == null) return null;
20
+ let s = normalizeSep(String(p));
21
+ // Strip trailing slashes (but keep root '/')
22
+ s = s.replace(/\/+$/, '') || '/';
23
+ // Resolve relative paths to absolute
24
+ try { s = normalizeSep(resolve(s)); } catch { /* keep s */ }
25
+ // Best-effort symlink resolution (path may not exist — that's fine)
26
+ try { s = normalizeSep(realpathSync(s)); } catch { /* keep resolved value */ }
27
+ // Windows: NTFS case-insensitive → lowercase
28
+ if (process.platform === 'win32') s = s.toLowerCase();
29
+ return s;
30
+ }
31
+
32
+ // Issue #15 Bug fix — worktree-aware overlap.
33
+ // Two claims in distinct non-null worktrees are physically isolated → no scope overlap.
34
+ // One or both null (main checkout) → conservative; assume overlap.
35
+ export function claimsShareFilesystem(claimA, claimB) {
36
+ const wa = canonicalizeWorktreePath(claimA && claimA.worktree_path);
37
+ const wb = canonicalizeWorktreePath(claimB && claimB.worktree_path);
38
+ if (wa !== null && wb !== null && wa !== wb) return false;
39
+ return true;
40
+ }
2
41
 
3
42
  // Glob → RegExp following gitignore-style semantics:
4
43
  // `**` matches zero-or-more path segments (including empty)
5
44
  // `*` matches anything within a segment (no `/`)
6
45
  // `?` matches single char (no `/`)
7
46
  // Behaviour validated by reviewer Critical #1: src/**/*.js MUST match src/x.js.
47
+ //
48
+ // Note (issue #13 Bug 2 / black-bot H-8): globToRegex stays pure — it does NOT
49
+ // normalize separators. Callers (pathMatchesScope, scopesOverlap, etc.) are
50
+ // responsible for normalizing inputs before invoking globToRegex.
8
51
  export function globToRegex(glob) {
9
52
  let pattern = '';
10
53
  for (let i = 0; i < glob.length; i++) {
@@ -41,40 +84,137 @@ export function globToRegex(glob) {
41
84
  return new RegExp(`^${pattern}$`);
42
85
  }
43
86
 
87
+ // Issue #13 Bug 2 + H-5 + H-7: normalize BOTH path AND each pattern.
88
+ // This single location covers all callers (detectOutOfScopeEdits, check-staged hook,
89
+ // programmatic agentCheckStagedCommand).
44
90
  export function pathMatchesScope(path, scope) {
91
+ const np = normalizeSep(path);
45
92
  for (const pattern of scope) {
46
- if (globToRegex(pattern).test(path)) return true;
93
+ const npattern = normalizeSep(pattern);
94
+ if (globToRegex(npattern).test(np)) return true;
47
95
  }
48
96
  return false;
49
97
  }
50
98
 
51
- // Two globs/paths overlap if there exists a concrete path matching both.
52
- // We approximate by:
53
- // - exact string equality
54
- // - exact path matches a glob in the other set
55
- // - both contain wildcards: try a small biased sample set at depths 0/1/2
99
+ // Issue #13 Bug 1: Two globs/paths overlap if there exists a concrete path
100
+ // matching both. Previous implementation used a 3-sample heuristic which
101
+ // produced false negatives (e.g. `a/*/x.js` vs `a/y/*.js` share `a/y/x.js`
102
+ // but neither's sample set generated that path).
103
+ //
104
+ // Replaced with segment-by-segment intersection (memoized + depth-guarded
105
+ // per black-bot C-1). For glob-vs-glob single-segment subcases, segmentMeet
106
+ // uses a heuristic candidate that the outer regex test validates (black-bot
107
+ // C-2 — honest framing: "heuristic-but-validated", not pure deterministic).
56
108
  export function scopesOverlap(a, b) {
109
+ a = normalizeSep(a);
110
+ b = normalizeSep(b);
57
111
  if (a === b) return true;
112
+
58
113
  const aHasGlob = /[*?]/.test(a);
59
114
  const bHasGlob = /[*?]/.test(b);
60
115
  const ra = globToRegex(a);
61
116
  const rb = globToRegex(b);
62
117
 
63
- // Concrete path vs glob: test the concrete one against the glob, both ways.
64
- if (!aHasGlob && bHasGlob) return rb.test(a);
65
- if (aHasGlob && !bHasGlob) return ra.test(b);
118
+ if (!aHasGlob && !bHasGlob) return false; // both literal + non-equal
119
+ if (!aHasGlob) return rb.test(a);
120
+ if (!bHasGlob) return ra.test(b);
121
+
122
+ // Both globs — segment intersection
123
+ const candidate = globIntersectCandidate(a.split('/'), b.split('/'));
124
+ if (candidate === null) return false;
125
+ return ra.test(candidate) && rb.test(candidate);
126
+ }
127
+
128
+ // Bound recursion depth on degenerate patterns (e.g. `**/**/**/**` chains).
129
+ // Returning null on guard trip means "no candidate found" — outer scopesOverlap
130
+ // returns false (cooperative fallback; never silent stack overflow).
131
+ const MAX_INTERSECT_DEPTH = 256;
132
+
133
+ export function globIntersectCandidate(segsA, segsB) {
134
+ return _intersect(segsA, 0, segsB, 0, new Map(), 0);
135
+ }
136
+
137
+ function _intersect(segsA, iA, segsB, iB, memo, depth) {
138
+ if (depth > MAX_INTERSECT_DEPTH) return null;
139
+ const key = iA + '-' + iB;
140
+ if (memo.has(key)) return memo.get(key);
141
+
142
+ const doneA = iA >= segsA.length;
143
+ const doneB = iB >= segsB.length;
144
+
145
+ if (doneA && doneB) { memo.set(key, ''); return ''; }
66
146
 
67
- // Both globs synthesise candidate concrete paths and test against both.
68
- const samples = new Set();
69
- for (const g of [a, b]) {
70
- samples.add(g.replace(/\*\*\//g, '').replace(/\*\*/g, '').replace(/\*/g, 'X').replace(/\?/g, 'Y'));
71
- samples.add(g.replace(/\*\*\//g, 'd/').replace(/\*\*/g, 'd').replace(/\*/g, 'X').replace(/\?/g, 'Y'));
72
- samples.add(g.replace(/\*\*\//g, 'd/e/').replace(/\*\*/g, 'd/e').replace(/\*/g, 'X').replace(/\?/g, 'Y'));
147
+ const sA = doneA ? null : segsA[iA];
148
+ const sB = doneB ? null : segsB[iB];
149
+
150
+ if (doneA) {
151
+ const v = sB === '**' ? _intersect(segsA, iA, segsB, iB + 1, memo, depth + 1) : null;
152
+ memo.set(key, v); return v;
73
153
  }
74
- for (const s of samples) {
75
- if (ra.test(s) && rb.test(s)) return true;
154
+ if (doneB) {
155
+ const v = sA === '**' ? _intersect(segsA, iA + 1, segsB, iB, memo, depth + 1) : null;
156
+ memo.set(key, v); return v;
76
157
  }
77
- return false;
158
+
159
+ if (sA === '**' && sB === '**') {
160
+ const tryA = _intersect(segsA, iA + 1, segsB, iB, memo, depth + 1);
161
+ if (tryA !== null) { memo.set(key, tryA); return tryA; }
162
+ const tryB = _intersect(segsA, iA, segsB, iB + 1, memo, depth + 1);
163
+ memo.set(key, tryB); return tryB;
164
+ }
165
+
166
+ if (sA === '**') {
167
+ const skip = _intersect(segsA, iA + 1, segsB, iB, memo, depth + 1);
168
+ if (skip !== null) { memo.set(key, skip); return skip; }
169
+ const consume = _intersect(segsA, iA, segsB, iB + 1, memo, depth + 1);
170
+ if (consume !== null) {
171
+ const concrete = concreteSegment(sB);
172
+ const result = concrete + (consume === '' ? '' : '/' + consume);
173
+ memo.set(key, result); return result;
174
+ }
175
+ memo.set(key, null); return null;
176
+ }
177
+ if (sB === '**') {
178
+ const skip = _intersect(segsA, iA, segsB, iB + 1, memo, depth + 1);
179
+ if (skip !== null) { memo.set(key, skip); return skip; }
180
+ const consume = _intersect(segsA, iA + 1, segsB, iB, memo, depth + 1);
181
+ if (consume !== null) {
182
+ const concrete = concreteSegment(sA);
183
+ const result = concrete + (consume === '' ? '' : '/' + consume);
184
+ memo.set(key, result); return result;
185
+ }
186
+ memo.set(key, null); return null;
187
+ }
188
+
189
+ const seg = segmentMeet(sA, sB);
190
+ if (seg === null) { memo.set(key, null); return null; }
191
+ const rest = _intersect(segsA, iA + 1, segsB, iB + 1, memo, depth + 1);
192
+ if (rest === null) { memo.set(key, null); return null; }
193
+ const result = rest === '' ? seg : seg + '/' + rest;
194
+ memo.set(key, result); return result;
195
+ }
196
+
197
+ // Returns concrete string satisfying both single-segment patterns, or null.
198
+ // For glob-vs-glob: heuristic candidate (outer scopesOverlap validates via
199
+ // regex.test on both). Honest framing per black-bot C-2.
200
+ function segmentMeet(sA, sB) {
201
+ const aGlob = /[*?]/.test(sA);
202
+ const bGlob = /[*?]/.test(sB);
203
+ if (!aGlob && !bGlob) return sA === sB ? sA : null;
204
+ if (!aGlob) return globToRegex(sB).test(sA) ? sA : null;
205
+ if (!bGlob) return globToRegex(sA).test(sB) ? sB : null;
206
+ // Both segments wildcard: try candidate from sA; if mismatch, try sB; else 'x'.
207
+ // Outer validator catches incorrect candidates.
208
+ const candA = concreteSegment(sA);
209
+ if (globToRegex(sB).test(candA)) return candA;
210
+ const candB = concreteSegment(sB);
211
+ if (globToRegex(sA).test(candB)) return candB;
212
+ return 'x';
213
+ }
214
+
215
+ function concreteSegment(seg) {
216
+ const replaced = seg.replace(/\*+/g, 'x').replace(/\?/g, 'y');
217
+ return replaced || 'x';
78
218
  }
79
219
 
80
220
  export function detectClaimOverlaps(rootDir = process.cwd(), { taskId, includeAllTasks = false } = {}) {
@@ -106,10 +246,15 @@ export function detectClaimOverlaps(rootDir = process.cwd(), { taskId, includeAl
106
246
  const subtaskOverlap = a.task_id === b.task_id
107
247
  ? a.subtasks.filter((s) => b.subtasks.includes(s))
108
248
  : [];
249
+ // Issue #15: skip write_scope comparison when claims operate in distinct worktrees
250
+ // (physical isolation). Subtask overlap stays — that's semantic, not filesystem-based.
251
+ const worktreeIsolated = !claimsShareFilesystem(a, b);
109
252
  const writeOverlap = [];
110
- for (const pa of a.write_scope) {
111
- for (const pb of b.write_scope) {
112
- if (scopesOverlap(pa, pb)) writeOverlap.push({ a: pa, b: pb });
253
+ if (!worktreeIsolated) {
254
+ for (const pa of a.write_scope) {
255
+ for (const pb of b.write_scope) {
256
+ if (scopesOverlap(pa, pb)) writeOverlap.push({ a: pa, b: pb });
257
+ }
113
258
  }
114
259
  }
115
260
 
@@ -122,6 +267,7 @@ export function detectClaimOverlaps(rootDir = process.cwd(), { taskId, includeAl
122
267
  task_id: a.task_id === b.task_id ? a.task_id : `${a.task_id},${b.task_id}`,
123
268
  subtask_overlap: subtaskOverlap,
124
269
  write_overlap: writeOverlap,
270
+ worktree_isolated: worktreeIsolated, // black-bot H-2: explicit signal for output renderer
125
271
  });
126
272
  }
127
273
  }
@@ -132,6 +278,7 @@ export function detectClaimOverlaps(rootDir = process.cwd(), { taskId, includeAl
132
278
  export function detectOutOfScopeEdits(claimId, editedFiles, rootDir = process.cwd()) {
133
279
  const claim = loadClaim(claimId, rootDir);
134
280
  if (!claim) return { ok: false, reason: 'claim-not-found' };
281
+ // pathMatchesScope handles normalizeSep on both path AND each pattern internally
135
282
  const outside = editedFiles.filter((f) => !pathMatchesScope(f, claim.write_scope));
136
283
  return { ok: true, outside, total_edits: editedFiles.length };
137
284
  }
@@ -3,15 +3,74 @@ import { join, dirname } from 'node:path';
3
3
 
4
4
  const TASKS_DIR = '.dw/tasks';
5
5
  const MAX_EVENT_BYTES = 4096;
6
+ const CLAIM_ID_CLIP = 64;
6
7
 
7
8
  export function eventsFile(taskId, rootDir = process.cwd()) {
8
9
  return join(rootDir, TASKS_DIR, taskId, 'events.jsonl');
9
10
  }
10
11
 
12
+ // Issue #14 Bug 1 fix — Smart 3-step truncation preserves identifying fields.
13
+ // Previous behavior dropped EVERYTHING except ts + event (claim_id lost → audit useless).
14
+ // Step 1: truncate write_scope/read_scope arrays (the usual oversized field) when non-empty
15
+ // Step 2: hard-cap to IDENTIFY_FIELDS (ts, event, claim_id, agent_id, vendor, role, subtasks)
16
+ // Step 3: backstop for adversarial inputs — clip claim_id to 64 chars + subtasks to count summary
17
+ // (black-bot C-1)
18
+ const IDENTIFY_FIELDS = ['ts', 'event', 'claim_id', 'agent_id', 'vendor', 'role', 'subtasks'];
19
+
20
+ function smartTruncate(enriched, originalBytes) {
21
+ // Step 1: scope-only truncation when arrays are non-empty (black-bot M-10)
22
+ const candidate = { ...enriched };
23
+ const truncatedFields = [];
24
+ if (Array.isArray(candidate.write_scope) && candidate.write_scope.length > 0) {
25
+ truncatedFields.push('write_scope');
26
+ candidate.write_scope = [`(${candidate.write_scope.length} paths — truncated for size)`];
27
+ }
28
+ if (Array.isArray(candidate.read_scope) && candidate.read_scope.length > 0) {
29
+ truncatedFields.push('read_scope');
30
+ candidate.read_scope = [`(${candidate.read_scope.length} paths — truncated for size)`];
31
+ }
32
+ if (truncatedFields.length > 0) {
33
+ candidate._dw_meta = { truncated: true, truncated_fields: truncatedFields, original_size_bytes: originalBytes };
34
+ const afterScope = JSON.stringify(candidate) + '\n';
35
+ if (Buffer.byteLength(afterScope, 'utf8') <= MAX_EVENT_BYTES) return afterScope;
36
+ }
37
+
38
+ // Step 2: hard-cap to identifying fields
39
+ const minimal = {};
40
+ for (const f of IDENTIFY_FIELDS) {
41
+ if (enriched[f] !== undefined) minimal[f] = enriched[f];
42
+ }
43
+ minimal._dw_meta = { truncated: true, original_size_bytes: originalBytes };
44
+ let line = JSON.stringify(minimal) + '\n';
45
+ if (Buffer.byteLength(line, 'utf8') <= MAX_EVENT_BYTES) return line;
46
+
47
+ // Step 3: backstop for adversarial inputs (long claim_id from many concatenated subtask slugs;
48
+ // huge subtasks array). Clip + summarize. (black-bot C-1)
49
+ if (typeof minimal.claim_id === 'string' && minimal.claim_id.length > CLAIM_ID_CLIP) {
50
+ minimal.claim_id = minimal.claim_id.slice(0, CLAIM_ID_CLIP) + '…(clipped)';
51
+ }
52
+ if (Array.isArray(minimal.subtasks) && minimal.subtasks.length > 3) {
53
+ minimal.subtasks = [`(${minimal.subtasks.length} subtasks — truncated)`];
54
+ }
55
+ minimal._dw_meta = { truncated: true, original_size_bytes: originalBytes, hard_clipped: true };
56
+ return JSON.stringify(minimal) + '\n';
57
+ }
58
+
59
+ /**
60
+ * Append an event to .dw/tasks/{taskId}/events.jsonl.
61
+ *
62
+ * @returns {{ok: boolean, error?: Error}}
63
+ * `ok: false` means the write failed (file locked / permission / disk).
64
+ * All callers SHOULD check the return value and surface a warning if !ok.
65
+ * Previous boolean-only return was silently ignored at 3 CLI callers (issue #14 Bug 3).
66
+ */
11
67
  export function logAgentEvent(taskId, event, rootDir = process.cwd()) {
12
68
  const file = eventsFile(taskId, rootDir);
13
69
  const dir = dirname(file);
14
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
70
+ if (!existsSync(dir)) {
71
+ try { mkdirSync(dir, { recursive: true }); }
72
+ catch (e) { return { ok: false, error: e }; }
73
+ }
15
74
 
16
75
  const enriched = {
17
76
  ts: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
@@ -20,24 +79,54 @@ export function logAgentEvent(taskId, event, rootDir = process.cwd()) {
20
79
  let line = JSON.stringify(enriched) + '\n';
21
80
  const originalBytes = Buffer.byteLength(line, 'utf8');
22
81
  if (originalBytes > MAX_EVENT_BYTES) {
23
- const trimmed = { ts: enriched.ts, event: enriched.event || 'unknown', _truncated: true, _original_size_bytes: originalBytes };
24
- line = JSON.stringify(trimmed) + '\n';
82
+ line = smartTruncate(enriched, originalBytes);
25
83
  }
26
84
  try {
27
85
  appendFileSync(file, line, 'utf8');
28
- return true;
29
- } catch {
30
- return false;
86
+ return { ok: true };
87
+ } catch (e) {
88
+ return { ok: false, error: e };
31
89
  }
32
90
  }
33
91
 
34
- export function readEvents(taskId, rootDir = process.cwd()) {
92
+ // Track files for which we've already warned about silently-skipped lines (black-bot C-2:
93
+ // emit ONE warning per file per process, don't spam stderr on every read).
94
+ const _skipWarnedFiles = new Set();
95
+
96
+ /**
97
+ * Read events.jsonl for a task.
98
+ *
99
+ * @param {string} taskId
100
+ * @param {string} rootDir
101
+ * @param {object} opts
102
+ * @param {boolean} opts.strict When true, malformed lines are returned inline as
103
+ * `{_malformed: true, _line, _raw, _error}` (for `dw agent verify`).
104
+ * When false (default), malformed lines are skipped, but a
105
+ * stderr warning fires once per file per process (black-bot C-2:
106
+ * silent-skip-by-default = the original bug).
107
+ * @returns {object[]} Parsed events (plus `_malformed` markers if strict).
108
+ */
109
+ export function readEvents(taskId, rootDir = process.cwd(), { strict = false } = {}) {
35
110
  const file = eventsFile(taskId, rootDir);
36
111
  if (!existsSync(file)) return [];
37
112
  const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
38
113
  const out = [];
39
- for (const line of lines) {
40
- try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
114
+ let skipped = 0;
115
+ for (let i = 0; i < lines.length; i++) {
116
+ try { out.push(JSON.parse(lines[i])); }
117
+ catch (err) {
118
+ if (strict) {
119
+ out.push({ _malformed: true, _line: i + 1, _raw: lines[i], _error: err.message });
120
+ } else {
121
+ skipped++;
122
+ }
123
+ }
124
+ }
125
+ if (!strict && skipped > 0 && !_skipWarnedFiles.has(file)) {
126
+ _skipWarnedFiles.add(file);
127
+ try {
128
+ process.stderr.write(`⚠ events.jsonl: ${skipped} malformed line(s) skipped in ${file}. Run \`dw agent verify ${taskId}\` to inspect.\n`);
129
+ } catch { /* stderr may be closed in piped contexts */ }
41
130
  }
42
131
  return out;
43
132
  }
@@ -1,8 +1,9 @@
1
- import { appendFileSync, existsSync, readFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, readFileSync, mkdirSync, statSync, renameSync, unlinkSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
 
4
4
  const EVENTS_FILE_RELATIVE = '.dw/events-global.jsonl';
5
5
  const MAX_EVENT_BYTES = 4096;
6
+ const CLAIM_ID_CLIP = 64;
6
7
  const ROTATE_BYTES = 500 * 1024; // C-4: 500KB
7
8
  const ROTATE_LINES = 5000; // C-4: 5000 lines
8
9
 
@@ -32,19 +33,70 @@ function rotateIfNeeded(file) {
32
33
  if (existsSync(archive)) {
33
34
  const existing = readFileSync(file, 'utf8');
34
35
  appendFileSync(archive, existing, 'utf8');
35
- try {
36
- const { unlinkSync } = require('node:fs');
37
- unlinkSync(file);
38
- } catch { /* best-effort */ }
36
+ // Issue #14 black-bot C-3 fix: was `const { unlinkSync } = require('node:fs')` in ESM
37
+ // which throws ReferenceError. Use the import at top of file instead.
38
+ try { unlinkSync(file); } catch { /* best-effort */ }
39
39
  } else {
40
40
  renameSync(file, archive);
41
41
  }
42
42
  }
43
43
 
44
+ // Issue #14 Bug 1 — same smart truncation as agent-events.mjs adapted for goal events.
45
+ // Identifying fields differ: goal_id + field instead of claim_id + agent_id.
46
+ const GOAL_IDENTIFY_FIELDS = ['ts', 'event', 'goal_id', 'kr_id', 'field', 'changed_by'];
47
+
48
+ function smartTruncateGoal(enriched, originalBytes) {
49
+ // Step 1: truncate large string-valued fields like `new` (long summaries) or `staged_files` arrays
50
+ const candidate = { ...enriched };
51
+ const truncatedFields = [];
52
+ for (const key of ['old', 'new', 'summary']) {
53
+ if (typeof candidate[key] === 'string' && candidate[key].length > 500) {
54
+ truncatedFields.push(key);
55
+ candidate[key] = candidate[key].slice(0, 500) + '…(truncated)';
56
+ }
57
+ }
58
+ for (const key of ['linked_task_ids', 'staged_files', 'out_of_scope']) {
59
+ if (Array.isArray(candidate[key]) && candidate[key].length > 0) {
60
+ truncatedFields.push(key);
61
+ const count = candidate[key].length;
62
+ candidate[key] = [`(${count} items — truncated for size)`];
63
+ }
64
+ }
65
+ if (truncatedFields.length > 0) {
66
+ candidate._dw_meta = { truncated: true, truncated_fields: truncatedFields, original_size_bytes: originalBytes };
67
+ const afterScope = JSON.stringify(candidate) + '\n';
68
+ if (Buffer.byteLength(afterScope, 'utf8') <= MAX_EVENT_BYTES) return afterScope;
69
+ }
70
+
71
+ // Step 2: hard-cap to identifying fields
72
+ const minimal = {};
73
+ for (const f of GOAL_IDENTIFY_FIELDS) {
74
+ if (enriched[f] !== undefined) minimal[f] = enriched[f];
75
+ }
76
+ minimal._dw_meta = { truncated: true, original_size_bytes: originalBytes };
77
+ let line = JSON.stringify(minimal) + '\n';
78
+ if (Buffer.byteLength(line, 'utf8') <= MAX_EVENT_BYTES) return line;
79
+
80
+ // Step 3: backstop — clip goal_id if pathologically long
81
+ if (typeof minimal.goal_id === 'string' && minimal.goal_id.length > CLAIM_ID_CLIP) {
82
+ minimal.goal_id = minimal.goal_id.slice(0, CLAIM_ID_CLIP) + '…(clipped)';
83
+ }
84
+ minimal._dw_meta = { truncated: true, original_size_bytes: originalBytes, hard_clipped: true };
85
+ return JSON.stringify(minimal) + '\n';
86
+ }
87
+
88
+ /**
89
+ * Append a goal event to .dw/events-global.jsonl.
90
+ *
91
+ * @returns {{ok: boolean, error?: Error}} (issue #14 Bug 3 fix — was bare boolean)
92
+ */
44
93
  export function logGoalEvent(event, rootDir = process.cwd()) {
45
94
  const file = eventsGlobalFile(rootDir);
46
95
  const dir = dirname(file);
47
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
96
+ if (!existsSync(dir)) {
97
+ try { mkdirSync(dir, { recursive: true }); }
98
+ catch (e) { return { ok: false, error: e }; }
99
+ }
48
100
 
49
101
  rotateIfNeeded(file);
50
102
 
@@ -55,25 +107,41 @@ export function logGoalEvent(event, rootDir = process.cwd()) {
55
107
  let line = JSON.stringify(enriched) + '\n';
56
108
  const originalBytes = Buffer.byteLength(line, 'utf8');
57
109
  if (originalBytes > MAX_EVENT_BYTES) {
58
- const trimmed = { ts: enriched.ts, event: enriched.event || 'unknown', _truncated: true, _original_size_bytes: originalBytes };
59
- line = JSON.stringify(trimmed) + '\n';
110
+ line = smartTruncateGoal(enriched, originalBytes);
60
111
  }
61
112
  try {
62
113
  appendFileSync(file, line, 'utf8');
63
- return true;
64
- } catch {
65
- return false;
114
+ return { ok: true };
115
+ } catch (e) {
116
+ return { ok: false, error: e };
66
117
  }
67
118
  }
68
119
 
69
- export function readGoalEvents(rootDir = process.cwd(), { limit = 100, offset = 0 } = {}) {
120
+ // Track files for which we've already warned about silently-skipped lines (black-bot C-2).
121
+ const _skipWarnedGoalFiles = new Set();
122
+
123
+ export function readGoalEvents(rootDir = process.cwd(), { limit = 100, offset = 0, strict = false } = {}) {
70
124
  const file = eventsGlobalFile(rootDir);
71
125
  if (!existsSync(file)) return [];
72
126
  const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
73
127
  const sliced = lines.slice(Math.max(0, lines.length - offset - limit), lines.length - offset);
74
128
  const out = [];
75
- for (const line of sliced) {
76
- try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
129
+ let skipped = 0;
130
+ for (let i = 0; i < sliced.length; i++) {
131
+ try { out.push(JSON.parse(sliced[i])); }
132
+ catch (err) {
133
+ if (strict) {
134
+ out.push({ _malformed: true, _line: i + 1, _raw: sliced[i], _error: err.message });
135
+ } else {
136
+ skipped++;
137
+ }
138
+ }
139
+ }
140
+ if (!strict && skipped > 0 && !_skipWarnedGoalFiles.has(file)) {
141
+ _skipWarnedGoalFiles.add(file);
142
+ try {
143
+ process.stderr.write(`⚠ events-global.jsonl: ${skipped} malformed line(s) skipped in ${file}.\n`);
144
+ } catch { /* stderr may be closed */ }
77
145
  }
78
146
  return out;
79
147
  }