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.
- package/.claude/hooks/pre-commit-gate.sh +17 -19
- package/.dw/core/schemas/agent-claim.schema.json +21 -1
- package/package.json +1 -1
- package/src/cli.mjs +29 -1
- package/src/commands/agent-check-staged.mjs +63 -0
- package/src/commands/agent-claim.mjs +165 -13
- package/src/commands/agent-inspect.mjs +4 -0
- package/src/commands/agent-verify.mjs +75 -0
- package/src/commands/task-watch.mjs +18 -2
- package/src/lib/agent-claim.mjs +47 -0
- package/src/lib/agent-conflict.mjs +168 -21
- package/src/lib/agent-events.mjs +98 -9
- package/src/lib/goal-events.mjs +82 -14
|
@@ -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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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": "
|
|
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
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
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
for (const
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/lib/agent-claim.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
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
|
-
|
|
64
|
-
if (!aHasGlob
|
|
65
|
-
if (
|
|
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
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
for (const
|
|
112
|
-
|
|
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
|
}
|
package/src/lib/agent-events.mjs
CHANGED
|
@@ -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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
}
|
package/src/lib/goal-events.mjs
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
}
|