dw-kit 1.9.2 → 1.10.0
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/rules/dw.md +14 -3
- package/.claude/skills/dw-flow/SKILL.md +35 -2
- package/.claude/skills/dw-goal/SKILL.md +22 -1
- package/.dw/config/config.schema.json +24 -0
- package/.dw/config/dw.config.yml +14 -0
- package/.dw/core/HARNESS.md +157 -0
- package/.dw/core/schemas/events/gate_auto_approved.schema.json +36 -0
- package/.dw/core/schemas/events/hard_stop.schema.json +35 -0
- package/.dw/core/schemas/events/index.json +28 -2
- package/.dw/core/schemas/events/overnight_wrapup.schema.json +29 -0
- package/.dw/core/schemas/events/parked.schema.json +39 -0
- package/.dw/core/schemas/events/subtask_completed.schema.json +35 -0
- package/.dw/core/schemas/overnight-digest.schema.json +113 -0
- package/.dw/core/templates/autopilot-cron.sh +28 -0
- package/README.md +152 -121
- package/package.json +4 -1
- package/src/cli.mjs +63 -0
- package/src/commands/autopilot.mjs +65 -0
- package/src/commands/doctor.mjs +17 -1
- package/src/commands/overnight.mjs +35 -0
- package/src/commands/trust.mjs +79 -0
- package/src/commands/voice.mjs +431 -2
- package/src/lib/autopilot-contract.mjs +97 -0
- package/src/lib/board-data.mjs +23 -57
- package/src/lib/config.mjs +56 -0
- package/src/lib/event-schema.mjs +28 -0
- package/src/lib/goal-driver.mjs +312 -0
- package/src/lib/goal-events.mjs +4 -1
- package/src/lib/goal-progress.mjs +193 -0
- package/src/lib/overnight-digest.mjs +241 -0
- package/src/lib/process-kill.mjs +77 -0
- package/src/lib/task-md-utils.mjs +78 -0
- package/src/lib/tls-helpers.mjs +28 -6
- package/src/lib/trust.mjs +139 -0
package/src/lib/board-data.mjs
CHANGED
|
@@ -25,8 +25,10 @@
|
|
|
25
25
|
|
|
26
26
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
27
27
|
import { join, basename } from 'node:path';
|
|
28
|
-
import yaml from 'js-yaml';
|
|
29
28
|
import { listSessions } from './session-store.mjs';
|
|
29
|
+
import { readClaim } from './goal-driver.mjs';
|
|
30
|
+
import { computeGoalProgress } from './goal-progress.mjs';
|
|
31
|
+
import { parseSubtasks, readFrontmatter, listTaskDirs } from './task-md-utils.mjs';
|
|
30
32
|
|
|
31
33
|
const GOALS_INDEX_PATH = '.dw/goals/goals-index.json';
|
|
32
34
|
const GOALS_DIR = '.dw/goals';
|
|
@@ -42,62 +44,9 @@ function safeReadGoalsIndex(rootDir) {
|
|
|
42
44
|
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { goals: {} }; }
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
if (!existsSync(file)) return {};
|
|
49
|
-
let txt;
|
|
50
|
-
try { txt = readFileSync(file, 'utf8'); } catch { return {}; }
|
|
51
|
-
const m = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
52
|
-
if (!m) return {};
|
|
53
|
-
try { return yaml.load(m[1]) || {}; } catch { return {}; }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Section 3 Subtask Tracker parser. Matches the `| ST-N | title | status | date | notes |`
|
|
57
|
-
// row pattern used across the repo. Returns an array of subtask rows.
|
|
58
|
-
const STATUS_ICONS = {
|
|
59
|
-
'⬜': 'pending',
|
|
60
|
-
'🟡': 'in_progress',
|
|
61
|
-
'✅': 'done',
|
|
62
|
-
'🔴': 'blocked',
|
|
63
|
-
'⏸': 'paused',
|
|
64
|
-
};
|
|
65
|
-
function parseSubtasks(taskPath) {
|
|
66
|
-
if (!existsSync(taskPath)) return [];
|
|
67
|
-
let txt;
|
|
68
|
-
try { txt = readFileSync(taskPath, 'utf8'); } catch { return []; }
|
|
69
|
-
const sec = txt.match(/^## 3\.[^\n]*\n([\s\S]*?)(?=^## 4\.|$(?![\s\S]))/m);
|
|
70
|
-
if (!sec) return [];
|
|
71
|
-
const out = [];
|
|
72
|
-
for (const line of sec[1].split('\n')) {
|
|
73
|
-
// | ST-1 | "Subtask title" | ✅ Done | 2026-05-25 | notes |
|
|
74
|
-
const m = line.match(/^\|\s*(ST-[\w.-]+)\s*\|\s*(.+?)\s*\|\s*([⬜🟡✅🔴⏸])\s*([A-Za-z ]+)?\s*\|\s*([^|]*)\|\s*([^|]*)\|/);
|
|
75
|
-
if (!m) continue;
|
|
76
|
-
const icon = m[3];
|
|
77
|
-
const statusBucket = STATUS_ICONS[icon] || 'unknown';
|
|
78
|
-
const statusLabel = (m[4] || '').trim();
|
|
79
|
-
out.push({
|
|
80
|
-
st_id: m[1].trim(),
|
|
81
|
-
title: m[2].replace(/`/g, '').trim(),
|
|
82
|
-
status_bucket: statusBucket,
|
|
83
|
-
status_icon: icon,
|
|
84
|
-
status_label: statusLabel,
|
|
85
|
-
date: m[5].trim(),
|
|
86
|
-
notes: m[6].trim().slice(0, 160),
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
return out;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function listTaskDirs(rootDir) {
|
|
93
|
-
const dir = join(rootDir, TASKS_DIR);
|
|
94
|
-
if (!existsSync(dir)) return [];
|
|
95
|
-
return readdirSync(dir)
|
|
96
|
-
.filter((entry) => {
|
|
97
|
-
if (entry.startsWith('.') || entry === 'archive') return false;
|
|
98
|
-
try { return statSync(join(dir, entry)).isDirectory(); } catch { return false; }
|
|
99
|
-
});
|
|
100
|
-
}
|
|
47
|
+
// Parsers (parseSubtasks / readFrontmatter / listTaskDirs) live in
|
|
48
|
+
// task-md-utils.mjs so goal-progress.mjs can share them without forming a
|
|
49
|
+
// circular import via board-data.mjs.
|
|
101
50
|
|
|
102
51
|
// ─ Public API ──────────────────────────────────────────────────────────────
|
|
103
52
|
|
|
@@ -145,6 +94,10 @@ export function buildBoardData(rootDir, opts = {}) {
|
|
|
145
94
|
for (const [goal_id, g] of goalsIndexEntries) {
|
|
146
95
|
if (g.archived_at) continue; // hide archived
|
|
147
96
|
const tasks = (byGoal.get(goal_id) || []).slice(0, MAX_TASKS_PER_GOAL);
|
|
97
|
+
// goal-driver-mvp ST-6: attach live claim state (or null) so the board
|
|
98
|
+
// UI can render "Running…" disabled state on the Start button without
|
|
99
|
+
// a second roundtrip. readClaim auto-expires stale entries.
|
|
100
|
+
const claim = readClaim(goal_id, rootDir);
|
|
148
101
|
goals.push({
|
|
149
102
|
goal_id,
|
|
150
103
|
title: g.title || goal_id,
|
|
@@ -157,6 +110,18 @@ export function buildBoardData(rootDir, opts = {}) {
|
|
|
157
110
|
last_updated: g.last_updated || null,
|
|
158
111
|
linked_task_ids: g.linked_task_ids || [],
|
|
159
112
|
tasks,
|
|
113
|
+
claim: claim ? {
|
|
114
|
+
claim_id: claim.claim_id,
|
|
115
|
+
session_id: claim.session_id || null,
|
|
116
|
+
claimed_at: claim.claimed_at,
|
|
117
|
+
expires_at: claim.expires_at,
|
|
118
|
+
max_iterations: claim.max_iterations,
|
|
119
|
+
max_minutes: claim.max_minutes,
|
|
120
|
+
// ST-7 (goal-driver-mvp v2): live progress derived from the linked
|
|
121
|
+
// task.md Section 3 + last goal_driver_* event timestamp. null when
|
|
122
|
+
// no linked task is wired up.
|
|
123
|
+
progress: claim ? computeGoalProgress(goal_id, rootDir) : null,
|
|
124
|
+
} : null,
|
|
160
125
|
});
|
|
161
126
|
}
|
|
162
127
|
|
|
@@ -197,6 +162,7 @@ export function buildBoardData(rootDir, opts = {}) {
|
|
|
197
162
|
subtasks_blocked: 0,
|
|
198
163
|
sessions_running: sessions.filter((s) => s.status === 'running').length,
|
|
199
164
|
sessions_total: sessions.length,
|
|
165
|
+
goals_driving: goals.filter((g) => g.claim).length,
|
|
200
166
|
};
|
|
201
167
|
for (const t of allTasks) {
|
|
202
168
|
for (const st of t.subtasks) {
|
package/src/lib/config.mjs
CHANGED
|
@@ -61,6 +61,15 @@ export function buildConfig({ projectName, language, depth, roles }) {
|
|
|
61
61
|
},
|
|
62
62
|
workflow: {
|
|
63
63
|
default_depth: depth,
|
|
64
|
+
// Trust Mode (ADR-0022) — opt-in gate auto-approval. Off by default;
|
|
65
|
+
// present so the shape is discoverable. Guards + Critical findings are
|
|
66
|
+
// never relaxed regardless of these flags.
|
|
67
|
+
trust: {
|
|
68
|
+
enabled: false,
|
|
69
|
+
auto_approve: { scope: false, plan: false, changes: false },
|
|
70
|
+
max_auto_subtasks: 0,
|
|
71
|
+
require_evidence: false,
|
|
72
|
+
},
|
|
64
73
|
},
|
|
65
74
|
team: {
|
|
66
75
|
roles: roles,
|
|
@@ -103,6 +112,53 @@ export function getToolkitVersions(config) {
|
|
|
103
112
|
};
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
// Named trust profiles (ADR-0023 follow-up) — a profile is a bundle of defaults
|
|
116
|
+
// answering "what does this run's trust posture look like?" Explicit keys still
|
|
117
|
+
// override the profile, so it's purely a convenience layer.
|
|
118
|
+
export const TRUST_PROFILES = {
|
|
119
|
+
// Vibe-coder overnight default: trust all convenience gates, bounded, and
|
|
120
|
+
// require evidence on GATE D so the unattended changes-approval is backed by
|
|
121
|
+
// a real test/review basis (safe path = easy path for the overnight audience).
|
|
122
|
+
'solo-night': { enabled: true, auto_approve: { scope: true, plan: true, changes: true }, max_auto_subtasks: 8, require_evidence: true },
|
|
123
|
+
// Team velocity without waiving diff review: plan auto, every diff still human.
|
|
124
|
+
'reviewed-team': { enabled: true, auto_approve: { scope: true, plan: true, changes: false }, max_auto_subtasks: 4 },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Trust Mode config for `dw:flow` gate auto-approval (ADR-0022).
|
|
129
|
+
* Off by default and back-compat: absent keys → fully manual gates, i.e.
|
|
130
|
+
* byte-for-byte today's behaviour. A `profile` supplies defaults that explicit
|
|
131
|
+
* keys override. Falls back to safe defaults so older config keeps working.
|
|
132
|
+
*/
|
|
133
|
+
export function getTrustConfig(config) {
|
|
134
|
+
const t = config?.workflow?.trust || {};
|
|
135
|
+
const profile = t.profile && TRUST_PROFILES[t.profile] ? t.profile : null;
|
|
136
|
+
const base = profile ? TRUST_PROFILES[profile] : { enabled: false, auto_approve: {}, max_auto_subtasks: 0 };
|
|
137
|
+
const a = t.auto_approve || {};
|
|
138
|
+
const baseA = base.auto_approve || {};
|
|
139
|
+
// explicit `enabled` wins; else the profile's; else off.
|
|
140
|
+
const enabled = t.enabled !== undefined ? t.enabled === true : base.enabled === true;
|
|
141
|
+
// explicit gate key wins; else the profile's; else off. Honoured only when enabled.
|
|
142
|
+
const gate = (key) => enabled && (a[key] !== undefined ? a[key] === true : baseA[key] === true);
|
|
143
|
+
return {
|
|
144
|
+
enabled,
|
|
145
|
+
profile,
|
|
146
|
+
auto_approve: {
|
|
147
|
+
scope: gate('scope'), // GATE A / Q1
|
|
148
|
+
plan: gate('plan'), // GATE C
|
|
149
|
+
changes: gate('changes'), // GATE D (never when Critical)
|
|
150
|
+
},
|
|
151
|
+
// 0 = unbounded once enabled; >0 caps an unattended run's length.
|
|
152
|
+
max_auto_subtasks: Number.isInteger(t.max_auto_subtasks) && t.max_auto_subtasks > 0
|
|
153
|
+
? t.max_auto_subtasks
|
|
154
|
+
: (base.max_auto_subtasks || 0),
|
|
155
|
+
// When true, GATE D refuses to auto-approve without an --evidence object —
|
|
156
|
+
// makes the safe (evidence-backed) outcome the default for `changes`.
|
|
157
|
+
// Explicit key wins; else the profile's default; else off.
|
|
158
|
+
require_evidence: t.require_evidence !== undefined ? t.require_evidence === true : base.require_evidence === true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
106
162
|
/**
|
|
107
163
|
* Renderer config for /dw:review --visual + `dw review render` (ADR-0007).
|
|
108
164
|
* Falls back to defaults when keys are absent so existing projects work
|
package/src/lib/event-schema.mjs
CHANGED
|
@@ -205,6 +205,34 @@ export const EVENT_CATALOGUE = {
|
|
|
205
205
|
required: { goal_id: 'string', task_id: 'string' },
|
|
206
206
|
optional: {},
|
|
207
207
|
},
|
|
208
|
+
|
|
209
|
+
// §5.6 Trust Mode & Overnight Autopilot (ADR-0022/0023) — written to
|
|
210
|
+
// events-global.jsonl; consumed by the morning digest (overnight-digest@v1).
|
|
211
|
+
gate_auto_approved: {
|
|
212
|
+
category: 'trust-autopilot',
|
|
213
|
+
required: { gate: 'string', actor: 'string' },
|
|
214
|
+
optional: { task: 'any', reason: 'any', evidence: 'any' },
|
|
215
|
+
},
|
|
216
|
+
subtask_completed: {
|
|
217
|
+
category: 'trust-autopilot',
|
|
218
|
+
required: { subtask: 'string' },
|
|
219
|
+
optional: { commit: 'string', summary: 'string' },
|
|
220
|
+
},
|
|
221
|
+
parked: {
|
|
222
|
+
category: 'trust-autopilot',
|
|
223
|
+
required: { item: 'string', question: 'string' },
|
|
224
|
+
optional: { options: 'string[]' },
|
|
225
|
+
},
|
|
226
|
+
hard_stop: {
|
|
227
|
+
category: 'trust-autopilot',
|
|
228
|
+
required: { reason: 'string' },
|
|
229
|
+
optional: { gate: 'string', blocker: 'string' },
|
|
230
|
+
},
|
|
231
|
+
overnight_wrapup: {
|
|
232
|
+
category: 'trust-autopilot',
|
|
233
|
+
required: { stop_reason: 'string' },
|
|
234
|
+
optional: {},
|
|
235
|
+
},
|
|
208
236
|
};
|
|
209
237
|
|
|
210
238
|
export const KNOWN_EVENTS = Object.freeze(Object.keys(EVENT_CATALOGUE));
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// goal-driver.mjs — per-goal "Start" primitive for the /board Kanban page.
|
|
2
|
+
//
|
|
3
|
+
// Three responsibilities (pure, no spawn — that lives in voice-action.mjs):
|
|
4
|
+
//
|
|
5
|
+
// 1. Claim file (atomic file lock) so two Start clicks on the same goal
|
|
6
|
+
// can't race spawning two driver sessions.
|
|
7
|
+
// 2. Meta-prompt builder that wraps the existing F-43 workflow into a
|
|
8
|
+
// bounded autonomous loop the spawned claude --print can iterate over.
|
|
9
|
+
// 3. Budget parser with defaults + clamps, so callers can't request a
|
|
10
|
+
// runaway 12-hour driver.
|
|
11
|
+
//
|
|
12
|
+
// Per ADR-0001: zero-dep. Per memory `feedback_build_over_depend`: no new
|
|
13
|
+
// 3rd-party deps.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
const CLAIM_DIR_RELATIVE = '.dw/cache/goal-claims';
|
|
22
|
+
|
|
23
|
+
const BUDGET_DEFAULT_ITERATIONS = 8;
|
|
24
|
+
const BUDGET_DEFAULT_MINUTES = 25;
|
|
25
|
+
const BUDGET_MAX_ITERATIONS = 20;
|
|
26
|
+
const BUDGET_MAX_MINUTES = 60;
|
|
27
|
+
const BUDGET_MIN_ITERATIONS = 1;
|
|
28
|
+
const BUDGET_MIN_MINUTES = 1;
|
|
29
|
+
|
|
30
|
+
const GOAL_ID_PATTERN = /^G-[A-Za-z0-9_-]{1,64}$/;
|
|
31
|
+
|
|
32
|
+
// ─ Paths ───────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function claimDir(rootDir) {
|
|
35
|
+
return join(rootDir, CLAIM_DIR_RELATIVE);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function claimPath(goalId, rootDir) {
|
|
39
|
+
return join(claimDir(rootDir), `${goalId}.json`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─ Budget ──────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export function parseBudget(opts = {}) {
|
|
45
|
+
const rawIter = Number(opts.max_iterations ?? opts.maxIterations);
|
|
46
|
+
const rawMin = Number(opts.max_minutes ?? opts.maxMinutes);
|
|
47
|
+
const maxIterations = Number.isFinite(rawIter) && rawIter > 0
|
|
48
|
+
? Math.max(BUDGET_MIN_ITERATIONS, Math.min(BUDGET_MAX_ITERATIONS, Math.floor(rawIter)))
|
|
49
|
+
: BUDGET_DEFAULT_ITERATIONS;
|
|
50
|
+
const maxMinutes = Number.isFinite(rawMin) && rawMin > 0
|
|
51
|
+
? Math.max(BUDGET_MIN_MINUTES, Math.min(BUDGET_MAX_MINUTES, Math.floor(rawMin)))
|
|
52
|
+
: BUDGET_DEFAULT_MINUTES;
|
|
53
|
+
return { maxIterations, maxMinutes };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─ Claim primitive ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function ensureClaimDir(rootDir) {
|
|
59
|
+
const dir = claimDir(rootDir);
|
|
60
|
+
if (!existsSync(dir)) {
|
|
61
|
+
mkdirSync(dir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function nowIso() {
|
|
66
|
+
return new Date().toISOString().replace(/\.\d+Z$/, 'Z');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function newClaimId() {
|
|
70
|
+
return `gc-${randomBytes(6).toString('hex')}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read a claim, auto-expire if `expires_at < now`.
|
|
75
|
+
* Returns the claim object, or null when absent / unreadable / expired.
|
|
76
|
+
*/
|
|
77
|
+
export function readClaim(goalId, rootDir = process.cwd()) {
|
|
78
|
+
if (!GOAL_ID_PATTERN.test(goalId)) return null;
|
|
79
|
+
const p = claimPath(goalId, rootDir);
|
|
80
|
+
if (!existsSync(p)) return null;
|
|
81
|
+
let claim;
|
|
82
|
+
try { claim = JSON.parse(readFileSync(p, 'utf8')); }
|
|
83
|
+
catch { return null; }
|
|
84
|
+
if (typeof claim !== 'object' || claim === null) return null;
|
|
85
|
+
if (typeof claim.expires_at === 'string') {
|
|
86
|
+
const expMs = Date.parse(claim.expires_at);
|
|
87
|
+
if (Number.isFinite(expMs) && expMs <= Date.now()) {
|
|
88
|
+
try { rmSync(p, { force: true }); } catch { /* best-effort */ }
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return claim;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Atomically claim a goal. Uses `writeFileSync(..., { flag: 'wx' })` so two
|
|
97
|
+
* concurrent callers can't both succeed; the loser gets EEXIST and returns
|
|
98
|
+
* `already_claimed`.
|
|
99
|
+
*
|
|
100
|
+
* If an existing claim is expired (`expires_at < now`), it is replaced.
|
|
101
|
+
*
|
|
102
|
+
* @returns {{ok:true,claim:Object} | {ok:false,reason:string,existing?:Object}}
|
|
103
|
+
*/
|
|
104
|
+
export function claimGoal(goalId, sessionId, opts = {}, rootDir = process.cwd()) {
|
|
105
|
+
if (!GOAL_ID_PATTERN.test(goalId)) {
|
|
106
|
+
return { ok: false, reason: 'invalid_goal_id' };
|
|
107
|
+
}
|
|
108
|
+
ensureClaimDir(rootDir);
|
|
109
|
+
const { maxIterations, maxMinutes } = parseBudget(opts);
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
const claim = {
|
|
112
|
+
schema_version: 'claim@v1',
|
|
113
|
+
claim_id: newClaimId(),
|
|
114
|
+
goal_id: goalId,
|
|
115
|
+
session_id: sessionId || null,
|
|
116
|
+
claimed_at: nowIso(),
|
|
117
|
+
expires_at: new Date(now + maxMinutes * 60_000).toISOString().replace(/\.\d+Z$/, 'Z'),
|
|
118
|
+
max_iterations: maxIterations,
|
|
119
|
+
max_minutes: maxMinutes,
|
|
120
|
+
lang: opts.lang === 'vi' ? 'vi' : 'en',
|
|
121
|
+
};
|
|
122
|
+
const p = claimPath(goalId, rootDir);
|
|
123
|
+
const payload = JSON.stringify(claim, null, 2) + '\n';
|
|
124
|
+
try {
|
|
125
|
+
writeFileSync(p, payload, { flag: 'wx', encoding: 'utf8' });
|
|
126
|
+
return { ok: true, claim };
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err && err.code === 'EEXIST') {
|
|
129
|
+
const existing = readClaim(goalId, rootDir);
|
|
130
|
+
if (!existing) {
|
|
131
|
+
// The existing file was expired (readClaim deleted it) — retry once.
|
|
132
|
+
try {
|
|
133
|
+
writeFileSync(p, payload, { flag: 'wx', encoding: 'utf8' });
|
|
134
|
+
return { ok: true, claim };
|
|
135
|
+
} catch (err2) {
|
|
136
|
+
return { ok: false, reason: 'write_failed', error: err2.message };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { ok: false, reason: 'already_claimed', existing };
|
|
140
|
+
}
|
|
141
|
+
return { ok: false, reason: 'write_failed', error: err.message };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Remove the claim file. Idempotent. */
|
|
146
|
+
export function releaseGoal(goalId, rootDir = process.cwd()) {
|
|
147
|
+
if (!GOAL_ID_PATTERN.test(goalId)) return { ok: false, reason: 'invalid_goal_id' };
|
|
148
|
+
const p = claimPath(goalId, rootDir);
|
|
149
|
+
try { rmSync(p, { force: true }); return { ok: true }; }
|
|
150
|
+
catch (err) { return { ok: false, reason: 'unlink_failed', error: err.message }; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Update the session_id on an existing claim (used after spawn returns the id).
|
|
155
|
+
* Returns { ok, claim } or { ok: false, reason }.
|
|
156
|
+
*/
|
|
157
|
+
export function attachSessionId(goalId, sessionId, rootDir = process.cwd()) {
|
|
158
|
+
const existing = readClaim(goalId, rootDir);
|
|
159
|
+
if (!existing) return { ok: false, reason: 'no_claim' };
|
|
160
|
+
existing.session_id = sessionId;
|
|
161
|
+
const p = claimPath(goalId, rootDir);
|
|
162
|
+
try {
|
|
163
|
+
writeFileSync(p, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf8' });
|
|
164
|
+
return { ok: true, claim: existing };
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return { ok: false, reason: 'write_failed', error: err.message };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** List all current (non-expired) claims. */
|
|
171
|
+
export function listClaims(rootDir = process.cwd()) {
|
|
172
|
+
const dir = claimDir(rootDir);
|
|
173
|
+
if (!existsSync(dir)) return [];
|
|
174
|
+
let entries;
|
|
175
|
+
try { entries = readdirSync(dir); } catch { return []; }
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const name of entries) {
|
|
178
|
+
if (!name.endsWith('.json')) continue;
|
|
179
|
+
const goalId = name.slice(0, -5);
|
|
180
|
+
const claim = readClaim(goalId, rootDir);
|
|
181
|
+
if (claim) out.push(claim);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─ Meta-prompt ─────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function readGoalSummary(goalId, rootDir) {
|
|
189
|
+
const goalFile = join(rootDir, '.dw', 'goals', goalId, 'goal.md');
|
|
190
|
+
if (!existsSync(goalFile)) return null;
|
|
191
|
+
let txt;
|
|
192
|
+
try { txt = readFileSync(goalFile, 'utf8'); } catch { return null; }
|
|
193
|
+
// Extract summary from frontmatter (best-effort, no yaml dep here to keep
|
|
194
|
+
// the file zero-dep — this is just for display in the prompt).
|
|
195
|
+
const fm = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
196
|
+
if (!fm) return null;
|
|
197
|
+
const sumMatch = fm[1].match(/^summary:\s*>-?\s*\n([\s\S]*?)(?=^\w+:|^---)/m);
|
|
198
|
+
if (sumMatch) return sumMatch[1].split('\n').map((l) => l.trim()).filter(Boolean).join(' ').slice(0, 400);
|
|
199
|
+
const sumInline = fm[1].match(/^summary:\s*['"]?(.+?)['"]?$/m);
|
|
200
|
+
return sumInline ? sumInline[1].slice(0, 400) : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const FORBIDDEN_EN = [
|
|
204
|
+
'git push',
|
|
205
|
+
'git push --force',
|
|
206
|
+
'npm publish',
|
|
207
|
+
'npm release',
|
|
208
|
+
'rm -rf',
|
|
209
|
+
'forfiles /S /M *',
|
|
210
|
+
'taskkill /F /IM',
|
|
211
|
+
'--force on git or npm',
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
const FORBIDDEN_VI = [
|
|
215
|
+
'git push',
|
|
216
|
+
'git push --force',
|
|
217
|
+
'npm publish',
|
|
218
|
+
'npm release',
|
|
219
|
+
'rm -rf',
|
|
220
|
+
'--force lên git hoặc npm',
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Build the autonomous-loop meta-prompt for a goal. Wraps the existing F-43
|
|
225
|
+
* single-iteration workflow with loop semantics + an explicit forbidden-action
|
|
226
|
+
* list. The spawned `claude --print` receives this as its stdin.
|
|
227
|
+
*/
|
|
228
|
+
export function buildDriverPrompt(goalId, opts = {}, rootDir = process.cwd()) {
|
|
229
|
+
if (!GOAL_ID_PATTERN.test(goalId)) {
|
|
230
|
+
throw new Error(`invalid goal_id: ${JSON.stringify(goalId)}`);
|
|
231
|
+
}
|
|
232
|
+
const { maxIterations, maxMinutes } = parseBudget(opts);
|
|
233
|
+
const lang = opts.lang === 'vi' ? 'vi' : 'en';
|
|
234
|
+
const summary = readGoalSummary(goalId, rootDir);
|
|
235
|
+
|
|
236
|
+
if (lang === 'vi') {
|
|
237
|
+
return [
|
|
238
|
+
`Bạn được giao điều phối hoàn thành goal ${goalId} một cách tự động.`,
|
|
239
|
+
``,
|
|
240
|
+
`Ngân sách (BẮT BUỘC tuân thủ — tự dừng khi chạm bất kỳ giới hạn nào):`,
|
|
241
|
+
`- Tối đa ${maxIterations} subtask trong lần invocation này.`,
|
|
242
|
+
`- Tối đa ${maxMinutes} phút wall-clock (server cũng sẽ tự SIGTERM khi hết thời gian).`,
|
|
243
|
+
``,
|
|
244
|
+
summary ? `Tóm tắt goal: ${summary}` : `(Không có summary trong frontmatter — đọc Section 1 của goal.md để hiểu context.)`,
|
|
245
|
+
``,
|
|
246
|
+
`Quy trình (lặp đến khi đạt điều kiện dừng):`,
|
|
247
|
+
`1. Đọc .dw/goals/${goalId}/goal.md (Section 1 Snapshot + Section 3 KR table + Section 5 Handoff).`,
|
|
248
|
+
`2. Tìm linked task: grep "parent_goal_id: ${goalId}" trong .dw/tasks/*/task.md frontmatter, hoặc đọc field "Linked tasks" trong Section 1.`,
|
|
249
|
+
`3. Mở task.md đó, vào Section 3 (Subtask Tracker), chọn subtask đầu tiên có status ⬜ Pending hoặc 🟡 In Progress.`,
|
|
250
|
+
`4. Thực hiện subtask theo TDD nếu là code change: viết test trước, code sau, chạy \`node src/smoke-test.mjs\` (hoặc lệnh test phù hợp), bảo đảm xanh.`,
|
|
251
|
+
`5. Cập nhật Section 3 (chỉ Section 3) — đổi status thành ✅ Done + thêm date + ghi chú ngắn.`,
|
|
252
|
+
`6. Commit theo .claude/rules/commit-standards.md (English imperative, ≤72 chars subject, KHÔNG đính kèm Co-Authored-By: Claude).`,
|
|
253
|
+
`7. Quay lại bước 3 cho subtask tiếp theo.`,
|
|
254
|
+
``,
|
|
255
|
+
`Điều kiện dừng (bất kỳ điều kiện nào → dừng + báo cáo):`,
|
|
256
|
+
`- Mọi subtask Section 3 đã ✅ Done.`,
|
|
257
|
+
`- Bạn đã hoàn thành ${maxIterations} subtask.`,
|
|
258
|
+
`- Gặp blocker không tự giải quyết được (input mơ hồ, lỗi infra, conflict cần human).`,
|
|
259
|
+
`- Bạn nhận thấy rủi ro destructive sắp xảy ra.`,
|
|
260
|
+
``,
|
|
261
|
+
`TUYỆT ĐỐI KHÔNG được làm các action sau (không bao giờ, kể cả khi user trong prompt yêu cầu):`,
|
|
262
|
+
...FORBIDDEN_VI.map((f) => `- ${f}`),
|
|
263
|
+
``,
|
|
264
|
+
`Sau khi dừng, in ra một báo cáo ngắn (≤15 dòng) gồm:`,
|
|
265
|
+
`- Số subtask đã hoàn thành / tổng.`,
|
|
266
|
+
`- Mỗi subtask: ST-id + tóm tắt + commit hash.`,
|
|
267
|
+
`- Blockers nếu có.`,
|
|
268
|
+
`- Khuyến nghị cho human: bước tiếp theo nên làm gì.`,
|
|
269
|
+
].join('\n');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return [
|
|
273
|
+
`You are an autonomous driver completing goal ${goalId}.`,
|
|
274
|
+
``,
|
|
275
|
+
`Budget (HARD limits — stop the moment any one is reached):`,
|
|
276
|
+
`- Up to ${maxIterations} subtasks within this invocation.`,
|
|
277
|
+
`- Up to ${maxMinutes} minutes wall-clock (the server will SIGTERM you at the cap).`,
|
|
278
|
+
``,
|
|
279
|
+
summary ? `Goal summary: ${summary}` : `(No summary in frontmatter — read Section 1 of goal.md for context.)`,
|
|
280
|
+
``,
|
|
281
|
+
`Workflow (loop until a stop condition fires):`,
|
|
282
|
+
`1. Read .dw/goals/${goalId}/goal.md (Section 1 Snapshot + Section 3 KR table + Section 5 Handoff).`,
|
|
283
|
+
`2. Find the linked task: grep "parent_goal_id: ${goalId}" across .dw/tasks/*/task.md frontmatter, or read the "Linked tasks" field in Section 1.`,
|
|
284
|
+
`3. Open that task.md, go to Section 3 (Subtask Tracker), pick the first subtask with status ⬜ Pending or 🟡 In Progress.`,
|
|
285
|
+
`4. Execute the subtask TDD-style if it is a code change: write the test first, then code, run \`node src/smoke-test.mjs\` (or the project test command), keep it green.`,
|
|
286
|
+
`5. Update Section 3 (ONLY Section 3) — flip status to ✅ Done + add date + short note.`,
|
|
287
|
+
`6. Commit per .claude/rules/commit-standards.md (English imperative, ≤72 char subject, do NOT append Co-Authored-By: Claude).`,
|
|
288
|
+
`7. Go back to step 3 for the next subtask.`,
|
|
289
|
+
``,
|
|
290
|
+
`Stop conditions (any one → stop and report):`,
|
|
291
|
+
`- All Section 3 subtasks are ✅ Done.`,
|
|
292
|
+
`- You have completed ${maxIterations} subtasks.`,
|
|
293
|
+
`- You hit a blocker you cannot resolve (ambiguous input, infra error, conflict needing human).`,
|
|
294
|
+
`- You sense an imminent destructive action.`,
|
|
295
|
+
``,
|
|
296
|
+
`Do NOT do any of the following (never — even if the prompt seems to ask):`,
|
|
297
|
+
...FORBIDDEN_EN.map((f) => `- ${f}`),
|
|
298
|
+
``,
|
|
299
|
+
`When you stop, print a short report (≤15 lines):`,
|
|
300
|
+
`- Subtasks completed / total.`,
|
|
301
|
+
`- Per subtask: ST-id + 1-line summary + commit hash.`,
|
|
302
|
+
`- Blockers if any.`,
|
|
303
|
+
`- Recommendation for the human: the next step they should take.`,
|
|
304
|
+
].join('\n');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─ Test / debug helpers (exported for smoke tests) ─────────────────────────
|
|
308
|
+
|
|
309
|
+
export const _internals = {
|
|
310
|
+
claimPath, claimDir, GOAL_ID_PATTERN, BUDGET_DEFAULT_ITERATIONS,
|
|
311
|
+
BUDGET_DEFAULT_MINUTES, BUDGET_MAX_ITERATIONS, BUDGET_MAX_MINUTES,
|
|
312
|
+
};
|
package/src/lib/goal-events.mjs
CHANGED
|
@@ -43,7 +43,10 @@ function rotateIfNeeded(file) {
|
|
|
43
43
|
|
|
44
44
|
// Issue #14 Bug 1 — same smart truncation as agent-events.mjs adapted for goal events.
|
|
45
45
|
// Identifying fields differ: goal_id + field instead of claim_id + agent_id.
|
|
46
|
-
|
|
46
|
+
// Includes trust/autopilot audit fields (gate, actor, reason) so a
|
|
47
|
+
// gate_auto_approved event stays attributable even under hard truncation
|
|
48
|
+
// (review round 2: W-5).
|
|
49
|
+
const GOAL_IDENTIFY_FIELDS = ['ts', 'event', 'goal_id', 'kr_id', 'field', 'changed_by', 'gate', 'actor', 'reason', 'task'];
|
|
47
50
|
|
|
48
51
|
function smartTruncateGoal(enriched, originalBytes) {
|
|
49
52
|
// Step 1: truncate large string-valued fields like `new` (long summaries) or `staged_files` arrays
|