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.
Files changed (79) hide show
  1. package/.claude/hooks/stop-check.sh +10 -0
  2. package/.claude/rules/dw.md +2 -0
  3. package/.claude/skills/dw-decision/SKILL.md +2 -1
  4. package/.claude/skills/dw-goal/SKILL.md +206 -0
  5. package/.claude/skills/dw-goal-sync/SKILL.md +131 -0
  6. package/.claude/templates/agent-report.md +35 -35
  7. package/.dw/config/agents.yml +8 -0
  8. package/.dw/core/AGENTS.md +53 -53
  9. package/.dw/core/schemas/decision-frontmatter.schema.json +54 -0
  10. package/.dw/core/schemas/events/created.schema.json +33 -0
  11. package/.dw/core/schemas/events/debate_agent_failed.schema.json +42 -0
  12. package/.dw/core/schemas/events/debate_agent_replied.schema.json +44 -0
  13. package/.dw/core/schemas/events/debate_agent_started.schema.json +37 -0
  14. package/.dw/core/schemas/events/debate_completed.schema.json +36 -0
  15. package/.dw/core/schemas/events/debate_started.schema.json +47 -0
  16. package/.dw/core/schemas/events/goal_archived.schema.json +32 -0
  17. package/.dw/core/schemas/events/goal_created.schema.json +32 -0
  18. package/.dw/core/schemas/events/goal_field_updated.schema.json +35 -0
  19. package/.dw/core/schemas/events/goal_pivoted.schema.json +36 -0
  20. package/.dw/core/schemas/events/goal_status_changed.schema.json +40 -0
  21. package/.dw/core/schemas/events/goal_task_linked.schema.json +33 -0
  22. package/.dw/core/schemas/events/goal_task_unlinked.schema.json +33 -0
  23. package/.dw/core/schemas/events/index.json +185 -0
  24. package/.dw/core/schemas/events/orchestrator_cancelled.schema.json +29 -0
  25. package/.dw/core/schemas/events/orchestrator_completed.schema.json +38 -0
  26. package/.dw/core/schemas/events/orchestrator_confirm.schema.json +33 -0
  27. package/.dw/core/schemas/events/orchestrator_confirmed.schema.json +33 -0
  28. package/.dw/core/schemas/events/orchestrator_error.schema.json +29 -0
  29. package/.dw/core/schemas/events/orchestrator_pending_dropped.schema.json +29 -0
  30. package/.dw/core/schemas/events/orchestrator_pending_expired.schema.json +32 -0
  31. package/.dw/core/schemas/events/orchestrator_recommend_rejected.schema.json +37 -0
  32. package/.dw/core/schemas/events/orchestrator_recommended.schema.json +33 -0
  33. package/.dw/core/schemas/events/orchestrator_spawn_failed.schema.json +29 -0
  34. package/.dw/core/schemas/events/orchestrator_started.schema.json +33 -0
  35. package/.dw/core/schemas/events/orchestrator_timeout.schema.json +29 -0
  36. package/.dw/core/schemas/events/reconciled.schema.json +29 -0
  37. package/.dw/core/schemas/events/reconciled_stale.schema.json +29 -0
  38. package/.dw/core/schemas/events/session.created.schema.json +39 -0
  39. package/.dw/core/schemas/events/session.reconciled.schema.json +33 -0
  40. package/.dw/core/schemas/events/session.status_changed.schema.json +42 -0
  41. package/.dw/core/schemas/events/spawn_failed.schema.json +29 -0
  42. package/.dw/core/schemas/events/started.schema.json +59 -0
  43. package/.dw/core/schemas/events/stopped.schema.json +33 -0
  44. package/.dw/core/schemas/goal-frontmatter.schema.json +2 -2
  45. package/.dw/core/schemas/task-frontmatter.schema.json +2 -2
  46. package/.dw/core/templates/v3/task.md +38 -9
  47. package/.dw/security/advisory-snapshot.json +157 -0
  48. package/LICENSE +201 -21
  49. package/NOTICE +26 -0
  50. package/README.md +5 -2
  51. package/SECURITY.md +87 -0
  52. package/TRADEMARK.md +65 -0
  53. package/bin/dw.mjs +1 -1
  54. package/package.json +13 -5
  55. package/src/cli.mjs +33 -0
  56. package/src/commands/decision-index.mjs +45 -0
  57. package/src/commands/goal-delete.mjs +3 -1
  58. package/src/commands/goal-link.mjs +3 -1
  59. package/src/commands/goal-status.mjs +95 -0
  60. package/src/commands/lint-task.mjs +20 -0
  61. package/src/commands/task-index.mjs +47 -0
  62. package/src/commands/task-migrate.mjs +16 -5
  63. package/src/commands/task-new.mjs +6 -0
  64. package/src/commands/task-summary.mjs +4 -3
  65. package/src/commands/voice.mjs +590 -4
  66. package/src/lib/board-data.mjs +220 -0
  67. package/src/lib/debate.mjs +325 -0
  68. package/src/lib/decision-store.mjs +146 -0
  69. package/src/lib/event-schema.mjs +342 -0
  70. package/src/lib/goal-store.mjs +40 -1
  71. package/src/lib/lint-rules.mjs +10 -1
  72. package/src/lib/orchestrator.mjs +31 -9
  73. package/src/lib/session-store.mjs +36 -4
  74. package/src/lib/task-store.mjs +164 -0
  75. package/src/lib/voice-action.mjs +165 -0
  76. package/src/lib/voice-parser.mjs +13 -0
  77. package/.dw/config/connectors.local.yml +0 -38
  78. package/.dw/core/PILLARS.md +0 -122
  79. 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
+ }
@@ -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
- return linked;
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
+ }
@@ -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: `${e.path}: ${e.message}${e.keyword ? ` (${e.keyword})` : ''}`,
59
+ message,
51
60
  file: timelineFile,
52
61
  });
53
62
  }
@@ -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
- `- Reply in <=2 sentences before the tag. TTS reads slowly; brevity is kindness.`,
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
- const ctx = collectContext(rootDir, text, lang);
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 mode = def.goal_mode || 'trailing-arg';
174
- if (mode === 'trailing-arg') args.push(prompt);
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 - (mode === 'trailing-arg' ? 1 : 0))],
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: [mode === 'stdin' ? 'pipe' : 'ignore', 'pipe', 'pipe'],
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 (mode === 'stdin') {
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
- appendEvent(id, {
276
- event: 'reconciled',
277
- reason: pidMissing ? 'missing-pid' : 'pid-dead-on-listing',
278
- }, rootDir);
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
  }