dw-kit 1.8.0-rc.2 → 1.9.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/hooks/stop-check.sh +10 -0
- package/.claude/rules/dw.md +2 -0
- package/.claude/skills/dw-decision/SKILL.md +2 -1
- package/.claude/skills/dw-goal/SKILL.md +206 -0
- package/.claude/skills/dw-goal-sync/SKILL.md +131 -0
- package/.claude/templates/agent-report.md +35 -35
- package/.dw/config/agents.yml +8 -0
- package/.dw/core/AGENTS.md +53 -53
- package/.dw/core/schemas/decision-frontmatter.schema.json +54 -0
- package/.dw/core/schemas/events/created.schema.json +33 -0
- package/.dw/core/schemas/events/debate_agent_failed.schema.json +42 -0
- package/.dw/core/schemas/events/debate_agent_replied.schema.json +44 -0
- package/.dw/core/schemas/events/debate_agent_started.schema.json +37 -0
- package/.dw/core/schemas/events/debate_completed.schema.json +36 -0
- package/.dw/core/schemas/events/debate_started.schema.json +47 -0
- package/.dw/core/schemas/events/goal_archived.schema.json +32 -0
- package/.dw/core/schemas/events/goal_created.schema.json +32 -0
- package/.dw/core/schemas/events/goal_field_updated.schema.json +35 -0
- package/.dw/core/schemas/events/goal_pivoted.schema.json +36 -0
- package/.dw/core/schemas/events/goal_status_changed.schema.json +40 -0
- package/.dw/core/schemas/events/goal_task_linked.schema.json +33 -0
- package/.dw/core/schemas/events/goal_task_unlinked.schema.json +33 -0
- package/.dw/core/schemas/events/index.json +185 -0
- package/.dw/core/schemas/events/orchestrator_cancelled.schema.json +29 -0
- package/.dw/core/schemas/events/orchestrator_completed.schema.json +38 -0
- package/.dw/core/schemas/events/orchestrator_confirm.schema.json +33 -0
- package/.dw/core/schemas/events/orchestrator_confirmed.schema.json +33 -0
- package/.dw/core/schemas/events/orchestrator_error.schema.json +29 -0
- package/.dw/core/schemas/events/orchestrator_pending_dropped.schema.json +29 -0
- package/.dw/core/schemas/events/orchestrator_pending_expired.schema.json +32 -0
- package/.dw/core/schemas/events/orchestrator_recommend_rejected.schema.json +37 -0
- package/.dw/core/schemas/events/orchestrator_recommended.schema.json +33 -0
- package/.dw/core/schemas/events/orchestrator_spawn_failed.schema.json +29 -0
- package/.dw/core/schemas/events/orchestrator_started.schema.json +33 -0
- package/.dw/core/schemas/events/orchestrator_timeout.schema.json +29 -0
- package/.dw/core/schemas/events/reconciled.schema.json +29 -0
- package/.dw/core/schemas/events/reconciled_stale.schema.json +29 -0
- package/.dw/core/schemas/events/session.created.schema.json +39 -0
- package/.dw/core/schemas/events/session.reconciled.schema.json +33 -0
- package/.dw/core/schemas/events/session.status_changed.schema.json +42 -0
- package/.dw/core/schemas/events/spawn_failed.schema.json +29 -0
- package/.dw/core/schemas/events/started.schema.json +59 -0
- package/.dw/core/schemas/events/stopped.schema.json +33 -0
- package/.dw/core/schemas/goal-frontmatter.schema.json +2 -2
- package/.dw/core/schemas/task-frontmatter.schema.json +2 -2
- package/.dw/core/templates/v3/task.md +38 -9
- package/.dw/security/advisory-snapshot.json +157 -0
- package/LICENSE +201 -21
- package/NOTICE +26 -0
- package/README.md +5 -2
- package/SECURITY.md +87 -0
- package/TRADEMARK.md +65 -0
- package/bin/dw.mjs +1 -1
- package/package.json +13 -5
- package/src/cli.mjs +33 -0
- package/src/commands/decision-index.mjs +45 -0
- package/src/commands/goal-delete.mjs +3 -1
- package/src/commands/goal-link.mjs +3 -1
- package/src/commands/goal-status.mjs +95 -0
- package/src/commands/lint-task.mjs +20 -0
- package/src/commands/task-index.mjs +47 -0
- package/src/commands/task-migrate.mjs +16 -5
- package/src/commands/task-new.mjs +6 -0
- package/src/commands/task-summary.mjs +4 -3
- package/src/commands/voice.mjs +590 -4
- package/src/lib/board-data.mjs +220 -0
- package/src/lib/debate.mjs +325 -0
- package/src/lib/decision-store.mjs +146 -0
- package/src/lib/event-schema.mjs +342 -0
- package/src/lib/goal-store.mjs +40 -1
- package/src/lib/lint-rules.mjs +10 -1
- package/src/lib/orchestrator.mjs +31 -9
- package/src/lib/session-store.mjs +36 -4
- package/src/lib/task-store.mjs +164 -0
- package/src/lib/voice-action.mjs +165 -0
- package/src/lib/voice-parser.mjs +13 -0
- package/.dw/config/connectors.local.yml +0 -38
- package/.dw/core/PILLARS.md +0 -122
- package/CLAUDE.md +0 -44
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// event-schema.mjs — runtime validator for the DW Event Schema v1.0.
|
|
2
|
+
//
|
|
3
|
+
// Authoritative spec: docs/specs/dw-event-schema-v1.0.md
|
|
4
|
+
// Adapter protocol: docs/specs/dw-adapter-protocol.md
|
|
5
|
+
//
|
|
6
|
+
// Design choices:
|
|
7
|
+
// - Hand-rolled validator, no ajv dependency at the hot path. The schemas
|
|
8
|
+
// are small (≤8 fields per event), the validator runs per-event in
|
|
9
|
+
// every adapter, and zero-dep keeps the npm install footprint flat.
|
|
10
|
+
// ajv-validated JSON Schema files DO ship (under .dw/core/schemas/events/)
|
|
11
|
+
// for third-party tools, but dw-kit itself uses this code path.
|
|
12
|
+
// - Advisory by default. validateEvent() returns { ok, errors }; it does
|
|
13
|
+
// NOT throw. Writers must not lose audit data due to a schema bug —
|
|
14
|
+
// the validator is for adapters at the boundary, not for the producer.
|
|
15
|
+
// - Tolerant of unknown event names + unknown fields. Per §8 of the spec,
|
|
16
|
+
// adapters MUST tolerate forward-compat additions; this validator
|
|
17
|
+
// mirrors that posture (an unknown event_name returns { ok: true,
|
|
18
|
+
// warnings: ['unknown event name; treated as informational'] }).
|
|
19
|
+
//
|
|
20
|
+
// Per ADR-0001: zero-dep, Node built-ins only.
|
|
21
|
+
|
|
22
|
+
export const SCHEMA_VERSION = '1.0.0';
|
|
23
|
+
|
|
24
|
+
// Catalogue of known event names, grouped by category (matches §5 of the
|
|
25
|
+
// spec). The value is the field shape: { required: [...], optional: [...] }.
|
|
26
|
+
// Field names map to type validators in TYPE_VALIDATORS below.
|
|
27
|
+
export const EVENT_CATALOGUE = {
|
|
28
|
+
// §5.1 Session lifecycle
|
|
29
|
+
created: {
|
|
30
|
+
category: 'session-lifecycle',
|
|
31
|
+
required: { agent: 'string', goal: 'string' },
|
|
32
|
+
optional: {},
|
|
33
|
+
},
|
|
34
|
+
started: {
|
|
35
|
+
category: 'session-lifecycle',
|
|
36
|
+
required: { pid: 'int', command: 'string', via: 'enum:cli|voice|voice-action|connector' },
|
|
37
|
+
optional: { args: 'string[]', goal_mode: 'enum:stdin|trailing-arg', workspace: 'string' },
|
|
38
|
+
},
|
|
39
|
+
stopped: {
|
|
40
|
+
category: 'session-lifecycle',
|
|
41
|
+
required: { signal: 'string', by: 'string' },
|
|
42
|
+
optional: {},
|
|
43
|
+
},
|
|
44
|
+
reconciled: {
|
|
45
|
+
category: 'session-lifecycle',
|
|
46
|
+
required: { reason: 'string' },
|
|
47
|
+
optional: {},
|
|
48
|
+
},
|
|
49
|
+
reconciled_stale: {
|
|
50
|
+
category: 'session-lifecycle',
|
|
51
|
+
required: { by: 'string' },
|
|
52
|
+
optional: {},
|
|
53
|
+
},
|
|
54
|
+
spawn_failed: {
|
|
55
|
+
category: 'session-lifecycle',
|
|
56
|
+
required: { error: 'string' },
|
|
57
|
+
optional: {},
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
// §5.2 Voice orchestrator
|
|
61
|
+
orchestrator_started: {
|
|
62
|
+
category: 'voice-orchestrator',
|
|
63
|
+
required: { transcript_preview: 'string', agent: 'string' },
|
|
64
|
+
optional: {},
|
|
65
|
+
},
|
|
66
|
+
orchestrator_spawn_failed: {
|
|
67
|
+
category: 'voice-orchestrator',
|
|
68
|
+
required: { error: 'string' },
|
|
69
|
+
optional: {},
|
|
70
|
+
},
|
|
71
|
+
orchestrator_timeout: {
|
|
72
|
+
category: 'voice-orchestrator',
|
|
73
|
+
required: { timeout_ms: 'int' },
|
|
74
|
+
optional: {},
|
|
75
|
+
},
|
|
76
|
+
orchestrator_completed: {
|
|
77
|
+
category: 'voice-orchestrator',
|
|
78
|
+
required: { exit_code: 'int' },
|
|
79
|
+
optional: { reply_chars: 'int', stderr_chars: 'int', reply_preview: 'string' },
|
|
80
|
+
},
|
|
81
|
+
orchestrator_error: {
|
|
82
|
+
category: 'voice-orchestrator',
|
|
83
|
+
required: { error: 'string' },
|
|
84
|
+
optional: {},
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// §5.3 Voice action gate (ADR-0014)
|
|
88
|
+
orchestrator_recommended: {
|
|
89
|
+
category: 'voice-action',
|
|
90
|
+
required: { action: 'string', args: 'object' },
|
|
91
|
+
optional: {},
|
|
92
|
+
},
|
|
93
|
+
orchestrator_recommend_rejected: {
|
|
94
|
+
category: 'voice-action',
|
|
95
|
+
required: { action: 'string', args: 'object', error: 'string' },
|
|
96
|
+
optional: {},
|
|
97
|
+
},
|
|
98
|
+
orchestrator_confirm: {
|
|
99
|
+
category: 'voice-action',
|
|
100
|
+
required: { action: 'string', args: 'object' },
|
|
101
|
+
optional: {},
|
|
102
|
+
},
|
|
103
|
+
orchestrator_confirmed: {
|
|
104
|
+
category: 'voice-action',
|
|
105
|
+
required: { action: 'string', args: 'object' },
|
|
106
|
+
optional: {}, // result fields vary by action; tolerate any extras
|
|
107
|
+
},
|
|
108
|
+
orchestrator_cancelled: {
|
|
109
|
+
category: 'voice-action',
|
|
110
|
+
required: { action: 'string' },
|
|
111
|
+
optional: {},
|
|
112
|
+
},
|
|
113
|
+
orchestrator_pending_dropped: {
|
|
114
|
+
category: 'voice-action',
|
|
115
|
+
required: { action: 'string' },
|
|
116
|
+
optional: {},
|
|
117
|
+
},
|
|
118
|
+
orchestrator_pending_expired: {
|
|
119
|
+
category: 'voice-action',
|
|
120
|
+
required: { action: 'string' },
|
|
121
|
+
optional: { ttl_ms: 'int' },
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// §5.4 Multi-agent debate (ADR-0015)
|
|
125
|
+
debate_started: {
|
|
126
|
+
category: 'debate',
|
|
127
|
+
required: { debate_id: 'string', topic: 'string', roster: 'string[]', lang: 'string' },
|
|
128
|
+
optional: { audit_session_id: 'string' },
|
|
129
|
+
},
|
|
130
|
+
debate_agent_started: {
|
|
131
|
+
category: 'debate',
|
|
132
|
+
required: { debate_id: 'string', agent: 'string', pid: 'int' },
|
|
133
|
+
optional: {},
|
|
134
|
+
},
|
|
135
|
+
debate_agent_replied: {
|
|
136
|
+
category: 'debate',
|
|
137
|
+
required: { debate_id: 'string', agent: 'string', ms: 'int', reply: 'string' },
|
|
138
|
+
optional: { reply_chars: 'int' },
|
|
139
|
+
},
|
|
140
|
+
debate_agent_failed: {
|
|
141
|
+
category: 'debate',
|
|
142
|
+
required: { debate_id: 'string', agent: 'string' },
|
|
143
|
+
optional: { error: 'string', exit_code: 'int', ms: 'int' },
|
|
144
|
+
},
|
|
145
|
+
debate_completed: {
|
|
146
|
+
category: 'debate',
|
|
147
|
+
required: { debate_id: 'string', agents: 'string[]' },
|
|
148
|
+
optional: {},
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
// §5.4b Session lifecycle — GLOBAL sink (F-44, lightweight signals for SSE
|
|
152
|
+
// fanout to dashboards). These are dual-written from session-store.mjs; the
|
|
153
|
+
// per-session events.jsonl still carries the full audit (created/started/
|
|
154
|
+
// stopped/reconciled). The `session.*` prefix differentiates global signals
|
|
155
|
+
// from per-session detail.
|
|
156
|
+
'session.created': {
|
|
157
|
+
category: 'session-lifecycle',
|
|
158
|
+
required: { session_id: 'string', agent: 'string' },
|
|
159
|
+
optional: { goal: 'string', workspace_path: 'string' },
|
|
160
|
+
},
|
|
161
|
+
'session.status_changed': {
|
|
162
|
+
category: 'session-lifecycle',
|
|
163
|
+
required: { session_id: 'string', to_status: 'string' },
|
|
164
|
+
optional: { from_status: 'string', pid: 'int', exit_code: 'int' },
|
|
165
|
+
},
|
|
166
|
+
'session.reconciled': {
|
|
167
|
+
category: 'session-lifecycle',
|
|
168
|
+
required: { session_id: 'string', reason: 'string' },
|
|
169
|
+
optional: {},
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// §5.5 Goal layer
|
|
173
|
+
goal_created: {
|
|
174
|
+
category: 'goal',
|
|
175
|
+
required: { goal_id: 'string' },
|
|
176
|
+
optional: { created_by: 'string' },
|
|
177
|
+
},
|
|
178
|
+
goal_field_updated: {
|
|
179
|
+
category: 'goal',
|
|
180
|
+
required: { goal_id: 'string', field: 'string' },
|
|
181
|
+
optional: { old: 'any', new: 'any' },
|
|
182
|
+
},
|
|
183
|
+
goal_status_changed: {
|
|
184
|
+
category: 'goal',
|
|
185
|
+
required: { goal_id: 'string', from_status: 'string', to_status: 'string' },
|
|
186
|
+
optional: { changed_by: 'string' },
|
|
187
|
+
},
|
|
188
|
+
goal_pivoted: {
|
|
189
|
+
category: 'goal',
|
|
190
|
+
required: { goal_id: 'string', from_version: 'int' },
|
|
191
|
+
optional: { to_version: 'int' },
|
|
192
|
+
},
|
|
193
|
+
goal_archived: {
|
|
194
|
+
category: 'goal',
|
|
195
|
+
required: { goal_id: 'string' },
|
|
196
|
+
optional: { archived_at: 'string' },
|
|
197
|
+
},
|
|
198
|
+
goal_task_linked: {
|
|
199
|
+
category: 'goal',
|
|
200
|
+
required: { goal_id: 'string', task_id: 'string' },
|
|
201
|
+
optional: {},
|
|
202
|
+
},
|
|
203
|
+
goal_task_unlinked: {
|
|
204
|
+
category: 'goal',
|
|
205
|
+
required: { goal_id: 'string', task_id: 'string' },
|
|
206
|
+
optional: {},
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const KNOWN_EVENTS = Object.freeze(Object.keys(EVENT_CATALOGUE));
|
|
211
|
+
export const CATEGORIES = Object.freeze(
|
|
212
|
+
Array.from(new Set(Object.values(EVENT_CATALOGUE).map((s) => s.category)))
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// ─ Type validators ──────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
function isInt(v) { return typeof v === 'number' && Number.isInteger(v) && Number.isFinite(v); }
|
|
218
|
+
function isString(v) { return typeof v === 'string'; }
|
|
219
|
+
function isStringArray(v) { return Array.isArray(v) && v.every(isString); }
|
|
220
|
+
function isPlainObject(v) {
|
|
221
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function validateField(value, type) {
|
|
225
|
+
// 'any' = present (not undefined). 'object' = plain object. 'enum:a|b|c' = one of.
|
|
226
|
+
if (type === 'any') return value !== undefined;
|
|
227
|
+
if (type === 'int') return isInt(value);
|
|
228
|
+
if (type === 'string') return isString(value);
|
|
229
|
+
if (type === 'string[]') return isStringArray(value);
|
|
230
|
+
if (type === 'object') return isPlainObject(value);
|
|
231
|
+
if (type.startsWith('enum:')) {
|
|
232
|
+
const allowed = type.slice(5).split('|');
|
|
233
|
+
return isString(value) && allowed.includes(value);
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─ Envelope validation ─────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
function validateEnvelope(ev) {
|
|
241
|
+
const errors = [];
|
|
242
|
+
if (!isPlainObject(ev)) {
|
|
243
|
+
errors.push('event must be a JSON object');
|
|
244
|
+
return errors;
|
|
245
|
+
}
|
|
246
|
+
if (!isString(ev.ts) || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(ev.ts)) {
|
|
247
|
+
errors.push('envelope.ts must be ISO 8601 UTC string ending in Z');
|
|
248
|
+
}
|
|
249
|
+
// F-44: allow dotted namespace prefix for global-sink events (e.g.
|
|
250
|
+
// session.created, goal.archived) per the v1.1 catalogue additions. The
|
|
251
|
+
// legacy bare snake_case names (created, debate_started, ...) remain valid.
|
|
252
|
+
if (!isString(ev.event) || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/.test(ev.event)) {
|
|
253
|
+
errors.push('envelope.event must be lowercase snake_case (optional dotted namespace prefix)');
|
|
254
|
+
}
|
|
255
|
+
return errors;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─ Public API ──────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Validate a single event. Returns { ok, errors, warnings, category? }.
|
|
262
|
+
*
|
|
263
|
+
* - Unknown event names → ok: true, warnings: ['unknown event name'],
|
|
264
|
+
* category: 'unknown'. Per spec §8, adapters MUST tolerate unknown events.
|
|
265
|
+
* - Unknown fields on known events → ok: true. Per spec §8, adapters MUST
|
|
266
|
+
* tolerate unknown fields.
|
|
267
|
+
* - Missing required fields → ok: false.
|
|
268
|
+
* - Type mismatches on known fields → ok: false.
|
|
269
|
+
* - Missing/malformed envelope → ok: false.
|
|
270
|
+
*
|
|
271
|
+
* Never throws. Returns plain data.
|
|
272
|
+
*/
|
|
273
|
+
export function validateEvent(ev) {
|
|
274
|
+
const errors = validateEnvelope(ev);
|
|
275
|
+
const warnings = [];
|
|
276
|
+
|
|
277
|
+
if (errors.length > 0) {
|
|
278
|
+
return { ok: false, errors, warnings, category: null };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const spec = EVENT_CATALOGUE[ev.event];
|
|
282
|
+
if (!spec) {
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
errors: [],
|
|
286
|
+
warnings: [`unknown event name "${ev.event}"; treated as informational per spec §8`],
|
|
287
|
+
category: 'unknown',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const [name, type] of Object.entries(spec.required)) {
|
|
292
|
+
if (!(name in ev)) {
|
|
293
|
+
errors.push(`required field "${name}" missing`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (!validateField(ev[name], type)) {
|
|
297
|
+
errors.push(`field "${name}" failed type check (${type})`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const [name, type] of Object.entries(spec.optional || {})) {
|
|
302
|
+
if (!(name in ev)) continue;
|
|
303
|
+
if (!validateField(ev[name], type)) {
|
|
304
|
+
errors.push(`optional field "${name}" present but failed type check (${type})`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
ok: errors.length === 0,
|
|
310
|
+
errors,
|
|
311
|
+
warnings,
|
|
312
|
+
category: spec.category,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Throwing variant. Use at adapter boundaries when you want strict
|
|
318
|
+
* enforcement. Throws a single Error whose .message lists all problems.
|
|
319
|
+
*/
|
|
320
|
+
export function validateEventStrict(ev) {
|
|
321
|
+
const r = validateEvent(ev);
|
|
322
|
+
if (!r.ok) {
|
|
323
|
+
const e = new Error(`event failed strict validation: ${r.errors.join('; ')}`);
|
|
324
|
+
e.errors = r.errors;
|
|
325
|
+
e.event = ev;
|
|
326
|
+
throw e;
|
|
327
|
+
}
|
|
328
|
+
return r;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Filter an event stream to only those passing validation. Yields the
|
|
333
|
+
* survivors. Logs skipped events to the supplied `onSkip` callback if
|
|
334
|
+
* provided. Used by adapter tail-readers per protocol §4 rule 1.
|
|
335
|
+
*/
|
|
336
|
+
export function* filterValid(events, onSkip) {
|
|
337
|
+
for (const ev of events) {
|
|
338
|
+
const r = validateEvent(ev);
|
|
339
|
+
if (r.ok) yield ev;
|
|
340
|
+
else if (typeof onSkip === 'function') onSkip(ev, r.errors);
|
|
341
|
+
}
|
|
342
|
+
}
|
package/src/lib/goal-store.mjs
CHANGED
|
@@ -38,15 +38,35 @@ export function readGoalIndex(rootDir = process.cwd()) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Stable key order so the same goal set serializes byte-identically regardless
|
|
42
|
+
// of writer path (syncIndexEntry appends; rebuilds use readdirSync order) (#21).
|
|
43
|
+
function sortGoals(goals) {
|
|
44
|
+
const sorted = {};
|
|
45
|
+
for (const id of Object.keys(goals || {}).sort()) sorted[id] = goals[id];
|
|
46
|
+
return sorted;
|
|
47
|
+
}
|
|
48
|
+
|
|
41
49
|
export function writeGoalIndex(index, rootDir = process.cwd()) {
|
|
42
50
|
const file = goalIndexFile(rootDir);
|
|
43
51
|
const dir = dirname(file);
|
|
44
52
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
53
|
+
const goals = sortGoals(index.goals);
|
|
54
|
+
// Skip the write when the goal set is byte-identical to disk: preserves the
|
|
55
|
+
// on-disk last_updated (freshness signal, ADR-0017/0018) and yields zero diff.
|
|
56
|
+
const prior = readGoalIndex(rootDir);
|
|
57
|
+
if (existsSync(file) && JSON.stringify(sortGoals(prior.goals)) === JSON.stringify(goals)) {
|
|
58
|
+
return prior;
|
|
59
|
+
}
|
|
60
|
+
// Spread `index` first so non-payload top-level fields ($schema_note,
|
|
61
|
+
// schema_version) and their order are preserved; override goals (sorted) +
|
|
62
|
+
// last_updated in place.
|
|
45
63
|
const updated = {
|
|
46
64
|
...index,
|
|
47
65
|
last_updated: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
|
|
66
|
+
goals,
|
|
48
67
|
};
|
|
49
68
|
writeFileSync(file, JSON.stringify(updated, null, 2) + '\n', 'utf8');
|
|
69
|
+
return updated;
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
export function readGoal(goalId, rootDir = process.cwd()) {
|
|
@@ -181,7 +201,9 @@ export function findLinkedTaskIds(goalId, rootDir = process.cwd()) {
|
|
|
181
201
|
else if (Array.isArray(fm.contributing_goal_ids) && fm.contributing_goal_ids.includes(goalId)) linked.push(entry);
|
|
182
202
|
} catch { /* skip */ }
|
|
183
203
|
}
|
|
184
|
-
|
|
204
|
+
// Sort for determinism — readdirSync order is filesystem-dependent and would
|
|
205
|
+
// otherwise churn linked_task_ids in the committed index (#21).
|
|
206
|
+
return linked.sort();
|
|
185
207
|
}
|
|
186
208
|
|
|
187
209
|
function extractTitle(content) {
|
|
@@ -200,3 +222,20 @@ export function nowUtc() {
|
|
|
200
222
|
export function validateGoalId(goalId) {
|
|
201
223
|
return /^G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?$/.test(goalId);
|
|
202
224
|
}
|
|
225
|
+
|
|
226
|
+
// Single source for the goal lifecycle status enum (#20). Reads the project's
|
|
227
|
+
// schema (consumer-vendored via `dw init`) and falls back to the canonical set
|
|
228
|
+
// when absent, so the status command works in a stripped install.
|
|
229
|
+
const FALLBACK_GOAL_STATUSES = ['Draft', 'Active', 'Achieved', 'Abandoned', 'Pivoted'];
|
|
230
|
+
|
|
231
|
+
export function goalStatusEnum(rootDir = process.cwd()) {
|
|
232
|
+
const schemaFile = join(rootDir, '.dw/core/schemas/goal-frontmatter.schema.json');
|
|
233
|
+
if (!existsSync(schemaFile)) return [...FALLBACK_GOAL_STATUSES];
|
|
234
|
+
try {
|
|
235
|
+
const schema = JSON.parse(readFileSync(schemaFile, 'utf8'));
|
|
236
|
+
const enumVals = schema?.properties?.status?.enum;
|
|
237
|
+
return Array.isArray(enumVals) && enumVals.length ? enumVals : [...FALLBACK_GOAL_STATUSES];
|
|
238
|
+
} catch {
|
|
239
|
+
return [...FALLBACK_GOAL_STATUSES];
|
|
240
|
+
}
|
|
241
|
+
}
|
package/src/lib/lint-rules.mjs
CHANGED
|
@@ -44,10 +44,19 @@ export function lintTimeline(taskDir, opts = {}) {
|
|
|
44
44
|
const result = validateFrontmatter(fm, schema);
|
|
45
45
|
if (!result.ok) {
|
|
46
46
|
for (const e of result.errors) {
|
|
47
|
+
let message = `${e.path}: ${e.message}${e.keyword ? ` (${e.keyword})` : ''}`;
|
|
48
|
+
// #22: the raw ajv enum error for schema_version is opaque. Teach the
|
|
49
|
+
// allowed values + the namespaced-vs-bare convention right at the error.
|
|
50
|
+
if (e.path === '/schema_version' && e.keyword === 'enum') {
|
|
51
|
+
const allowed = (e.params?.allowedValues || []).map((v) => `"${v}"`).join(', ');
|
|
52
|
+
message = `schema_version: must be one of ${allowed}. Canonical is namespaced `
|
|
53
|
+
+ `(task@v3.x) to match goal@v1 / tasks-index@v1; bare v3.x stays valid for `
|
|
54
|
+
+ `back-compat. See docs/specs/dw-document-schema-v1.0.md §2.1.`;
|
|
55
|
+
}
|
|
47
56
|
violations.push({
|
|
48
57
|
severity: 'error',
|
|
49
58
|
rule: 'frontmatter-schema',
|
|
50
|
-
message
|
|
59
|
+
message,
|
|
51
60
|
file: timelineFile,
|
|
52
61
|
});
|
|
53
62
|
}
|
package/src/lib/orchestrator.mjs
CHANGED
|
@@ -96,12 +96,32 @@ export function collectContext(rootDir, transcript = '', lang = 'en-US') {
|
|
|
96
96
|
* ctx.lang (BCP-47, e.g. "vi-VN", "en-US") tells the agent which language
|
|
97
97
|
* the user is most likely speaking; agent replies in that language.
|
|
98
98
|
*/
|
|
99
|
+
// F-48 Deep mode trigger detection. User says "explain X" / "phân tích X" /
|
|
100
|
+
// "đi sâu" etc → orchestrator answers with longer detail (≤8 sentences,
|
|
101
|
+
// markdown allowed); TTS still reads only the head, page shows full reply.
|
|
102
|
+
const DEEP_PATTERNS_EN = /\b(explain|elaborate|deep dive|thoroughly|in detail|tell me more|walk me through|break down|analyz[se])\b/i;
|
|
103
|
+
// VN: drop \b (JS regex word boundary doesn't recognize Vietnamese diacritics
|
|
104
|
+
// as word chars without /u + \p{L}). The phrases are distinctive enough that
|
|
105
|
+
// substring match is safe.
|
|
106
|
+
const DEEP_PATTERNS_VI = /(phân tích|chi tiết|giải thích kỹ|đi sâu|nói rõ|kỹ hơn|chi tiet|phan tich)/i;
|
|
107
|
+
export function detectDeepMode(text, lang = 'en-US') {
|
|
108
|
+
if (typeof text !== 'string' || !text) return false;
|
|
109
|
+
if (lang.startsWith('vi') && DEEP_PATTERNS_VI.test(text)) return true;
|
|
110
|
+
return DEEP_PATTERNS_EN.test(text);
|
|
111
|
+
}
|
|
112
|
+
|
|
99
113
|
export function getOrchestratorPrompt(ctx = {}) {
|
|
100
114
|
const transcriptSafe = (ctx.transcript || '').slice(0, 2000).replace(/"/g, '\\"');
|
|
101
115
|
const lang = ctx.lang || 'en-US';
|
|
102
116
|
const langPrefix = lang.split('-')[0];
|
|
117
|
+
const deep = ctx.mode === 'deep';
|
|
118
|
+
const lengthRule = deep
|
|
119
|
+
? (langPrefix === 'vi'
|
|
120
|
+
? `- DEEP MODE: trả lời chi tiết (≤8 câu), markdown OK. TTS sẽ chỉ đọc câu đầu, browser sẽ hiển thị toàn bộ.`
|
|
121
|
+
: `- DEEP MODE: reply in detail (≤8 sentences), markdown OK. TTS will read the head only; browser shows the full reply.`)
|
|
122
|
+
: `- Reply in <=2 sentences before the tag. TTS reads slowly; brevity is kindness.`;
|
|
103
123
|
const langLine = langPrefix === 'vi'
|
|
104
|
-
? `- The user is speaking Vietnamese (${lang}). REPLY IN VIETNAMESE. Vẫn theo các quy tắc trên: <=2 câu, không preamble
|
|
124
|
+
? `- The user is speaking Vietnamese (${lang}). REPLY IN VIETNAMESE. ${deep ? 'Cho phép markdown + ≤8 câu (DEEP MODE).' : 'Vẫn theo các quy tắc trên: <=2 câu, không preamble.'}`
|
|
105
125
|
: `- The user is speaking ${lang}. Reply in that language unless they switch.`;
|
|
106
126
|
const prompt = [
|
|
107
127
|
`You are a voice orchestrator for dw, a CLI workflow toolkit.`,
|
|
@@ -123,8 +143,9 @@ export function getOrchestratorPrompt(ctx = {}) {
|
|
|
123
143
|
` [ACTION: start_session agent=claude goal="fix the bug"]`,
|
|
124
144
|
` [ACTION: prune_sessions days=7]`,
|
|
125
145
|
` The system will ask the user to confirm before executing. ONLY these 3 actions are whitelisted; for any other request just answer verbally.`,
|
|
146
|
+
`- F-43: When the user asks to "start an agent FOR a goal" / "khởi động agent cho goal X" / "tiếp tục goal X" / "resume goal X", the goal= arg MUST be a FULL TASK PROMPT, not just the goal_id. Example BAD: goal="G-rgoal-realtime-orch". Example GOOD: goal="Read .dw/goals/G-rgoal-realtime-orch/goal.md, find the linked task, pick the first pending subtask in Section 3, execute it (TDD), commit per repo rules, stop before push." A bare goal_id makes the spawned agent give a one-shot status report instead of actually working. (The system will safety-net-expand a bare goal_id, but it's better to write the full prompt yourself.)`,
|
|
126
147
|
`- For READ-ONLY queries (listing sessions / listing goals / showing one session's details / checking status / "what's running" / "how many goals"), DO NOT emit an action tag. You ALREADY have the workspace state above — summarize it directly in your reply. Inventing actions like "list_sessions" or "show_status" is a bug; just answer with the data you see.`,
|
|
127
|
-
|
|
148
|
+
lengthRule,
|
|
128
149
|
`- Skip preamble ("Sure, ...", "Here's ..."). Answer directly.`,
|
|
129
150
|
`- If the user is chatting socially, NO tag — just chat briefly + nudge toward a command.`,
|
|
130
151
|
`- If you don't know, say so — don't invent commands or actions that aren't in the list above.`,
|
|
@@ -159,19 +180,20 @@ export function getOrchestratorPrompt(ctx = {}) {
|
|
|
159
180
|
*
|
|
160
181
|
* Returns: { ok, reply?, error?, sessionId?, exitCode? }
|
|
161
182
|
*/
|
|
162
|
-
export async function runOrchestrator({ text, rootDir, agentName, timeoutMs = DEFAULT_TIMEOUT_MS, lang = 'en-US' }) {
|
|
183
|
+
export async function runOrchestrator({ text, rootDir, agentName, timeoutMs = DEFAULT_TIMEOUT_MS, lang = 'en-US', mode = 'quick' }) {
|
|
163
184
|
const cfg = loadAgentsConfig(rootDir);
|
|
164
185
|
const def = cfg.agents?.[agentName];
|
|
165
186
|
if (!def || !def.command) {
|
|
166
187
|
return { ok: false, error: `orchestrator agent "${agentName}" not configured in agents.yml` };
|
|
167
188
|
}
|
|
168
|
-
|
|
189
|
+
// F-48: 'deep' mode loosens the ≤2-sentence rule.
|
|
190
|
+
const ctx = { ...collectContext(rootDir, text, lang), mode };
|
|
169
191
|
const prompt = getOrchestratorPrompt(ctx);
|
|
170
192
|
|
|
171
193
|
// Build argv. `goal_mode` is the same conventions as `dw session start`.
|
|
172
194
|
const args = [...(def.args || [])];
|
|
173
|
-
const
|
|
174
|
-
if (
|
|
195
|
+
const goalMode = def.goal_mode || 'trailing-arg';
|
|
196
|
+
if (goalMode === 'trailing-arg') args.push(prompt);
|
|
175
197
|
// For stdin mode we write below after spawn.
|
|
176
198
|
|
|
177
199
|
// Audit session (lightweight — we don't redirect stdio to file; capture inline).
|
|
@@ -179,7 +201,7 @@ export async function runOrchestrator({ text, rootDir, agentName, timeoutMs = DE
|
|
|
179
201
|
agent: `voice-orchestrator:${agentName}`,
|
|
180
202
|
goal: text.slice(0, 200),
|
|
181
203
|
workspacePath: rootDir,
|
|
182
|
-
command: [def.command, ...args.slice(0, args.length - (
|
|
204
|
+
command: [def.command, ...args.slice(0, args.length - (goalMode === 'trailing-arg' ? 1 : 0))],
|
|
183
205
|
}, rootDir);
|
|
184
206
|
appendEvent(state.session_id, { event: 'orchestrator_started', transcript_preview: text.slice(0, 120), agent: agentName }, rootDir);
|
|
185
207
|
|
|
@@ -199,7 +221,7 @@ export async function runOrchestrator({ text, rootDir, agentName, timeoutMs = DE
|
|
|
199
221
|
try {
|
|
200
222
|
child = spawnAgent(resolved, args, {
|
|
201
223
|
cwd: rootDir,
|
|
202
|
-
stdio: [
|
|
224
|
+
stdio: [goalMode === 'stdin' ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
203
225
|
env: { ...process.env, ...(def.env || {}) },
|
|
204
226
|
windowsHide: true,
|
|
205
227
|
});
|
|
@@ -224,7 +246,7 @@ export async function runOrchestrator({ text, rootDir, agentName, timeoutMs = DE
|
|
|
224
246
|
child.stdout.on('data', (c) => { stdout += c.toString(); });
|
|
225
247
|
child.stderr.on('data', (c) => { stderr += c.toString(); });
|
|
226
248
|
|
|
227
|
-
if (
|
|
249
|
+
if (goalMode === 'stdin') {
|
|
228
250
|
try { child.stdin.write(prompt + '\n'); child.stdin.end(); } catch { /* harmless */ }
|
|
229
251
|
}
|
|
230
252
|
|
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, appendFileSync, openSync, closeSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
24
24
|
import { join, resolve, sep } from 'node:path';
|
|
25
25
|
import { randomBytes } from 'node:crypto';
|
|
26
|
+
// F-44: dual-write session lifecycle signals to events-global.jsonl so the
|
|
27
|
+
// SSE broker can fan them to browser/adapter clients for realtime updates.
|
|
28
|
+
// Per-session events.jsonl remains the authoritative audit; the global
|
|
29
|
+
// signals are intentionally lightweight (session_id + transition only).
|
|
30
|
+
import { logGoalEvent } from './goal-events.mjs';
|
|
26
31
|
|
|
27
32
|
const SESSIONS_DIR = '.dw/cache/sessions';
|
|
28
33
|
|
|
@@ -200,6 +205,17 @@ export function createSession({ agent, goal, workspacePath, command, owner }, ro
|
|
|
200
205
|
|
|
201
206
|
appendEvent(sessionId, { event: 'created', agent, goal }, rootDir);
|
|
202
207
|
|
|
208
|
+
// F-44: dual-write a lightweight global signal so SSE clients refresh.
|
|
209
|
+
try {
|
|
210
|
+
logGoalEvent({
|
|
211
|
+
event: 'session.created',
|
|
212
|
+
session_id: sessionId,
|
|
213
|
+
agent,
|
|
214
|
+
goal: typeof goal === 'string' ? goal.slice(0, 120) : '',
|
|
215
|
+
workspace_path: state.workspace_path,
|
|
216
|
+
}, rootDir);
|
|
217
|
+
} catch { /* best-effort; per-session events.jsonl is authoritative */ }
|
|
218
|
+
|
|
203
219
|
const idx = loadIndex(rootDir);
|
|
204
220
|
idx.sessions[sessionId] = summarize(state);
|
|
205
221
|
saveIndex(rootDir, idx);
|
|
@@ -231,6 +247,20 @@ export function updateSessionStatus(sessionId, patch, rootDir = process.cwd()) {
|
|
|
231
247
|
const idx = loadIndex(rootDir);
|
|
232
248
|
idx.sessions[sessionId] = summarize(merged);
|
|
233
249
|
saveIndex(rootDir, idx);
|
|
250
|
+
|
|
251
|
+
// F-44: dual-write a lightweight global signal when status actually changed.
|
|
252
|
+
if (patch.status && patch.status !== state.status) {
|
|
253
|
+
try {
|
|
254
|
+
logGoalEvent({
|
|
255
|
+
event: 'session.status_changed',
|
|
256
|
+
session_id: sessionId,
|
|
257
|
+
from_status: state.status,
|
|
258
|
+
to_status: patch.status,
|
|
259
|
+
...(typeof merged.pid === 'number' ? { pid: merged.pid } : {}),
|
|
260
|
+
...(typeof merged.exit_code === 'number' ? { exit_code: merged.exit_code } : {}),
|
|
261
|
+
}, rootDir);
|
|
262
|
+
} catch { /* best-effort */ }
|
|
263
|
+
}
|
|
234
264
|
return merged;
|
|
235
265
|
}
|
|
236
266
|
|
|
@@ -272,10 +302,12 @@ export function reconcileStaleSessions(rootDir = process.cwd()) {
|
|
|
272
302
|
const pidMissing = !summary.pid;
|
|
273
303
|
if (pidDead || pidMissing) {
|
|
274
304
|
updateSessionStatus(id, { status: 'exited', pid: null }, rootDir);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
305
|
+
const reason = pidMissing ? 'missing-pid' : 'pid-dead-on-listing';
|
|
306
|
+
appendEvent(id, { event: 'reconciled', reason }, rootDir);
|
|
307
|
+
// F-44: also fan global signal so SSE-watching clients refresh.
|
|
308
|
+
try {
|
|
309
|
+
logGoalEvent({ event: 'session.reconciled', session_id: id, reason }, rootDir);
|
|
310
|
+
} catch { /* best-effort */ }
|
|
279
311
|
changed++;
|
|
280
312
|
}
|
|
281
313
|
}
|