futura-scion 0.2.2 → 0.2.3

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/bin/scion.js CHANGED
@@ -84,6 +84,33 @@ const oracle = realVerifiers ? { verifiers: realVerifiers } : { verifyFn };
84
84
  */
85
85
  const CLARIFY_MAX_DEPTH = 3;
86
86
 
87
+ /** Human chat summary of a brief model (+ optional verification). */
88
+ function briefSummary(model, verification, intake) {
89
+ const reqs = model.requirements || [];
90
+ const lines = [];
91
+ if (intake) {
92
+ lines.push(intake.added > 0
93
+ ? `Noted — ${intake.added} new requirement${intake.added > 1 ? 's' : ''} added to brief #${model.id} (${intake.total} total).`
94
+ : `Already captured — brief #${model.id} has ${intake.total} requirement${intake.total === 1 ? '' : 's'}.`);
95
+ } else {
96
+ lines.push(`Brief #${model.id}: ${reqs.length} requirement${reqs.length === 1 ? '' : 's'}.`);
97
+ }
98
+ for (const r of reqs.slice(0, 8)) {
99
+ const mark = r.status === 'pass' ? '✓' : r.status === 'fail' ? '✗' : r.checkable ? '◇' : '·';
100
+ lines.push(` ${mark} ${r.kind}: ${String(r.text).slice(0, 90)}${r.checkable ? '' : ' (judgment — needs a human or a model)'}`);
101
+ }
102
+ if (reqs.length > 8) lines.push(` …and ${reqs.length - 8} more`);
103
+ if (verification) {
104
+ lines.push(`Gate verification: ${verification.passed} passed, ${verification.failed} failed` +
105
+ (verification.coverage != null ? `, coverage ${(verification.coverage * 100).toFixed(0)}%` : '') +
106
+ (verification.failed > 0 ? ' — failing requirements block completion.' :
107
+ verification.passed > 0 ? ' — the contract holds.' : ''));
108
+ } else {
109
+ lines.push('Say "verify the brief" to run the checks against the tree.');
110
+ }
111
+ return lines.join('\n');
112
+ }
113
+
87
114
  async function oneShot(text, opts = {}, depth = 0) {
88
115
  const { interpret, slotsToPayload, clarify, resolveClarification, resetLearnedTermsCache } = await import('../src/mind/nlu.js');
89
116
  const { getProjectScopes, resolveScopes } = await import('../src/mind/scopes.js');
@@ -142,6 +169,29 @@ async function oneShot(text, opts = {}, depth = 0) {
142
169
  const saved = save({ type: 'observation', title: content.slice(0, 80), content, tags: ['remembered'], confidence: 90, provenance: 'explicit' });
143
170
  return { ok: true, intent: 'remember', result: saved };
144
171
  }
172
+ // Brief intent — context arrives IN THE CHAT, not as a file. The message
173
+ // itself is ingested into the active brief (accumulated across turns);
174
+ // "verify"/"check" phrasing settles the active brief instead.
175
+ if (interp.intent === 'brief') {
176
+ const briefs = await import('../src/mind/brief.js');
177
+ if (arg === 'verify' || restArgs.includes('--verify') || /\b(verify|check|settle)\b/i.test(text)) {
178
+ const id = briefs.activeBriefId();
179
+ if (!id) return { ok: false, intent: 'brief', reply: 'no active brief — state a requirement first (e.g. "the report must contain totals").' };
180
+ const c = briefs.compileBrief(id);
181
+ const v = await briefs.verifyBrief(id);
182
+ return {
183
+ ok: true, intent: 'brief',
184
+ reply: briefSummary(briefs.getBrief(id), v),
185
+ result: { briefId: id, compiled: c.compiled, verification: v },
186
+ };
187
+ }
188
+ const intake = await briefs.chatIntake(text);
189
+ return {
190
+ ok: true, intent: 'brief',
191
+ reply: briefSummary(intake.model, null, intake),
192
+ result: { briefId: intake.briefId, added: intake.added, total: intake.total },
193
+ };
194
+ }
145
195
 
146
196
  // Ambiguous: ask ONE deterministic question with the top-N ranked
147
197
  // interpretations instead of a cold escalation or a silent guess. Every
@@ -11,8 +11,12 @@ ladder:
11
11
  min_insight_confidence: 0.55 # reasoner rung admission floor
12
12
 
13
13
  llm:
14
- daily_tokens: 0 # 0 = LLM rung off (zero-LLM doctrine)
14
+ daily_tokens: 500
15
15
 
16
+ apiKey: null
17
+ model: "qwen2.5-coder:7b"
18
+ baseUrl: "http://localhost:11434/v1"
19
+ provider: "ollama"
16
20
  bounds:
17
21
  max_turns: 25
18
22
  timeout_ms: 300000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "futura-scion",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "The fused scion of cortex-os-agent + persona: one zero-LLM-dependent agent stack — Mind proposes, Muscle executes, Gate disposes.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/http.js CHANGED
@@ -275,7 +275,32 @@ export function createApiServer(opts = {}) {
275
275
 
276
276
  let reply; let meta;
277
277
  try {
278
- if (interp.intent === 'show' || interp.intent === 'search' || interp.intent === 'remember' || mode === 'chat') {
278
+ // Brief intent: requirements arrive IN THE CHAT — ingest with
279
+ // streamed stage events (parsed → compiled), or verify on request.
280
+ if (interp.intent === 'brief') {
281
+ const briefs = await import('./mind/brief.js');
282
+ if (/\b(verify|check|settle)\b/i.test(body.text)) {
283
+ const id = briefs.activeBriefId();
284
+ if (!id) {
285
+ reply = 'No active brief yet — state a requirement first (e.g. "the report must contain a totals row").';
286
+ meta = { rung: 'brief' };
287
+ } else {
288
+ emit('progress', { stage: 'brief-compile', detail: `compiling brief ${id}` });
289
+ const c = briefs.compileBrief(id);
290
+ emit('progress', { stage: 'brief-verify', detail: `${c.compiled} checks through the Gate` });
291
+ const v = await briefs.verifyBrief(id);
292
+ briefs.setActiveBrief(null); // settled — matches chatIntake settle semantics
293
+ reply = briefChatSummary(briefs.getBrief(id), v);
294
+ meta = { rung: 'brief', detail: `brief ${id}: ${v.passed}✓ ${v.failed}✗` };
295
+ }
296
+ } else {
297
+ emit('progress', { stage: 'brief-parse', detail: 'parsing requirements from the message' });
298
+ const intake = await briefs.chatIntake(body.text);
299
+ emit('progress', { stage: 'brief-compiled', detail: `${intake.model.requirements.filter(r => r.checkable).length} machine-checkable` });
300
+ reply = briefChatSummary(intake.model, null, intake);
301
+ meta = { rung: 'brief', detail: `brief ${intake.briefId}: +${intake.added} (${intake.total} total)` };
302
+ }
303
+ } else if (interp.intent === 'show' || interp.intent === 'search' || interp.intent === 'remember' || mode === 'chat') {
279
304
  reply = await chatAnswer(interp, body.text);
280
305
  meta = { rung: 'nlu', detail: `intent: ${interp.intent} (${Math.round(interp.confidence * 100)}%)` };
281
306
  } else {
@@ -325,6 +350,25 @@ export function createApiServer(opts = {}) {
325
350
  const { interpret, slotsToPayload } = await nlu();
326
351
  const interp = interpret(body.text);
327
352
  const mode = body.mode === 'agent' || body.mode === 'chat' ? body.mode : 'chat';
353
+ // Brief intent: requirements arrive IN THE CHAT — ingest into the
354
+ // active brief (accumulated across turns), or verify on request.
355
+ if (interp.intent === 'brief') {
356
+ const briefs = await import('./mind/brief.js');
357
+ if (/\b(verify|check|settle)\b/i.test(body.text)) {
358
+ const id = briefs.activeBriefId();
359
+ if (!id) {
360
+ return send(res, 200, { reply: 'No active brief yet — state a requirement first (e.g. "the report must contain a totals row").', meta: { rung: 'brief' } });
361
+ }
362
+ const c = briefs.compileBrief(id);
363
+ const v = await briefs.verifyBrief(id);
364
+ return send(res, 200, { reply: briefChatSummary(briefs.getBrief(id), v), meta: { rung: 'brief', detail: `brief ${id}: ${v.passed}✓ ${v.failed}✗` } });
365
+ }
366
+ const intake = await briefs.chatIntake(body.text);
367
+ return send(res, 200, {
368
+ reply: briefChatSummary(intake.model, null, intake),
369
+ meta: { rung: 'brief', detail: `brief ${intake.briefId}: +${intake.added} (${intake.total} total)` },
370
+ });
371
+ }
328
372
  if (interp.intent === 'show' || interp.intent === 'search' || interp.intent === 'remember' || mode === 'chat') {
329
373
  const reply = await chatAnswer(interp, body.text);
330
374
  return send(res, 200, { reply, meta: { rung: 'nlu', detail: `intent: ${interp.intent} (${Math.round(interp.confidence * 100)}%)` } });
@@ -489,6 +533,33 @@ async function nlu() {
489
533
  }
490
534
 
491
535
  /** Chat mode: answer from kernel state — never invents, never mutates. */
536
+ /** Human chat summary of a brief model (shared by /ui/act and stream). */
537
+ function briefChatSummary(model, verification, intake) {
538
+ const reqs = model.requirements || [];
539
+ const lines = [];
540
+ if (intake) {
541
+ lines.push(intake.added > 0
542
+ ? `Noted — ${intake.added} new requirement${intake.added > 1 ? 's' : ''} added to brief #${model.id} (${intake.total} total).`
543
+ : `Already captured — brief #${model.id} has ${intake.total} requirement${intake.total === 1 ? '' : 's'}.`);
544
+ } else {
545
+ lines.push(`Brief #${model.id}: ${reqs.length} requirement${reqs.length === 1 ? '' : 's'}.`);
546
+ }
547
+ for (const r of reqs.slice(0, 8)) {
548
+ const mark = r.status === 'pass' ? '✓' : r.status === 'fail' ? '✗' : r.checkable ? '◇' : '·';
549
+ lines.push(`${mark} ${r.kind}: ${String(r.text).slice(0, 90)}${r.checkable ? '' : ' (judgment — needs a human or a model)'}`);
550
+ }
551
+ if (reqs.length > 8) lines.push(`…and ${reqs.length - 8} more`);
552
+ if (verification) {
553
+ lines.push(`Gate verification: ${verification.passed} passed, ${verification.failed} failed` +
554
+ (verification.coverage != null ? `, coverage ${(verification.coverage * 100).toFixed(0)}%` : '') +
555
+ (verification.failed > 0 ? ' — failing requirements block completion.' :
556
+ verification.passed > 0 ? ' — the contract holds.' : ''));
557
+ } else {
558
+ lines.push('Say "verify the brief" to run the checks against the tree.');
559
+ }
560
+ return lines.join('\n');
561
+ }
562
+
492
563
  async function chatAnswer(interp, text) {
493
564
  if (interp.intent === 'show') {
494
565
  const what = interp.target || '';
package/src/mind/brief.js CHANGED
@@ -119,12 +119,26 @@ export function ingest(opts) {
119
119
  continue;
120
120
  }
121
121
 
122
- // Rule sentences: MUST/SHALL/SHOULD/cannot/never/always…
122
+ // Rule sentences: MUST/SHALL/SHOULD/cannot/never/always… Compound
123
+ // chat lines ("X must exist and the customer wants a nicer UI") are
124
+ // split on conjunctions so each requirement stands alone — a checkable
125
+ // rule is never muddled with an adjacent wish.
123
126
  if (RULE_MODAL_RE.test(line)) {
124
127
  const modality = /must\s+not|shall\s+not|cannot|can't|can’t|never|forbidden|not\s+be\s+allowed/i.test(line) ? 'must-not'
125
128
  : /must|shall|requires/i.test(line) ? 'must'
126
129
  : /should/i.test(line) ? 'should' : 'always';
127
- requirements.push({ kind: 'rule', text: line, subject: line, modality });
130
+ const conjuncts = line
131
+ // sentence boundaries are the primary split inside a chat line
132
+ .split(/(?<=[.!?])\s+/)
133
+ .flatMap(s => s.split(/\s+(?:and|also|plus)\s+(?=(?:the\s+|a\s+|an\s+|all\s+|every\s+)?\w+\s+(?:must|shall|should|cannot|never|wants?|needs?|is\s+not\s+allowed))/i))
134
+ .map(s => s.trim().replace(/[.]$/, '')).filter(Boolean);
135
+ for (const part of conjuncts) {
136
+ const m = /must\s+not|shall\s+not|cannot|can't|can’t|never|forbidden/i.test(part) ? 'must-not'
137
+ : /must|shall|requires/i.test(part) ? 'must'
138
+ : /should/i.test(part) ? 'should'
139
+ : /^(the\s+)?(customer|user|client|team)/i.test(part) ? 'wish' : modality;
140
+ requirements.push({ kind: m === 'wish' ? 'judgment' : 'rule', text: part, subject: part, modality: m });
141
+ }
128
142
  continue;
129
143
  }
130
144
  }
@@ -171,6 +185,21 @@ export function ingest(opts) {
171
185
  export function classify(req) {
172
186
  const t = String(req?.text || '');
173
187
 
188
+ // Compound sentences split FIRST: chat lines carry several requirements
189
+ // joined by "and"/"also" — each conjunct is classified on its own, so
190
+ // "X must exist and the customer wants a nicer UI" yields one checkable
191
+ // rule and one judgment, not one muddled compound.
192
+ const conjuncts = t.split(/\s+(?:and|also|plus|;)\s+/i).map(s => s.trim()).filter(s => s.length >= 8);
193
+ if (conjuncts.length > 1) {
194
+ const parts = conjuncts.map(conj => classify({ ...req, text: conj }));
195
+ if (parts.some(p => p.checkable)) {
196
+ // Prefer the checkable part as THIS requirement's spec; the other
197
+ // conjuncts are re-emitted by the caller via splitConjuncts().
198
+ const hit = parts.find(p => p.checkable);
199
+ return { ...hit, conjuncts };
200
+ }
201
+ }
202
+
174
203
  // Explicit directive wins: "check: <shell command>" or a backticked
175
204
  // command anchored to an exit-code phrase — the author says exactly how
176
205
  // to verify; the Gate runs it verbatim.
@@ -184,12 +213,14 @@ export function classify(req) {
184
213
  || t.match(/\b(?:must|shall|should)\s+(?:exist|be\s+present)[^.]*\b([\w./\\-]+\.[a-z0-9]{1,6})\b/i);
185
214
  if (exists) return { checkable: true, spec: { type: 'file-exists', target: exists[1] } };
186
215
 
187
- // file-lacks / file-contains: "X must not contain/contain Y".
188
- const contains = t.match(/\b([\w./\\-]+\.[a-z0-9]{1,6})\b[^.]*?\b(?:must|shall|should)\s+not\s+(?:contain|include|reference|log)\s+(?:the\s+)?(?:word\s+|string\s+|secret[s]?\s+)?["'`]([^"'`\n]+)["'`]/i)
189
- || t.match(/\b([\w./\\-]+\.[a-z0-9]{1,6})\b[^.]*?\b(?:must|shall|should)\s+(?:contain|include|reference)\s+(?:the\s+)?(?:word\s+|string\s+)?["'`]([^"'`\n]+)["'`]/i);
216
+ // file-lacks / file-contains: "X must not contain/contain Y". The needle
217
+ // may be bare (no quotes) in chat speech: "must not contain the word ZZZZ".
218
+ const contains = t.match(/\b([\w./\\-]+\.[a-z0-9]{1,6})\b[^.]*?\b(?:must|shall|should)\s+not\s+(?:contain|include|reference|log)\s+(?:the\s+)?(?:word\s+|string\s+|secret[s]?\s+)?(?:["'`]([^"'`\n]+)["'`]|([A-Za-z0-9_]{3,})\b)/i)
219
+ || t.match(/\b([\w./\\-]+\.[a-z0-9]{1,6})\b[^.]*?\b(?:must|shall|should)\s+(?:contain|include|reference)\s+(?:the\s+)?(?:word\s+|string\s+)?(?:["'`]([^"'`\n]+)["'`]|([A-Za-z0-9_]{3,})\b)/i);
190
220
  if (contains) {
191
221
  const negative = /must\s+not|shall\s+not|never/i.test(contains.input);
192
- return { checkable: true, spec: { type: negative ? 'file-lacks' : 'file-contains', target: contains[1], needle: contains[2] } };
222
+ const needle = contains[2] ?? contains[3];
223
+ return { checkable: true, spec: { type: negative ? 'file-lacks' : 'file-contains', target: contains[1], needle } };
193
224
  }
194
225
 
195
226
  // regex: "must match /re/"
@@ -377,9 +408,117 @@ export function listBriefs(limit = 20) {
377
408
  ).all(limit);
378
409
  }
379
410
 
411
+ // ─── Chat accumulation: the ACTIVE brief ────────────────────────────
412
+
413
+ const ACTIVE_KEY = 'active_brief';
414
+
415
+ /**
416
+ * The active brief: requirements arriving in CHAT accumulate onto one
417
+ * brief (the conversation's working contract) until it is verified or
418
+ * explicitly replaced. Persisted in the brain meta table — restart-safe.
419
+ *
420
+ * @returns {number|null} brief id
421
+ */
422
+ export function activeBriefId() {
423
+ try {
424
+ const row = getDb().prepare('SELECT value FROM meta WHERE key = ?').get(ACTIVE_KEY);
425
+ if (!row) return null;
426
+ const id = Number(JSON.parse(row.value));
427
+ return getDb().prepare('SELECT id FROM briefs WHERE id = ?').get(id) ? id : null;
428
+ } catch { return null; }
429
+ }
430
+
431
+ /** Point the accumulator at a brief (or clear with null). */
432
+ export function setActiveBrief(briefId) {
433
+ const db = getDb();
434
+ if (briefId == null) {
435
+ db.prepare('DELETE FROM meta WHERE key = ?').run(ACTIVE_KEY);
436
+ return { active: null };
437
+ }
438
+ if (!db.prepare('SELECT id FROM briefs WHERE id = ?').get(briefId)) {
439
+ throw new Error(`brief.setActiveBrief: no brief ${briefId}`);
440
+ }
441
+ db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
442
+ .run(ACTIVE_KEY, JSON.stringify(Number(briefId)));
443
+ return { active: Number(briefId) };
444
+ }
445
+
446
+ /**
447
+ * Chat intake: append a message to the active brief (creating one on
448
+ * first requirement), re-parse, and report the accumulated model. Short
449
+ * messages add requirements; the message itself is the context, not a
450
+ * file path.
451
+ *
452
+ * @param {string} message — the raw chat text
453
+ * @param {Object} [opts] — { title? }
454
+ * @returns {{ briefId, added: number, total: number, newRequirements: Array, model: Object }}
455
+ */
456
+ const SETTLE_RE = /^(verify|check|settle|run)( the)? brief\b/i;
457
+ export async function chatIntake(message, opts = {}) {
458
+ const db = getDb();
459
+ let briefId = activeBriefId();
460
+
461
+ if (!briefId) {
462
+ const b = ingest({ text: message, title: opts.title || message.slice(0, 80), source: 'chat' });
463
+ briefId = b.brief.id;
464
+ setActiveBrief(briefId);
465
+ const model = getBrief(briefId);
466
+ trailJournal('brief.chat-intake', { brief_id: briefId, added: model.requirements.length, created: true });
467
+ return { briefId, added: model.requirements.length, total: model.requirements.length, newRequirements: model.requirements, model };
468
+ }
469
+
470
+ // Append to the active brief WITHOUT re-ingesting the whole document —
471
+ // re-parse drifts (the goal paragraph mutates as text grows, old rules
472
+ // resurface as "new"). Only the NEW message is parsed; dedup by exact
473
+ // text keeps repeats honest.
474
+ const existing = new Set(
475
+ db.prepare('SELECT text FROM requirements WHERE brief_id = ?').all(briefId).map(r => r.text)
476
+ );
477
+ const parsed = ingest({ text: message, title: opts.title, source: 'chat', persist: false });
478
+ // Goals are only minted at brief creation (the goal paragraph is the
479
+ // whole accumulated text — re-adding it per turn duplicates noise).
480
+ // Settle phrases route to verification, never into the contract.
481
+ if (SETTLE_RE.test(message.trim())) {
482
+ const model = getBrief(briefId);
483
+ trailJournal('brief.chat-settle', { brief_id: briefId });
484
+ const verification = await verifyBrief(briefId);
485
+ setActiveBrief(null); // settled — the next context starts a fresh brief
486
+ return { briefId, verified: true, added: 0, total: model.requirements.length, newRequirements: [], verification, model };
487
+ }
488
+ const fresh = parsed.requirements.filter(r => r.kind !== 'goal' && !existing.has(r.text));
489
+ const ins = db.prepare('INSERT INTO requirements (brief_id, kind, text, subject) VALUES (?, ?, ?, ?)');
490
+ for (const r of fresh) ins.run(briefId, r.kind, r.text, r.subject ?? null);
491
+ const prev = db.prepare('SELECT text FROM briefs WHERE id = ?').get(briefId);
492
+ db.prepare('UPDATE briefs SET text = ? WHERE id = ?').run(`${prev.text}\n${message}`, briefId);
493
+
494
+ const model = getBrief(briefId);
495
+ // Auto-compile any newly checkable requirements.
496
+ let compiledNew = 0;
497
+ for (const row of model.requirements) {
498
+ if (row.kind !== 'goal' && !row.check_spec) {
499
+ const cls = classify(row);
500
+ const cmd = cls.checkable ? compileCheck(cls.spec) : null;
501
+ const spec = cls.checkable ? (cmd ? JSON.stringify(typeof cmd === 'string' ? { cmd } : cmd) : null) : null;
502
+ db.prepare('UPDATE requirements SET checkable = ?, check_spec = ?, compiled_cmd = ? WHERE id = ?')
503
+ .run(cls.checkable ? 1 : 0, cls.checkable ? JSON.stringify(cls.spec) : null, spec, row.id);
504
+ if (spec) compiledNew++;
505
+ }
506
+ }
507
+ trailJournal('brief.chat-intake', { brief_id: briefId, added: fresh.length, compiled_new: compiledNew });
508
+ return {
509
+ briefId,
510
+ added: fresh.length,
511
+ total: model.requirements.length,
512
+ newRequirements: fresh,
513
+ compiledNew,
514
+ model: getBrief(briefId),
515
+ };
516
+ }
517
+
380
518
  /** Reset (tests). */
381
519
  export function reset() {
382
520
  const db = getDb();
383
521
  db.prepare('DELETE FROM requirements').run();
384
522
  db.prepare('DELETE FROM briefs').run();
523
+ db.prepare('DELETE FROM meta WHERE key = ?').run(ACTIVE_KEY);
385
524
  }
package/src/mind/nlu.js CHANGED
@@ -154,6 +154,12 @@ export const INTENT_LEXICON = Object.freeze([
154
154
  { intent: 'analyze', words: ['analyze', 'scan', 'inspect', 'audit', 'lint'] },
155
155
  { intent: 'fix', words: ['fix', 'repair', 'patch', 'resolve', 'clean', 'solve'] },
156
156
  { intent: 'run', words: ['run', 'execute', 'do', 'perform', 'start'] },
157
+ // The brief intent: a chat message that CARRIES requirements ("the report
158
+ // must contain totals", "users must not be deleted without audit") is a
159
+ // problem-understanding utterance, not a fix/run command. Routed to the
160
+ // brief organ: the requirements are ingested into the active brief and
161
+ // compiled to Gate checks — in the chat, no file needed.
162
+ { intent: 'brief', words: ['must', 'must not', 'shall', 'should', 'requirement', 'requirements', 'acceptance', 'criteria', 'brief'] },
157
163
  ]);
158
164
 
159
165
  /**
@@ -376,6 +382,18 @@ export function interpret(utterance, opts = {}) {
376
382
  const specific = bestOf(tier1, 1);
377
383
  if (specific) {
378
384
  intent = specific.intent; matchedWord = specific.w;
385
+ // Brief-context precedence: when the utterance ALSO carries rule
386
+ // modality ("must"/"shall"/"never"), it is a requirements utterance
387
+ // even if a task verb appears in it — "fix the login bug, the report
388
+ // must contain totals" carries a requirement; the verb describes the
389
+ // work the requirements govern. The brief organ ingests the whole
390
+ // message; the verb is preserved as evidence.
391
+ const hasBrief = intentEvidence.some(e => e.intent === 'brief');
392
+ const hasActionVerb = intent !== 'brief' && ['fix', 'analyze', 'plan', 'run'].includes(intent);
393
+ if (hasBrief && hasActionVerb) {
394
+ intent = 'brief';
395
+ matchedWord = intentEvidence.find(e => e.intent === 'brief')?.word ?? matchedWord;
396
+ }
379
397
  } else {
380
398
  const generic = bestOf(tier2, 2);
381
399
  if (generic) { intent = generic.intent; matchedWord = generic.w; }