dw-kit 1.4.0 → 1.7.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/executor.md +80 -80
- package/.claude/hooks/pre-commit-gate.sh +59 -0
- package/.claude/hooks/stop-check.sh +111 -31
- package/.claude/rules/commit-standards.md +48 -37
- package/.claude/rules/dw.md +47 -11
- package/.claude/skills/dw-commit/SKILL.md +7 -4
- package/.claude/skills/dw-decision/SKILL.md +5 -4
- package/.claude/skills/dw-execute/SKILL.md +18 -5
- package/.claude/skills/dw-handoff/SKILL.md +8 -3
- package/.claude/skills/dw-plan/SKILL.md +15 -2
- package/.claude/skills/dw-research/SKILL.md +7 -5
- package/.claude/skills/dw-retroactive/SKILL.md +75 -63
- package/.claude/skills/dw-task-init/SKILL.md +40 -35
- package/.dw/adapters/generic/AGENT.md +171 -169
- package/.dw/core/WORKFLOW.md +450 -450
- package/.dw/core/schemas/agent-claim.schema.json +127 -0
- package/.dw/core/schemas/agent-report.schema.json +72 -0
- package/.dw/core/schemas/goal-frontmatter.schema.json +84 -0
- package/.dw/core/schemas/task-frontmatter.schema.json +97 -0
- package/.dw/core/templates/v3/goal.md +146 -0
- package/.dw/core/templates/v3/task.md +188 -0
- package/CLAUDE.md +2 -2
- package/MIGRATION-v1.5.md +330 -0
- package/README.md +17 -0
- package/package.json +3 -2
- package/src/cli.mjs +312 -0
- package/src/commands/agent-claim.mjs +235 -0
- package/src/commands/agent-inspect.mjs +123 -0
- package/src/commands/doctor.mjs +64 -0
- package/src/commands/goal-bump.mjs +50 -0
- package/src/commands/goal-delete.mjs +120 -0
- package/src/commands/goal-link.mjs +126 -0
- package/src/commands/goal-lint.mjs +152 -0
- package/src/commands/goal-new.mjs +86 -0
- package/src/commands/goal-portfolio.mjs +84 -0
- package/src/commands/goal-render.mjs +49 -0
- package/src/commands/goal-set.mjs +62 -0
- package/src/commands/goal-show.mjs +94 -0
- package/src/commands/goal-stubs.mjs +21 -0
- package/src/commands/goal-suggest-krs.mjs +139 -0
- package/src/commands/goal-summary.mjs +67 -0
- package/src/commands/goal-view.mjs +196 -0
- package/src/commands/lint-task.mjs +112 -0
- package/src/commands/task-migrate.mjs +471 -0
- package/src/commands/task-new.mjs +90 -0
- package/src/commands/task-render.mjs +235 -0
- package/src/commands/task-rotate.mjs +168 -0
- package/src/commands/task-show.mjs +137 -0
- package/src/commands/task-summary.mjs +68 -0
- package/src/commands/task-view.mjs +386 -0
- package/src/commands/task-watch.mjs +868 -0
- package/src/lib/active-index.mjs +19 -1
- package/src/lib/agent-claim.mjs +173 -0
- package/src/lib/agent-conflict.mjs +137 -0
- package/src/lib/agent-events.mjs +43 -0
- package/src/lib/agent-report.mjs +96 -0
- package/src/lib/frontmatter.mjs +72 -0
- package/src/lib/goal-events.mjs +79 -0
- package/src/lib/goal-store.mjs +202 -0
- package/src/lib/goal-svg.mjs +293 -0
- package/src/lib/goal-watch.mjs +133 -0
- package/src/lib/lint-rules.mjs +149 -0
- package/src/lib/sse-broker.mjs +91 -0
- package/src/lib/timeline-parser.mjs +80 -0
- package/src/lib/watch-auth.mjs +64 -0
package/src/lib/active-index.mjs
CHANGED
|
@@ -12,7 +12,13 @@ function parseFrontmatter(content) {
|
|
|
12
12
|
const fm = {};
|
|
13
13
|
for (const line of lines) {
|
|
14
14
|
const kv = line.match(/^(\w+):\s*(.+)$/);
|
|
15
|
-
if (kv)
|
|
15
|
+
if (kv) {
|
|
16
|
+
let value = kv[2].trim();
|
|
17
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
18
|
+
value = value.slice(1, -1);
|
|
19
|
+
}
|
|
20
|
+
fm[kv[1]] = value;
|
|
21
|
+
}
|
|
16
22
|
}
|
|
17
23
|
return fm;
|
|
18
24
|
}
|
|
@@ -21,6 +27,18 @@ function readTaskStatus(taskDir) {
|
|
|
21
27
|
const fullPath = join(TASKS_DIR, taskDir);
|
|
22
28
|
if (!statSync(fullPath).isDirectory()) return null;
|
|
23
29
|
|
|
30
|
+
const timelineV3 = join(fullPath, 'task.md');
|
|
31
|
+
if (existsSync(timelineV3)) {
|
|
32
|
+
const fm = parseFrontmatter(readFileSync(timelineV3, 'utf8'));
|
|
33
|
+
return {
|
|
34
|
+
name: taskDir,
|
|
35
|
+
status: fm.status || 'unknown',
|
|
36
|
+
lastUpdated: fm.last_updated || fm.created || '—',
|
|
37
|
+
blockers: fm.blockers || 'none',
|
|
38
|
+
format: 'v3',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
const trackingV2 = join(fullPath, 'tracking.md');
|
|
25
43
|
if (existsSync(trackingV2)) {
|
|
26
44
|
const fm = parseFrontmatter(readFileSync(trackingV2, 'utf8'));
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, renameSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = dirname(__filename);
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
|
|
10
|
+
const CLAIMS_DIR = '.dw/cache/agents/claims';
|
|
11
|
+
const SCHEMA_PATH = '.dw/core/schemas/agent-claim.schema.json';
|
|
12
|
+
const BUNDLED_SCHEMA = join(__dirname, '..', '..', '.dw', 'core', 'schemas', 'agent-claim.schema.json');
|
|
13
|
+
|
|
14
|
+
export function claimsDir(rootDir = process.cwd()) {
|
|
15
|
+
return join(rootDir, CLAIMS_DIR);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadSchema(rootDir) {
|
|
19
|
+
const local = join(rootDir, SCHEMA_PATH);
|
|
20
|
+
if (existsSync(local)) return JSON.parse(readFileSync(local, 'utf8'));
|
|
21
|
+
if (existsSync(BUNDLED_SCHEMA)) return JSON.parse(readFileSync(BUNDLED_SCHEMA, 'utf8'));
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const validatorCache = new Map();
|
|
26
|
+
function getValidator(rootDir) {
|
|
27
|
+
// Reviewer Warning #1: key by rootDir so programmatic use across multiple repos
|
|
28
|
+
// doesn't lock the first-seen schema permanently.
|
|
29
|
+
if (validatorCache.has(rootDir)) return validatorCache.get(rootDir);
|
|
30
|
+
let Ajv;
|
|
31
|
+
try { Ajv = require('ajv'); } catch { return null; }
|
|
32
|
+
const ctor = Ajv.default || Ajv;
|
|
33
|
+
const ajv = new ctor({ strict: false, allErrors: true });
|
|
34
|
+
const schema = loadSchema(rootDir);
|
|
35
|
+
if (!schema) return null;
|
|
36
|
+
const v = ajv.compile(schema);
|
|
37
|
+
validatorCache.set(rootDir, v);
|
|
38
|
+
return v;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function validateClaim(claim, rootDir = process.cwd()) {
|
|
42
|
+
const validate = getValidator(rootDir);
|
|
43
|
+
if (!validate) return { ok: true, warnings: ['ajv or schema not available — skipped validation'] };
|
|
44
|
+
const valid = validate(claim);
|
|
45
|
+
return {
|
|
46
|
+
ok: valid,
|
|
47
|
+
errors: valid ? [] : (validate.errors || []).map((e) => ({
|
|
48
|
+
path: e.instancePath || '/',
|
|
49
|
+
message: e.message || 'invalid',
|
|
50
|
+
params: e.params,
|
|
51
|
+
})),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function nowIsoUtc() {
|
|
56
|
+
return new Date().toISOString().replace(/\.\d+Z$/, 'Z');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function generateClaimId(taskId, subtasks) {
|
|
60
|
+
const stamp = nowIsoUtc().replace(/[-:]/g, '').replace('T', 'T').replace('Z', 'Z');
|
|
61
|
+
const subSlug = subtasks.map((s) => s.toLowerCase()).join('-') || 'task';
|
|
62
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
63
|
+
return `${taskId}-${subSlug}-${stamp}-${rand}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createClaim({ taskId, agent, subtasks, writeScope = [], readScope = [], leaseSeconds = 3600, worktreePath = null }, rootDir = process.cwd()) {
|
|
67
|
+
const created = nowIsoUtc();
|
|
68
|
+
const expires = new Date(Date.now() + leaseSeconds * 1000).toISOString().replace(/\.\d+Z$/, 'Z');
|
|
69
|
+
const claim = {
|
|
70
|
+
schema_version: 'agent-claim@v1',
|
|
71
|
+
claim_id: generateClaimId(taskId, subtasks),
|
|
72
|
+
task_id: taskId,
|
|
73
|
+
agent,
|
|
74
|
+
subtasks,
|
|
75
|
+
write_scope: writeScope,
|
|
76
|
+
read_scope: readScope,
|
|
77
|
+
lease_expires: expires,
|
|
78
|
+
lease_duration_seconds: leaseSeconds,
|
|
79
|
+
status: 'created',
|
|
80
|
+
created_at: created,
|
|
81
|
+
};
|
|
82
|
+
if (worktreePath) claim.worktree_path = worktreePath;
|
|
83
|
+
return claim;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function persistClaim(claim, rootDir = process.cwd()) {
|
|
87
|
+
const dir = claimsDir(rootDir);
|
|
88
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
89
|
+
const target = join(dir, `${claim.claim_id}.json`);
|
|
90
|
+
const tmp = `${target}.tmp`;
|
|
91
|
+
writeFileSync(tmp, JSON.stringify(claim, null, 2) + '\n', 'utf8');
|
|
92
|
+
renameSync(tmp, target);
|
|
93
|
+
return target;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function loadClaim(claimId, rootDir = process.cwd()) {
|
|
97
|
+
const file = join(claimsDir(rootDir), `${claimId}.json`);
|
|
98
|
+
if (!existsSync(file)) return null;
|
|
99
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); }
|
|
100
|
+
catch { return null; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function listClaims(rootDir = process.cwd(), { taskId, status, includeStale = false } = {}) {
|
|
104
|
+
const dir = claimsDir(rootDir);
|
|
105
|
+
if (!existsSync(dir)) return [];
|
|
106
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
107
|
+
const out = [];
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
for (const f of files) {
|
|
110
|
+
try {
|
|
111
|
+
const c = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
112
|
+
if (taskId && c.task_id !== taskId) continue;
|
|
113
|
+
const effectiveExpiry = computeEffectiveExpiry(c);
|
|
114
|
+
const isExpired = effectiveExpiry < now && c.status !== 'released' && c.status !== 'expired' && c.status !== 'invalidated';
|
|
115
|
+
const liveStatus = isExpired ? 'expired' : c.status;
|
|
116
|
+
if (status && liveStatus !== status) continue;
|
|
117
|
+
if (!includeStale && (c.status === 'released' || c.status === 'invalidated')) continue;
|
|
118
|
+
out.push({ ...c, _live_status: liveStatus, _file: join(dir, f) });
|
|
119
|
+
} catch { /* skip malformed */ }
|
|
120
|
+
}
|
|
121
|
+
out.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function computeEffectiveExpiry(claim) {
|
|
126
|
+
// Both fields must be present + parseable; malformed silently expiring is a footgun
|
|
127
|
+
// (reviewer Warning #2). Return Infinity (= always active) when both are unusable
|
|
128
|
+
// so the claim doesn't disappear from listings without warning; caller surfaces it.
|
|
129
|
+
const wallRaw = Date.parse(claim.lease_expires);
|
|
130
|
+
const createdRaw = Date.parse(claim.created_at);
|
|
131
|
+
const wallClock = Number.isFinite(wallRaw) ? wallRaw : null;
|
|
132
|
+
const created = Number.isFinite(createdRaw) ? createdRaw : null;
|
|
133
|
+
const relative = created != null && Number.isFinite(claim.lease_duration_seconds)
|
|
134
|
+
? created + claim.lease_duration_seconds * 1000
|
|
135
|
+
: null;
|
|
136
|
+
if (wallClock == null && relative == null) {
|
|
137
|
+
return Number.POSITIVE_INFINITY;
|
|
138
|
+
}
|
|
139
|
+
return Math.max(wallClock ?? -Infinity, relative ?? -Infinity);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function transitionClaim(claimId, newStatus, opts = {}, rootDir = process.cwd()) {
|
|
143
|
+
const claim = loadClaim(claimId, rootDir);
|
|
144
|
+
if (!claim) return { ok: false, reason: 'not-found' };
|
|
145
|
+
// Lifecycle per ADR-0009 R2-1: created can also expire (agent crashed before
|
|
146
|
+
// activation; lease elapsed before any edit). Terminal states are immutable.
|
|
147
|
+
const allowed = {
|
|
148
|
+
created: ['active', 'released', 'expired', 'invalidated'],
|
|
149
|
+
active: ['released', 'expired', 'invalidated'],
|
|
150
|
+
released: [],
|
|
151
|
+
expired: [],
|
|
152
|
+
invalidated: [],
|
|
153
|
+
};
|
|
154
|
+
if (!(allowed[claim.status] || []).includes(newStatus)) {
|
|
155
|
+
return { ok: false, reason: `invalid-transition: ${claim.status}→${newStatus}` };
|
|
156
|
+
}
|
|
157
|
+
claim.status = newStatus;
|
|
158
|
+
const ts = nowIsoUtc();
|
|
159
|
+
if (newStatus === 'active' && !claim.activated_at) claim.activated_at = ts;
|
|
160
|
+
if (['released', 'expired', 'invalidated'].includes(newStatus)) {
|
|
161
|
+
claim.released_at = ts;
|
|
162
|
+
if (opts.reason) claim.release_reason = opts.reason;
|
|
163
|
+
}
|
|
164
|
+
persistClaim(claim, rootDir);
|
|
165
|
+
return { ok: true, claim };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function deleteClaim(claimId, rootDir = process.cwd()) {
|
|
169
|
+
const file = join(claimsDir(rootDir), `${claimId}.json`);
|
|
170
|
+
if (!existsSync(file)) return false;
|
|
171
|
+
unlinkSync(file);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { listClaims, loadClaim, computeEffectiveExpiry } from './agent-claim.mjs';
|
|
2
|
+
|
|
3
|
+
// Glob → RegExp following gitignore-style semantics:
|
|
4
|
+
// `**` matches zero-or-more path segments (including empty)
|
|
5
|
+
// `*` matches anything within a segment (no `/`)
|
|
6
|
+
// `?` matches single char (no `/`)
|
|
7
|
+
// Behaviour validated by reviewer Critical #1: src/**/*.js MUST match src/x.js.
|
|
8
|
+
export function globToRegex(glob) {
|
|
9
|
+
let pattern = '';
|
|
10
|
+
for (let i = 0; i < glob.length; i++) {
|
|
11
|
+
const ch = glob[i];
|
|
12
|
+
if (ch === '*') {
|
|
13
|
+
if (glob[i + 1] === '*') {
|
|
14
|
+
// `**` — zero or more path segments. Greedy match across `/`.
|
|
15
|
+
// Variants: `**/`, `/**`, mid-path `/**/`, or bare `**`.
|
|
16
|
+
if (glob[i + 2] === '/') {
|
|
17
|
+
// `**/foo` → match zero-or-more segments-with-trailing-slash, then foo
|
|
18
|
+
pattern += '(?:.*\\/)?';
|
|
19
|
+
i += 2;
|
|
20
|
+
} else if (i > 0 && glob[i - 1] === '/') {
|
|
21
|
+
// `/foo/**` at end → zero or more trailing segments (optionally with leading slash).
|
|
22
|
+
// `/` is not in the escape set (we emit it as literal), so drop 1 char not 2.
|
|
23
|
+
pattern = pattern.slice(0, -1);
|
|
24
|
+
pattern += '(?:\\/.*)?';
|
|
25
|
+
i += 1;
|
|
26
|
+
} else {
|
|
27
|
+
pattern += '.*';
|
|
28
|
+
i += 1;
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
pattern += '[^/]*';
|
|
32
|
+
}
|
|
33
|
+
} else if (ch === '?') {
|
|
34
|
+
pattern += '[^/]';
|
|
35
|
+
} else if ('.+^${}()|[]\\'.indexOf(ch) >= 0) {
|
|
36
|
+
pattern += '\\' + ch;
|
|
37
|
+
} else {
|
|
38
|
+
pattern += ch;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return new RegExp(`^${pattern}$`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pathMatchesScope(path, scope) {
|
|
45
|
+
for (const pattern of scope) {
|
|
46
|
+
if (globToRegex(pattern).test(path)) return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
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
|
|
56
|
+
export function scopesOverlap(a, b) {
|
|
57
|
+
if (a === b) return true;
|
|
58
|
+
const aHasGlob = /[*?]/.test(a);
|
|
59
|
+
const bHasGlob = /[*?]/.test(b);
|
|
60
|
+
const ra = globToRegex(a);
|
|
61
|
+
const rb = globToRegex(b);
|
|
62
|
+
|
|
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);
|
|
66
|
+
|
|
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'));
|
|
73
|
+
}
|
|
74
|
+
for (const s of samples) {
|
|
75
|
+
if (ra.test(s) && rb.test(s)) return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function detectClaimOverlaps(rootDir = process.cwd(), { taskId, includeAllTasks = false } = {}) {
|
|
81
|
+
// Per reviewer Warning 3: write_scope overlaps must be detected globally
|
|
82
|
+
// (cross-task), even when caller scoped to a single task.
|
|
83
|
+
const taskScoped = taskId ? listClaims(rootDir, { taskId }) : listClaims(rootDir);
|
|
84
|
+
const repoWide = includeAllTasks || !taskId ? listClaims(rootDir) : null;
|
|
85
|
+
|
|
86
|
+
const active = (taskScoped).filter((c) => {
|
|
87
|
+
const ls = c._live_status;
|
|
88
|
+
return ls === 'created' || ls === 'active';
|
|
89
|
+
});
|
|
90
|
+
const activeRepoWide = repoWide
|
|
91
|
+
? repoWide.filter((c) => c._live_status === 'created' || c._live_status === 'active')
|
|
92
|
+
: active;
|
|
93
|
+
|
|
94
|
+
const conflicts = [];
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (let i = 0; i < active.length; i++) {
|
|
97
|
+
for (let j = 0; j < activeRepoWide.length; j++) {
|
|
98
|
+
const a = active[i];
|
|
99
|
+
const b = activeRepoWide[j];
|
|
100
|
+
if (a.claim_id === b.claim_id) continue;
|
|
101
|
+
const key = [a.claim_id, b.claim_id].sort().join('::');
|
|
102
|
+
if (seen.has(key)) continue;
|
|
103
|
+
seen.add(key);
|
|
104
|
+
|
|
105
|
+
// Subtask overlap only meaningful within same task
|
|
106
|
+
const subtaskOverlap = a.task_id === b.task_id
|
|
107
|
+
? a.subtasks.filter((s) => b.subtasks.includes(s))
|
|
108
|
+
: [];
|
|
109
|
+
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 });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (subtaskOverlap.length || writeOverlap.length) {
|
|
117
|
+
conflicts.push({
|
|
118
|
+
claim_a: a.claim_id,
|
|
119
|
+
agent_a: a.agent.id,
|
|
120
|
+
claim_b: b.claim_id,
|
|
121
|
+
agent_b: b.agent.id,
|
|
122
|
+
task_id: a.task_id === b.task_id ? a.task_id : `${a.task_id},${b.task_id}`,
|
|
123
|
+
subtask_overlap: subtaskOverlap,
|
|
124
|
+
write_overlap: writeOverlap,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return conflicts;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function detectOutOfScopeEdits(claimId, editedFiles, rootDir = process.cwd()) {
|
|
133
|
+
const claim = loadClaim(claimId, rootDir);
|
|
134
|
+
if (!claim) return { ok: false, reason: 'claim-not-found' };
|
|
135
|
+
const outside = editedFiles.filter((f) => !pathMatchesScope(f, claim.write_scope));
|
|
136
|
+
return { ok: true, outside, total_edits: editedFiles.length };
|
|
137
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const TASKS_DIR = '.dw/tasks';
|
|
5
|
+
const MAX_EVENT_BYTES = 4096;
|
|
6
|
+
|
|
7
|
+
export function eventsFile(taskId, rootDir = process.cwd()) {
|
|
8
|
+
return join(rootDir, TASKS_DIR, taskId, 'events.jsonl');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function logAgentEvent(taskId, event, rootDir = process.cwd()) {
|
|
12
|
+
const file = eventsFile(taskId, rootDir);
|
|
13
|
+
const dir = dirname(file);
|
|
14
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
15
|
+
|
|
16
|
+
const enriched = {
|
|
17
|
+
ts: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
|
|
18
|
+
...event,
|
|
19
|
+
};
|
|
20
|
+
let line = JSON.stringify(enriched) + '\n';
|
|
21
|
+
const originalBytes = Buffer.byteLength(line, 'utf8');
|
|
22
|
+
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';
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
appendFileSync(file, line, 'utf8');
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readEvents(taskId, rootDir = process.cwd()) {
|
|
35
|
+
const file = eventsFile(taskId, rootDir);
|
|
36
|
+
if (!existsSync(file)) return [];
|
|
37
|
+
const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
const TASKS_DIR = '.dw/tasks';
|
|
12
|
+
const SCHEMA_PATH = '.dw/core/schemas/agent-report.schema.json';
|
|
13
|
+
const BUNDLED_SCHEMA = join(__dirname, '..', '..', '.dw', 'core', 'schemas', 'agent-report.schema.json');
|
|
14
|
+
|
|
15
|
+
export function reportsDir(taskId, rootDir = process.cwd()) {
|
|
16
|
+
return join(rootDir, TASKS_DIR, taskId, 'reports');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function loadSchema(rootDir) {
|
|
20
|
+
const local = join(rootDir, SCHEMA_PATH);
|
|
21
|
+
if (existsSync(local)) return JSON.parse(readFileSync(local, 'utf8'));
|
|
22
|
+
if (existsSync(BUNDLED_SCHEMA)) return JSON.parse(readFileSync(BUNDLED_SCHEMA, 'utf8'));
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const validatorCache = new Map();
|
|
27
|
+
function getValidator(rootDir) {
|
|
28
|
+
if (validatorCache.has(rootDir)) return validatorCache.get(rootDir);
|
|
29
|
+
let Ajv;
|
|
30
|
+
try { Ajv = require('ajv'); } catch { return null; }
|
|
31
|
+
const ctor = Ajv.default || Ajv;
|
|
32
|
+
const ajv = new ctor({ strict: false, allErrors: true });
|
|
33
|
+
const schema = loadSchema(rootDir);
|
|
34
|
+
if (!schema) return null;
|
|
35
|
+
const v = ajv.compile(schema);
|
|
36
|
+
validatorCache.set(rootDir, v);
|
|
37
|
+
return v;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseReport(filePath) {
|
|
41
|
+
if (!existsSync(filePath)) return null;
|
|
42
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
43
|
+
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
44
|
+
if (!fmMatch) return { frontmatter: {}, body: raw, _file: filePath };
|
|
45
|
+
let fm = {};
|
|
46
|
+
try { fm = yaml.load(fmMatch[1], { schema: yaml.CORE_SCHEMA }) || {}; }
|
|
47
|
+
catch { /* malformed yaml */ }
|
|
48
|
+
return { frontmatter: fm, body: raw.slice(fmMatch[0].length), _file: filePath };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function validateReportFrontmatter(fm, rootDir = process.cwd()) {
|
|
52
|
+
const validate = getValidator(rootDir);
|
|
53
|
+
if (!validate) return { ok: true, warnings: ['ajv or schema not available'] };
|
|
54
|
+
const valid = validate(fm);
|
|
55
|
+
return {
|
|
56
|
+
ok: valid,
|
|
57
|
+
errors: valid ? [] : (validate.errors || []).map((e) => ({ path: e.instancePath || '/', message: e.message })),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function listReports(rootDir = process.cwd(), { taskId, agentId } = {}) {
|
|
62
|
+
const tasksRoot = join(rootDir, TASKS_DIR);
|
|
63
|
+
if (!existsSync(tasksRoot)) return [];
|
|
64
|
+
const taskFolders = taskId
|
|
65
|
+
? [taskId]
|
|
66
|
+
: readdirSync(tasksRoot).filter((e) => {
|
|
67
|
+
try { return statSync(join(tasksRoot, e)).isDirectory() && e !== 'archive'; }
|
|
68
|
+
catch { return false; }
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const t of taskFolders) {
|
|
73
|
+
const dir = reportsDir(t, rootDir);
|
|
74
|
+
if (!existsSync(dir)) continue;
|
|
75
|
+
for (const f of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
|
|
76
|
+
const parsed = parseReport(join(dir, f));
|
|
77
|
+
if (!parsed) continue;
|
|
78
|
+
if (agentId && parsed.frontmatter.agent_id !== agentId) continue;
|
|
79
|
+
out.push({ ...parsed.frontmatter, _file: parsed._file, _task: t });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
out.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function writeReport(taskId, frontmatter, body, rootDir = process.cwd()) {
|
|
87
|
+
const dir = reportsDir(taskId, rootDir);
|
|
88
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
89
|
+
const ts = (frontmatter.created_at || new Date().toISOString().replace(/\.\d+Z$/, 'Z')).replace(/[-:]/g, '');
|
|
90
|
+
const filename = `${frontmatter.agent_id || 'agent'}-${ts}.md`;
|
|
91
|
+
const target = join(dir, filename);
|
|
92
|
+
const fmYaml = yaml.dump(frontmatter, { schema: yaml.CORE_SCHEMA, lineWidth: 100 });
|
|
93
|
+
const content = `---\n${fmYaml}---\n\n${body || ''}`;
|
|
94
|
+
writeFileSync(target, content, 'utf8');
|
|
95
|
+
return target;
|
|
96
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
const SCHEMA_PATH = join(__dirname, '..', '..', '.dw', 'core', 'schemas', 'task-frontmatter.schema.json');
|
|
12
|
+
|
|
13
|
+
export const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
|
|
14
|
+
|
|
15
|
+
export function extractFrontmatterBlock(content) {
|
|
16
|
+
const m = content.match(FRONTMATTER_RE);
|
|
17
|
+
if (!m) return { raw: '', body: content, hasFrontmatter: false };
|
|
18
|
+
return {
|
|
19
|
+
raw: m[1],
|
|
20
|
+
body: content.slice(m[0].length),
|
|
21
|
+
hasFrontmatter: true,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseFrontmatter(content) {
|
|
26
|
+
const { raw, hasFrontmatter } = extractFrontmatterBlock(content);
|
|
27
|
+
if (!hasFrontmatter) return {};
|
|
28
|
+
try {
|
|
29
|
+
return yaml.load(raw, { schema: yaml.CORE_SCHEMA }) || {};
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function stringifyFrontmatter(obj) {
|
|
36
|
+
const body = yaml.dump(obj, {
|
|
37
|
+
schema: yaml.CORE_SCHEMA,
|
|
38
|
+
lineWidth: 100,
|
|
39
|
+
noRefs: true,
|
|
40
|
+
quotingType: '"',
|
|
41
|
+
forceQuotes: false,
|
|
42
|
+
});
|
|
43
|
+
return `---\n${body}---\n`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function loadSchema(rootDir = process.cwd()) {
|
|
47
|
+
const local = join(rootDir, '.dw', 'core', 'schemas', 'task-frontmatter.schema.json');
|
|
48
|
+
if (existsSync(local)) return JSON.parse(readFileSync(local, 'utf8'));
|
|
49
|
+
if (existsSync(SCHEMA_PATH)) return JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateFrontmatter(fm, schema) {
|
|
54
|
+
let Ajv;
|
|
55
|
+
try {
|
|
56
|
+
Ajv = require('ajv');
|
|
57
|
+
} catch {
|
|
58
|
+
return { ok: true, warnings: ['ajv not available — skipping schema validation'] };
|
|
59
|
+
}
|
|
60
|
+
const ajv = new Ajv.default ? new Ajv.default({ strict: false, allErrors: true }) : new Ajv({ strict: false, allErrors: true });
|
|
61
|
+
const validate = ajv.compile(schema);
|
|
62
|
+
const valid = validate(fm);
|
|
63
|
+
return {
|
|
64
|
+
ok: valid,
|
|
65
|
+
errors: valid ? [] : (validate.errors || []).map((e) => ({
|
|
66
|
+
path: e.instancePath || '/',
|
|
67
|
+
message: e.message || 'invalid',
|
|
68
|
+
keyword: e.keyword,
|
|
69
|
+
params: e.params,
|
|
70
|
+
})),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const EVENTS_FILE_RELATIVE = '.dw/events-global.jsonl';
|
|
5
|
+
const MAX_EVENT_BYTES = 4096;
|
|
6
|
+
const ROTATE_BYTES = 500 * 1024; // C-4: 500KB
|
|
7
|
+
const ROTATE_LINES = 5000; // C-4: 5000 lines
|
|
8
|
+
|
|
9
|
+
export function eventsGlobalFile(rootDir = process.cwd()) {
|
|
10
|
+
return join(rootDir, EVENTS_FILE_RELATIVE);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function shouldRotate(file) {
|
|
14
|
+
if (!existsSync(file)) return false;
|
|
15
|
+
const st = statSync(file);
|
|
16
|
+
if (st.size >= ROTATE_BYTES) return true;
|
|
17
|
+
let lines = 0;
|
|
18
|
+
const content = readFileSync(file, 'utf8');
|
|
19
|
+
for (let i = 0; i < content.length; i++) {
|
|
20
|
+
if (content.charCodeAt(i) === 10) lines++;
|
|
21
|
+
if (lines >= ROTATE_LINES) return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rotateIfNeeded(file) {
|
|
27
|
+
if (!shouldRotate(file)) return;
|
|
28
|
+
const now = new Date();
|
|
29
|
+
const yyyy = now.getUTCFullYear();
|
|
30
|
+
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
31
|
+
const archive = file.replace(/events-global\.jsonl$/, `events-global-${yyyy}${mm}.jsonl`);
|
|
32
|
+
if (existsSync(archive)) {
|
|
33
|
+
const existing = readFileSync(file, 'utf8');
|
|
34
|
+
appendFileSync(archive, existing, 'utf8');
|
|
35
|
+
try {
|
|
36
|
+
const { unlinkSync } = require('node:fs');
|
|
37
|
+
unlinkSync(file);
|
|
38
|
+
} catch { /* best-effort */ }
|
|
39
|
+
} else {
|
|
40
|
+
renameSync(file, archive);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function logGoalEvent(event, rootDir = process.cwd()) {
|
|
45
|
+
const file = eventsGlobalFile(rootDir);
|
|
46
|
+
const dir = dirname(file);
|
|
47
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
rotateIfNeeded(file);
|
|
50
|
+
|
|
51
|
+
const enriched = {
|
|
52
|
+
ts: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
|
|
53
|
+
...event,
|
|
54
|
+
};
|
|
55
|
+
let line = JSON.stringify(enriched) + '\n';
|
|
56
|
+
const originalBytes = Buffer.byteLength(line, 'utf8');
|
|
57
|
+
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';
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
appendFileSync(file, line, 'utf8');
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function readGoalEvents(rootDir = process.cwd(), { limit = 100, offset = 0 } = {}) {
|
|
70
|
+
const file = eventsGlobalFile(rootDir);
|
|
71
|
+
if (!existsSync(file)) return [];
|
|
72
|
+
const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
73
|
+
const sliced = lines.slice(Math.max(0, lines.length - offset - limit), lines.length - offset);
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const line of sliced) {
|
|
76
|
+
try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|