@cgh567/agent 2.4.3 → 2.4.5

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 (141) hide show
  1. package/agents/business/talisman-ceo.md +183 -0
  2. package/agents/business/talisman-comms.md +257 -0
  3. package/agents/business/talisman-cto.md +153 -0
  4. package/agents/business/talisman-finance.md +246 -0
  5. package/agents/business/talisman-marketing.md +240 -0
  6. package/agents/business/talisman-sales.md +242 -0
  7. package/agents/business/talisman-support.md +236 -0
  8. package/bin/helios-rpc-wrapper.sh +4 -1
  9. package/bin/helios-rpc.js +19 -0
  10. package/daemon/adapters/helios-rpc-adapter.js +5 -12
  11. package/daemon/context-enrichment.js +27 -0
  12. package/daemon/helios-api.js +310 -58
  13. package/daemon/helios-company-daemon.js +179 -53
  14. package/daemon/lib/blast-radius-analyzer.js +75 -0
  15. package/daemon/lib/domain-bootstrap-orchestrator.js +267 -0
  16. package/daemon/lib/forensic-log.js +113 -0
  17. package/daemon/lib/goal-research-pipeline.js +644 -0
  18. package/daemon/lib/harada/cascade-judge.js +84 -1
  19. package/daemon/lib/harada/cascade-research-dispatcher.js +282 -0
  20. package/daemon/lib/harada/pillar-dispatcher.js +23 -2
  21. package/daemon/lib/hbo-bridge.js +73 -5
  22. package/daemon/lib/headroom-middleware.js +129 -0
  23. package/daemon/lib/headroom-proxy-manager.js +319 -0
  24. package/daemon/lib/intelligence/department-page-generator.js +46 -1
  25. package/daemon/lib/interpretation-engine.js +92 -0
  26. package/daemon/lib/mental-model-cache.js +96 -0
  27. package/daemon/lib/project-factory.js +47 -0
  28. package/daemon/lib/session-log-reader.js +93 -0
  29. package/daemon/lib/standard-work-bootstrap.js +87 -1
  30. package/daemon/lib/task-completion-processor.js +12 -0
  31. package/daemon/package.json +2 -1
  32. package/daemon/routes/agents.js +51 -6
  33. package/daemon/routes/channels.js +116 -2
  34. package/daemon/routes/crm.js +85 -0
  35. package/daemon/routes/dashboard.js +62 -16
  36. package/daemon/routes/dept.js +10 -1
  37. package/daemon/routes/email-triage.js +19 -10
  38. package/daemon/routes/hbo.js +367 -13
  39. package/daemon/routes/hed.js +133 -0
  40. package/daemon/routes/inbox.js +466 -10
  41. package/daemon/routes/project.js +392 -9
  42. package/daemon/schema-definitions.js +10 -0
  43. package/daemon/schema-migrations-hbo.js +10 -0
  44. package/daemon/schema-migrations-proj.js +22 -0
  45. package/extensions/__tests__/codebase-index.test.ts +73 -0
  46. package/extensions/__tests__/extension-command-registration.test.ts +35 -0
  47. package/extensions/__tests__/git-push-guard.test.ts +68 -0
  48. package/extensions/context-compaction.ts +104 -76
  49. package/extensions/cortex/__tests__/cortex-core.test.ts +100 -0
  50. package/extensions/email/actions/draft-response.ts +21 -1
  51. package/extensions/email/auth/accounts.ts +5 -11
  52. package/extensions/email/auth/inbox-dog.ts +5 -2
  53. package/extensions/email/backfill.ts +20 -13
  54. package/extensions/email/providers/gmail.ts +164 -0
  55. package/extensions/email/providers/google-calendar.ts +34 -5
  56. package/extensions/helios-browser/__tests__/browser-routing.test.ts +57 -0
  57. package/extensions/helios-browser/backends/playwright.ts +3 -1
  58. package/extensions/helios-governance/__tests__/governance-gates.test.ts +40 -0
  59. package/extensions/helios-governance/__tests__/tournament-consumer.test.js +66 -0
  60. package/extensions/hema-dispatch-v3/headroom-compress.ts +103 -0
  61. package/extensions/hema-dispatch-v3/index.ts +33 -65
  62. package/extensions/interview/__tests__/server.test.ts +117 -0
  63. package/extensions/lib/helios-root.cjs +46 -0
  64. package/extensions/subagent-mesh/__tests__/handlers.test.ts +98 -0
  65. package/extensions/warm-tick/warm-tick-maintenance.ts +156 -0
  66. package/lib/__tests__/bulk-ingest.live.test.ts +66 -0
  67. package/lib/__tests__/crash-fixes.test.ts +49 -0
  68. package/lib/__tests__/maintenance-mission-wiring.test.ts +35 -0
  69. package/lib/broker/__tests__/jit-subscription.test.js +44 -1
  70. package/lib/broker/__tests__/lifecycle-channels.test.js +25 -1
  71. package/lib/compression/__tests__/ccr-store.test.js +138 -0
  72. package/lib/compression/__tests__/pipeline.test.js +280 -0
  73. package/lib/compression/__tests__/smart-crusher.test.js +242 -0
  74. package/lib/compression/dist/server.js +34 -1
  75. package/lib/compression/dist/start-server.js +77 -0
  76. package/lib/graph/learning/headroom-learn-bridge.js +175 -0
  77. package/lib/hbo-core-store.ts +71 -0
  78. package/lib/mission-loop/__tests__/research-handler.test.ts +143 -0
  79. package/lib/skill-sync.js +6 -1
  80. package/lib/startup-integrity.js +9 -2
  81. package/lib/triage-core/__tests__/classifier-fixture.test.ts +254 -0
  82. package/lib/triage-core/__tests__/classifier-post-norm.test.ts +1 -1
  83. package/lib/triage-core/__tests__/classifier.test.ts +45 -7
  84. package/lib/triage-core/__tests__/correction-detector.test.ts +36 -0
  85. package/lib/triage-core/__tests__/d6-dunbar-boost.test.ts +5 -5
  86. package/lib/triage-core/__tests__/orchestrator-pipeline.test.ts +107 -0
  87. package/lib/triage-core/__tests__/orchestrator.test.ts +113 -1
  88. package/lib/triage-core/__tests__/signals.test.ts +357 -0
  89. package/lib/triage-core/__tests__/sql-parity.test.ts +216 -0
  90. package/lib/triage-core/backfill-cost-estimator.ts +91 -0
  91. package/lib/triage-core/backfill-orchestrator.ts +119 -0
  92. package/lib/triage-core/classifier.ts +38 -6
  93. package/lib/triage-core/cos/cross-channel-escalation.ts +2 -3
  94. package/lib/triage-core/cos/response-debt.ts +2 -2
  95. package/lib/triage-core/graph/__tests__/batch-persistence.test.ts +283 -0
  96. package/lib/triage-core/graph/batch-persistence.ts +66 -2
  97. package/lib/triage-core/graph/betweenness-worker.js +75 -0
  98. package/lib/triage-core/graph/graph-rank-sql.ts +67 -0
  99. package/lib/triage-core/graph/persistence.ts +1 -1
  100. package/lib/triage-core/graph/schema-v2.ts +2 -0
  101. package/lib/triage-core/graph/schema.cypher +1 -0
  102. package/lib/triage-core/graph/triage-query.ts +1 -1
  103. package/lib/triage-core/learning.ts +15 -20
  104. package/lib/triage-core/mental-model/bedrock-config.ts +78 -0
  105. package/lib/triage-core/mental-model/cos-integration.ts +1 -1
  106. package/lib/triage-core/mental-model/entity-extractor.ts +51 -4
  107. package/lib/triage-core/mental-model/identity-resolver.ts +5 -5
  108. package/lib/triage-core/mental-model/model-assembler-sql.ts +200 -0
  109. package/lib/triage-core/mental-model/model-assembler.ts +16 -3
  110. package/lib/triage-core/orchestrator.ts +4 -4
  111. package/lib/triage-core/scheduled-sends.ts +39 -2
  112. package/lib/triage-core/signals/comms-style.ts +1 -1
  113. package/lib/triage-core/signals/cross-channel-escalation.ts +2 -2
  114. package/lib/triage-core/signals/favee-type.ts +6 -1
  115. package/lib/triage-core/signals/goal-relevance.ts +31 -2
  116. package/lib/triage-core/signals/personal-importance.ts +1 -1
  117. package/lib/triage-core/signals/referral-chain.ts +0 -1
  118. package/lib/triage-core/signals/relationship-decay.ts +4 -0
  119. package/lib/triage-core/signals/relationship-health.ts +6 -1
  120. package/lib/triage-core/signals/trajectory-signal.ts +38 -3
  121. package/lib/triage-core/tournament-runner.js +11 -1
  122. package/lib/triage-core/triage-llm-factory.ts +110 -0
  123. package/lib/triage-core/triage-local-llm.ts +145 -0
  124. package/lib/triage-core/triage-sql-store.ts +337 -0
  125. package/lib/triage-core/types.ts +2 -2
  126. package/lib/unified-graph.atomic.test.ts +52 -0
  127. package/lib/unified-graph.failure-categories.test.ts +55 -0
  128. package/package.json +10 -3
  129. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  130. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  131. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  132. package/skills/helios-bookkeeping/SKILL.md +321 -0
  133. package/skills/helios-briefer/SKILL.md +44 -0
  134. package/skills/helios-client-relations/SKILL.md +322 -0
  135. package/skills/helios-personal-triager/SKILL.md +45 -0
  136. package/skills/helios-recruitment/SKILL.md +317 -0
  137. package/skills/helios-relationship-nudger/SKILL.md +77 -0
  138. package/skills/helios-researcher/SKILL.md +44 -0
  139. package/skills/helios-scheduler/SKILL.md +58 -0
  140. package/skills/helios-tax-analyst/SKILL.md +280 -0
  141. package/lib/triage-core/orchestrator.ts.bak-r005-r006-r008 +0 -1823
@@ -222,7 +222,21 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
222
222
  const row = parseRows(projRes)[0];
223
223
  if (!row) return jsonErr(res, 404, 'Project not found', req);
224
224
  const keys = ['id','name','phase','status','pillarId','goalId','createdAt','purpose','approach','successCriteria','intentAnchor','exclusions','version','updatedAt','content'];
225
- return jsonOk(res, rowToObj(row, keys), req);
225
+ const proj = rowToObj(row, keys);
226
+ // P3-02: fetch standardWorkDocuments, questions, linkedTasks as supplemental arrays
227
+ const cid = ctx.cid || ctx?.cid || '';
228
+ const [swdRes, qRes, ltRes] = await Promise.all([
229
+ mgQuery(`MATCH (s:StandardWorkDocument {projectId: $id, companyId: $cid}) RETURN s.id, s.title, s.content, s.section, s.updatedAt`, { id: projectId, cid }).catch(() => null),
230
+ mgQuery(`MATCH (q:ProjectQuestion {projectId: $id, companyId: $cid}) RETURN q.id, q.question, q.answer, q.askedBy, q.createdAt`, { id: projectId, cid }).catch(() => null),
231
+ mgQuery(`MATCH (t:Task {companyId: $cid}) WHERE t.projectId = $id OR (t)-[:BELONGS_TO_PROJECT]->(:HeliosProject {id: $id}) RETURN t.id, t.title, t.status, t.assigneeAgentId`, { id: projectId, cid }).catch(() => null),
232
+ ]);
233
+ const swdKeys = ['id','title','content','section','updatedAt'];
234
+ const qKeys = ['id','question','answer','askedBy','createdAt'];
235
+ const ltKeys = ['id','title','status','assigneeAgentId'];
236
+ proj.standardWorkDocuments = parseRows(swdRes).map(r => rowToObj(r, swdKeys));
237
+ proj.questions = parseRows(qRes).map(r => rowToObj(r, qKeys));
238
+ proj.linkedTasks = parseRows(ltRes).map(r => rowToObj(r, ltKeys));
239
+ return jsonOk(res, proj, req);
226
240
  } catch (e) {
227
241
  process.stderr.write(`[project] handleGet Memgraph failed, falling back to SQLite: ${e.message}\n`);
228
242
  }
@@ -278,7 +292,7 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
278
292
  if (hboStore?.upsertProjectDocument) {
279
293
  try {
280
294
  const existing = hboStore.getProjectDocument ? hboStore.getProjectDocument(projectId) : null;
281
- const update: Record<string, any> = {
295
+ const update = {
282
296
  id: docId, project_id: projectId, company_id: companyId,
283
297
  ...(existing ? { purpose: existing.purpose, approach: existing.approach, intent_anchor: existing.intent_anchor, success_criteria: existing.success_criteria, exclusions: existing.exclusions, content: existing.content, version: existing.version ?? 1 } : {}),
284
298
  [section === 'intentAnchor' ? 'intent_anchor' : section === 'successCriteria' ? 'success_criteria' : section]: String(value),
@@ -486,11 +500,26 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
486
500
  async function handleConfirmUnderstanding(req, res, ctx, projectId) {
487
501
  const body = await readBody(req);
488
502
  if (!assertBody(body, res, req)) return;
489
- const { proposalId, confirmed, correctionNote } = body;
490
- if (!proposalId || confirmed === undefined) {
491
- return jsonErr(res, 400, 'proposalId and confirmed required', req);
503
+ // Accept both pduId (desktop sends this) and proposalId (legacy) to fix the
504
+ // pduId/proposalId mismatch. When pduId is supplied, resolve proposalId from the
505
+ // PendingDocumentUpdate node so the daemon handler always has the correct proposalId.
506
+ let { proposalId, pduId, confirmed, correctionNote } = body;
507
+ if (!proposalId && !pduId) {
508
+ return jsonErr(res, 400, 'pduId or proposalId required', req);
492
509
  }
510
+ if (confirmed === undefined) return jsonErr(res, 400, 'confirmed required', req);
493
511
  try {
512
+ // Resolve proposalId from pduId if only pduId was provided
513
+ if (!proposalId && pduId) {
514
+ const pduRes = await mgQuery(
515
+ `MATCH (u:PendingDocumentUpdate {id: $pduId}) RETURN u.proposalId`,
516
+ { pduId }
517
+ );
518
+ const pduRow = parseRows(pduRes)[0];
519
+ proposalId = pduRow ? (pduRow[0] ?? pduRow['proposalId']) : null;
520
+ if (!proposalId) return jsonErr(res, 404, 'PendingDocumentUpdate not found for pduId', req);
521
+ }
522
+
494
523
  await mgQuery(
495
524
  `MATCH (pcp:PlanChangeProposal {id: $proposalId, targetId: $projId})
496
525
  SET pcp.confirmedAt=datetime(),
@@ -515,7 +544,7 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
515
544
  `MATCH (pcp:PlanChangeProposal {id: $proposalId}) SET pcp.impact=$impact, pcp.status='impact_ready'`,
516
545
  { proposalId, impact: JSON.stringify(impact) }
517
546
  ).catch(() => {});
518
- broadcast({ type: 'project:impact:ready', payload: { projectId, proposalId, impact }, companyId: ctx?.cid || '' });
547
+ broadcast({ type: 'project:impact:ready', payload: { projectId, proposalId, pduId, impact }, companyId: ctx?.cid || '' });
519
548
  } catch (_) {}
520
549
  });
521
550
  }
@@ -530,10 +559,22 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
530
559
  async function handleApplyUpdate(req, res, ctx, projectId) {
531
560
  const body = await readBody(req);
532
561
  if (!assertBody(body, res, req)) return;
533
- const { proposalId } = body;
534
- if (!proposalId) return jsonErr(res, 400, 'proposalId required', req);
535
- if (!projectId) return jsonErr(res, 400, 'projectId required for project-scope proposals', req);
562
+ // Accept both pduId (desktop sends this) and proposalId (legacy) same fix as confirm.
563
+ let { proposalId, pduId } = body;
564
+ if (!proposalId && !pduId) return jsonErr(res, 400, 'pduId or proposalId required', req);
565
+ if (!projectId) return jsonErr(res, 400, 'projectId required for project-scope proposals', req);
536
566
  try {
567
+ // Resolve proposalId from pduId if only pduId was provided
568
+ if (!proposalId && pduId) {
569
+ const pduRes = await mgQuery(
570
+ `MATCH (u:PendingDocumentUpdate {id: $pduId}) RETURN u.proposalId`,
571
+ { pduId }
572
+ );
573
+ const pduRow = parseRows(pduRes)[0];
574
+ proposalId = pduRow ? (pduRow[0] ?? pduRow['proposalId']) : null;
575
+ if (!proposalId) return jsonErr(res, 404, 'PendingDocumentUpdate not found for pduId', req);
576
+ }
577
+
537
578
  const pcpRes = await mgQuery(
538
579
  `MATCH (pcp:PlanChangeProposal {id: $proposalId, targetId: $projId})
539
580
  RETURN pcp.section, pcp.proposedChange`,
@@ -1052,6 +1093,319 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
1052
1093
  }
1053
1094
  }
1054
1095
 
1096
+ // ── GET /api/department/:dept/document ────────────────────────────────────
1097
+ // Returns the DepartmentDocument node for a dept, or a null-content stub when
1098
+ // no document exists yet (never 404 — callers show blank editor on null content).
1099
+ async function handleGetDepartmentDocument(req, res, ctx, deptSlug) {
1100
+ const url = new URL(req.url, 'http://localhost');
1101
+ const companyId = url.searchParams.get('companyId') || ctx?.cid || '';
1102
+ if (!companyId) return jsonErr(res, 400, 'companyId required', req);
1103
+ const docId = `ddoc:${companyId}:${deptSlug}:main`;
1104
+ try {
1105
+ const result = await mgQuery(
1106
+ `MATCH (d:DepartmentDocument {id: $docId}) RETURN d.id, d.companyId, d.department, d.content, d.charter, d.teamFocus, d.successCriteria, d.intentAnchor, d.version, toString(d.updatedAt) AS updatedAt`,
1107
+ { docId }
1108
+ );
1109
+ const row = parseRows(result)[0];
1110
+ if (!row) {
1111
+ // No document yet — return stub (editor renders default blocks)
1112
+ return jsonOk(res, { id: docId, companyId, department: deptSlug, content: null, charter: null, teamFocus: null, successCriteria: null, intentAnchor: null, version: 0, updatedAt: null }, req);
1113
+ }
1114
+ const keys = ['id','companyId','department','content','charter','teamFocus','successCriteria','intentAnchor','version','updatedAt'];
1115
+ return jsonOk(res, rowToObj(row, keys), req);
1116
+ } catch (e) {
1117
+ jsonErr(res, 500, safeMsg(e), req);
1118
+ }
1119
+ }
1120
+
1121
+ // ── PATCH /api/department/:dept/document ──────────────────────────────────
1122
+ // Accepts { section, value, companyId }. Creates node on first save via MERGE.
1123
+ // Sections 'charter','teamFocus','successCriteria','intentAnchor' trigger LLM analysis.
1124
+ // Section 'content' (full block tree) persists without LLM.
1125
+ async function handlePatchDepartmentDocument(req, res, ctx, deptSlug) {
1126
+ const body = await readBody(req);
1127
+ if (!assertBody(body, res, req)) return;
1128
+ const { section, value, companyId: bodyCompanyId } = body;
1129
+ if (!section || value === undefined) return jsonErr(res, 400, 'section and value required', req);
1130
+ const DEPT_ALLOWED_SECTIONS = ['charter', 'teamFocus', 'successCriteria', 'intentAnchor', 'content'];
1131
+ if (!DEPT_ALLOWED_SECTIONS.includes(section)) {
1132
+ return jsonErr(res, 400, `section must be one of: ${DEPT_ALLOWED_SECTIONS.join(', ')}`, req);
1133
+ }
1134
+ const companyId = bodyCompanyId || ctx?.cid || '';
1135
+ if (!companyId) return jsonErr(res, 400, 'companyId required', req);
1136
+ const docId = `ddoc:${companyId}:${deptSlug}:main`;
1137
+
1138
+ try {
1139
+ // ── content section: persist block tree without LLM ───────────────────
1140
+ if (section === 'content') {
1141
+ let blocks;
1142
+ try {
1143
+ blocks = JSON.parse(String(value));
1144
+ if (!Array.isArray(blocks)) throw new Error('content must be a JSON array');
1145
+ } catch (pe) {
1146
+ return jsonErr(res, 400, `content is not valid block JSON: ${pe.message}`, req);
1147
+ }
1148
+ // Back-extract semantic fields from block tree so LLM context stays current
1149
+ let charter = '', teamFocus = '', intentAnchor = '';
1150
+ let lastHeading = '';
1151
+ const charterLines = [], teamFocusLines = [];
1152
+ function visitDeptBlock(block) {
1153
+ if (block.type === 'heading') {
1154
+ const text = (block.content || []).filter(n => n.type === 'text').map(n => n.text || '').join('').toLowerCase();
1155
+ if (text.includes('charter')) lastHeading = 'charter';
1156
+ else if (text.includes('team focus')) lastHeading = 'teamFocus';
1157
+ else lastHeading = text;
1158
+ } else if (block.type === 'intentAnchor') {
1159
+ intentAnchor = block.props?.value || '';
1160
+ } else if (block.type === 'paragraph' || block.type === 'bulletListItem') {
1161
+ const text = (block.content || []).filter(n => n.type === 'text').map(n => n.text || '').join('');
1162
+ if (lastHeading === 'charter') charterLines.push(text);
1163
+ else if (lastHeading === 'teamFocus') teamFocusLines.push(text);
1164
+ }
1165
+ if (Array.isArray(block.children)) block.children.forEach(visitDeptBlock);
1166
+ }
1167
+ blocks.forEach(visitDeptBlock);
1168
+ charter = charterLines.filter(Boolean).join('\n').slice(0, 2000);
1169
+ teamFocus = teamFocusLines.filter(Boolean).join('\n').slice(0, 2000);
1170
+
1171
+ await mgQuery(
1172
+ `MERGE (d:DepartmentDocument {id: $docId})
1173
+ ON CREATE SET d.companyId=$companyId, d.department=$dept, d.version=1, d.createdAt=datetime()
1174
+ ON MATCH SET d.version=d.version+1
1175
+ SET d.content=$content,
1176
+ d.charter = CASE WHEN $charter <> '' THEN $charter ELSE d.charter END,
1177
+ d.teamFocus = CASE WHEN $teamFocus <> '' THEN $teamFocus ELSE d.teamFocus END,
1178
+ d.intentAnchor= CASE WHEN $intentAnchor<> '' THEN $intentAnchor ELSE d.intentAnchor END,
1179
+ d.updatedAt=datetime()`,
1180
+ { docId, companyId, dept: deptSlug, content: String(value), charter, teamFocus, intentAnchor }
1181
+ );
1182
+ return jsonOk(res, { ok: true, section: 'content' }, req, 200);
1183
+ }
1184
+
1185
+ // ── Semantic section: create pduId, return 202, fire LLM async ────────
1186
+ const proposalId = `dcpp:${deptSlug}:${companyId}:${Date.now()}`;
1187
+ const pduId = `dpdu:${docId}:${Date.now()}`;
1188
+
1189
+ // MERGE the DepartmentDocument first (auto-create on first semantic edit)
1190
+ await mgQuery(
1191
+ `MERGE (d:DepartmentDocument {id: $docId})
1192
+ ON CREATE SET d.companyId=$companyId, d.department=$dept, d.version=0, d.createdAt=datetime(), d.updatedAt=datetime()`,
1193
+ { docId, companyId, dept: deptSlug }
1194
+ );
1195
+
1196
+ await mgQuery(
1197
+ `MERGE (pdu:DeptPendingDocumentUpdate {id: $pduId})
1198
+ ON CREATE SET pdu.companyId=$companyId, pdu.deptSlug=$deptSlug,
1199
+ pdu.docId=$docId, pdu.section=$section,
1200
+ pdu.newValue=$newValue, pdu.proposalId=$proposalId,
1201
+ pdu.status='pending_review', pdu.createdAt=datetime()`,
1202
+ { pduId, companyId, deptSlug, docId, section, newValue: String(value), proposalId }
1203
+ );
1204
+
1205
+ await mgQuery(
1206
+ `MERGE (pcp:DeptPlanChangeProposal {id: $proposalId})
1207
+ ON CREATE SET pcp.companyId=$companyId, pcp.deptSlug=$deptSlug,
1208
+ pcp.scope='department', pcp.section=$section,
1209
+ pcp.proposedChange=$value, pcp.pduId=$pduId,
1210
+ pcp.status='pending', pcp.createdAt=datetime()`,
1211
+ { proposalId, companyId, deptSlug, section, value: String(value), pduId }
1212
+ );
1213
+
1214
+ // Respond 202 immediately — LLM analysis fires asynchronously
1215
+ jsonOk(res, { ok: true, pduId, proposalId, message: 'Analyzing edit — check back shortly' }, req, 202);
1216
+
1217
+ // Trigger semantic analysis asynchronously if semanticUpdater is available
1218
+ if (semanticUpdater) {
1219
+ setImmediate(async () => {
1220
+ try {
1221
+ // Fetch current document for context (charter = intentAnchor analog)
1222
+ const docRes = await mgQuery(
1223
+ `MATCH (d:DepartmentDocument {id: $docId}) RETURN d.charter, d.teamFocus, d.successCriteria, d.intentAnchor`,
1224
+ { docId }
1225
+ );
1226
+ const docRow = parseRows(docRes)[0];
1227
+ const currentDoc = docRow
1228
+ ? {
1229
+ purpose: docRow[0] ?? docRow['charter'] ?? '', // charter → purpose slot
1230
+ approach: docRow[1] ?? docRow['teamFocus'] ?? '', // teamFocus → approach slot
1231
+ successCriteria: docRow[2] ?? docRow['successCriteria'] ?? '[]',
1232
+ intentAnchor: docRow[3] ?? docRow['intentAnchor'] ?? '',
1233
+ }
1234
+ : {};
1235
+
1236
+ let check;
1237
+ if (typeof semanticUpdater.analyzeProposal === 'function') {
1238
+ const proposal = { scope: 'department', targetId: deptSlug, section, proposedChange: String(value), companyId };
1239
+ const context = { purpose: currentDoc.purpose, approach: currentDoc.approach };
1240
+ check = await semanticUpdater.analyzeProposal(proposal, context);
1241
+ } else {
1242
+ const raw = await semanticUpdater.analyzeEdit({
1243
+ pduId,
1244
+ projectId: deptSlug,
1245
+ docId,
1246
+ section,
1247
+ newValue: String(value),
1248
+ oldValue: currentDoc[section] || '',
1249
+ intentAnchor: currentDoc.intentAnchor,
1250
+ exclusions: '[]',
1251
+ });
1252
+ check = {
1253
+ userIntentAsUnderstood: (raw && raw.userIntentAsUnderstood) ? raw.userIntentAsUnderstood : String(value).slice(0, 60),
1254
+ confidenceScore: (raw && raw.confidenceScore != null) ? raw.confidenceScore : 0,
1255
+ components: [],
1256
+ affectedCells: [],
1257
+ proposedChanges: [],
1258
+ requiresApproval: false,
1259
+ };
1260
+ }
1261
+
1262
+ await mgQuery(
1263
+ `MATCH (pcp:DeptPlanChangeProposal {id: $proposalId})
1264
+ SET pcp.interpretation=$interp, pcp.confidenceScore=$score,
1265
+ pcp.status='ready'`,
1266
+ {
1267
+ proposalId,
1268
+ interp: check.userIntentAsUnderstood || String(value).slice(0, 60),
1269
+ score: check.confidenceScore ?? 0,
1270
+ }
1271
+ ).catch(() => {});
1272
+
1273
+ await mgQuery(
1274
+ `MATCH (pdu:DeptPendingDocumentUpdate {id: $pduId}) SET pdu.status='ready', pdu.updatedAt=datetime()`,
1275
+ { pduId }
1276
+ ).catch(() => {});
1277
+
1278
+ broadcast({ type: 'dept:understanding:ready', payload: { deptSlug, proposalId, pduId, check }, companyId });
1279
+ } catch (_) {}
1280
+ });
1281
+ } else {
1282
+ await mgQuery(
1283
+ `MATCH (pcp:DeptPlanChangeProposal {id: $proposalId}) SET pcp.status='ready'`,
1284
+ { proposalId }
1285
+ ).catch(() => {});
1286
+ broadcast({ type: 'dept:understanding:ready', payload: { deptSlug, proposalId, pduId, check: { userIntentAsUnderstood: value, confidenceScore: 1 } }, companyId });
1287
+ }
1288
+ } catch (e) {
1289
+ jsonErr(res, 500, safeMsg(e), req);
1290
+ }
1291
+ }
1292
+
1293
+ // ── POST /api/department/:dept/document/confirm-understanding ─────────────
1294
+ // Accepts { pduId, confirmed, correctionNote }. Looks up proposalId from
1295
+ // DeptPendingDocumentUpdate (fixes pduId/proposalId mismatch pattern).
1296
+ async function handleConfirmDeptUnderstanding(req, res, ctx, deptSlug) {
1297
+ const body = await readBody(req);
1298
+ if (!assertBody(body, res, req)) return;
1299
+ const { pduId, confirmed, correctionNote } = body;
1300
+ if (!pduId || confirmed === undefined) return jsonErr(res, 400, 'pduId and confirmed required', req);
1301
+ try {
1302
+ // Resolve proposalId from pduId — avoids the pduId/proposalId mismatch bug
1303
+ const pduRes = await mgQuery(
1304
+ `MATCH (pdu:DeptPendingDocumentUpdate {id: $pduId}) RETURN pdu.proposalId, pdu.companyId`,
1305
+ { pduId }
1306
+ );
1307
+ const pduRow = parseRows(pduRes)[0];
1308
+ if (!pduRow) return jsonErr(res, 404, 'DeptPendingDocumentUpdate not found', req);
1309
+ const proposalId = pduRow[0] ?? pduRow['proposalId'];
1310
+ const companyId = (pduRow[1] ?? pduRow['companyId'] ?? ctx?.cid) || '';
1311
+
1312
+ await mgQuery(
1313
+ `MATCH (pcp:DeptPlanChangeProposal {id: $proposalId})
1314
+ SET pcp.confirmedAt=datetime(),
1315
+ pcp.status = CASE WHEN $confirmed THEN 'confirmed' ELSE 'dismissed' END`,
1316
+ { proposalId, confirmed: !!confirmed }
1317
+ );
1318
+
1319
+ if (confirmed && semanticUpdater) {
1320
+ setImmediate(async () => {
1321
+ try {
1322
+ const pcpRes = await mgQuery(
1323
+ `MATCH (pcp:DeptPlanChangeProposal {id: $proposalId}) RETURN pcp.section, pcp.proposedChange, pcp.interpretation`,
1324
+ { proposalId }
1325
+ );
1326
+ const pcpRow = parseRows(pcpRes)[0];
1327
+ if (!pcpRow) return;
1328
+ const section = pcpRow[0] ?? pcpRow['section'];
1329
+ const interp = pcpRow[2] ?? pcpRow['interpretation'];
1330
+ // Build dept-scoped impact ctx (no real ActionCells — use empty set)
1331
+ const impact = await semanticUpdater.extractImpact(
1332
+ { section, proposedValue: pcpRow[1] ?? pcpRow['proposedChange'], interpretation: interp, companyId, actionCells: [] },
1333
+ deptSlug
1334
+ ).catch(() => null);
1335
+ if (impact) {
1336
+ await mgQuery(
1337
+ `MATCH (pcp:DeptPlanChangeProposal {id: $proposalId}) SET pcp.impact=$impact, pcp.status='impact_ready'`,
1338
+ { proposalId, impact: JSON.stringify(impact) }
1339
+ ).catch(() => {});
1340
+ broadcast({ type: 'dept:impact:ready', payload: { deptSlug, proposalId, pduId, impact }, companyId });
1341
+ }
1342
+ } catch (_) {}
1343
+ });
1344
+ }
1345
+
1346
+ jsonOk(res, { ok: true }, req);
1347
+ } catch (e) {
1348
+ jsonErr(res, 500, safeMsg(e), req);
1349
+ }
1350
+ }
1351
+
1352
+ // ── POST /api/department/:dept/document/apply-update ─────────────────────
1353
+ // Accepts { pduId }. Looks up proposalId from DeptPendingDocumentUpdate.
1354
+ async function handleApplyDeptUpdate(req, res, ctx, deptSlug) {
1355
+ const body = await readBody(req);
1356
+ if (!assertBody(body, res, req)) return;
1357
+ const { pduId } = body;
1358
+ if (!pduId) return jsonErr(res, 400, 'pduId required', req);
1359
+ try {
1360
+ // Resolve proposalId from pduId
1361
+ const pduRes = await mgQuery(
1362
+ `MATCH (pdu:DeptPendingDocumentUpdate {id: $pduId}) RETURN pdu.proposalId, pdu.companyId`,
1363
+ { pduId }
1364
+ );
1365
+ const pduRow = parseRows(pduRes)[0];
1366
+ if (!pduRow) return jsonErr(res, 404, 'DeptPendingDocumentUpdate not found', req);
1367
+ const proposalId = pduRow[0] ?? pduRow['proposalId'];
1368
+ const companyId = (pduRow[1] ?? pduRow['companyId'] ?? ctx?.cid) || '';
1369
+
1370
+ const pcpRes = await mgQuery(
1371
+ `MATCH (pcp:DeptPlanChangeProposal {id: $proposalId, deptSlug: $deptSlug})
1372
+ RETURN pcp.section, pcp.proposedChange`,
1373
+ { proposalId, deptSlug }
1374
+ );
1375
+ const pcpRow = parseRows(pcpRes)[0];
1376
+ if (!pcpRow) return jsonErr(res, 404, 'DeptPlanChangeProposal not found', req);
1377
+
1378
+ const section = pcpRow[0] ?? pcpRow['section'];
1379
+ const value = pcpRow[1] ?? pcpRow['proposedChange'];
1380
+ const docId = `ddoc:${companyId}:${deptSlug}:main`;
1381
+
1382
+ const SAFE_DEPT_SECTIONS = ['charter', 'teamFocus', 'successCriteria', 'intentAnchor'];
1383
+ if (!SAFE_DEPT_SECTIONS.includes(section)) return jsonErr(res, 400, `Invalid section: ${section}`, req);
1384
+
1385
+ await mgQuery(
1386
+ `MATCH (d:DepartmentDocument {id: $docId})
1387
+ SET d.${section} = $value, d.version = d.version + 1, d.updatedAt = datetime()`,
1388
+ { docId, value: String(value) }
1389
+ );
1390
+
1391
+ await mgQuery(
1392
+ `MATCH (pcp:DeptPlanChangeProposal {id: $proposalId}) SET pcp.status='applied', pcp.resolvedAt=datetime()`,
1393
+ { proposalId }
1394
+ ).catch(() => {});
1395
+
1396
+ const verRes = await mgQuery(
1397
+ `MATCH (d:DepartmentDocument {id: $docId}) RETURN d.version`,
1398
+ { docId }
1399
+ ).catch(() => null);
1400
+ const documentVersion = parseRows(verRes)[0]?.[0] ?? null;
1401
+
1402
+ broadcast({ type: 'dept:document:applied', payload: { deptSlug, proposalId, pduId, section, documentVersion }, companyId });
1403
+ jsonOk(res, { ok: true, documentVersion }, req);
1404
+ } catch (e) {
1405
+ jsonErr(res, 500, safeMsg(e), req);
1406
+ }
1407
+ }
1408
+
1055
1409
  // ── Router ────────────────────────────────────────────────────────────────
1056
1410
 
1057
1411
  return async function projectRoute(req, res, ctx, pathname, method) {
@@ -1071,6 +1425,35 @@ module.exports = function createProjectRoute({ mgQuery, broadcast, invalidateCon
1071
1425
  await handleGenerateDepartmentPage(req, res, ctx, deptGenMatch[1]);
1072
1426
  return true;
1073
1427
  }
1428
+
1429
+ // ── /api/department/:dept/document routes ─────────────────────────────────
1430
+ // GET /api/department/:dept/document?companyId=
1431
+ // PATCH /api/department/:dept/document
1432
+ // POST /api/department/:dept/document/confirm-understanding
1433
+ // POST /api/department/:dept/document/apply-update
1434
+ const deptDocMatch = pathname.match(/^\/api\/department\/([^/]+)\/document(\/.*)?$/);
1435
+ if (deptDocMatch) {
1436
+ const deptSlug = deptDocMatch[1];
1437
+ const deptDocRest = deptDocMatch[2] || '';
1438
+
1439
+ if (method === 'GET' && deptDocRest === '') {
1440
+ await handleGetDepartmentDocument(req, res, ctx, deptSlug);
1441
+ return true;
1442
+ }
1443
+ if (method === 'PATCH' && deptDocRest === '') {
1444
+ await handlePatchDepartmentDocument(req, res, ctx, deptSlug);
1445
+ return true;
1446
+ }
1447
+ if (method === 'POST' && deptDocRest === '/confirm-understanding') {
1448
+ await handleConfirmDeptUnderstanding(req, res, ctx, deptSlug);
1449
+ return true;
1450
+ }
1451
+ if (method === 'POST' && deptDocRest === '/apply-update') {
1452
+ await handleApplyDeptUpdate(req, res, ctx, deptSlug);
1453
+ return true;
1454
+ }
1455
+ }
1456
+
1074
1457
  // Return false for any other /api/department path (GET is handled by helios-api.js)
1075
1458
  if (pathname.startsWith('/api/department')) return false;
1076
1459
 
@@ -94,6 +94,10 @@ const INDEXES = {
94
94
  PendingDocumentUpdate:['documentId', 'status', 'createdAt'],
95
95
  DepartmentPage: ['companyId', 'department', 'lastGeneratedAt'],
96
96
  PlanChangeProposal: ['companyId', 'targetId', 'status', 'scope', 'createdAt'],
97
+ // ── Department living document (Notion-feel editor) ────────────────────────
98
+ DepartmentDocument: ['companyId', 'department', 'updatedAt'],
99
+ DeptPendingDocumentUpdate:['companyId', 'deptSlug', 'status'],
100
+ DeptPlanChangeProposal: ['companyId', 'deptSlug', 'status'],
97
101
 
98
102
  // ── Company beliefs (previously missing entirely) ──────────────────────────
99
103
  CompanyBelief: [
@@ -115,6 +119,7 @@ const INDEXES = {
115
119
 
116
120
  // ── PDSA / learning ───────────────────────────────────────────────────────
117
121
  PDSACycle: ['companyId', 'taskId', 'agentId', 'actDecision', 'pillarId'],
122
+ HillChart: ['taskId', 'companyId', 'phase', 'updatedAt'],
118
123
  KnowledgeAsset:['companyId', 'status', 'confidence', 'lastAppliedAt', 'applicableTaskTypes'],
119
124
  HanseiRecord: ['companyId', 'taskId', 'agentId'],
120
125
 
@@ -305,6 +310,10 @@ const UNIQUE_CONSTRAINTS = {
305
310
  PendingDocumentUpdate: [['id']],
306
311
  DepartmentPage: [['id']],
307
312
  PlanChangeProposal: [['id']],
313
+ // ── Department living document ────────────────────────────────────────────
314
+ DepartmentDocument: [['id']],
315
+ DeptPendingDocumentUpdate: [['id']],
316
+ DeptPlanChangeProposal: [['id']],
308
317
 
309
318
  // ── Company beliefs (previously missing entirely) ─────────────────────────
310
319
  CompanyBelief: [['id']], // CRITICAL: concurrent MERGE safety
@@ -320,6 +329,7 @@ const UNIQUE_CONSTRAINTS = {
320
329
 
321
330
  // ── PDSA / learning ───────────────────────────────────────────────────────
322
331
  PDSACycle: [['id']],
332
+ HillChart: [['id']], // E-02: one per Task, keyed by id
323
333
  KnowledgeAsset: [['id']],
324
334
  HanseiRecord: [['id']],
325
335
  Hansei: [['id']], // previously missing — written via MERGE
@@ -129,6 +129,16 @@ async function runHBOMigrations(mgQuery) {
129
129
  `CREATE INDEX FOR (n:PDSACycle) ON (n.companyId)`,
130
130
  `CREATE INDEX FOR (n:PDSACycle) ON (n.actDecision)`,
131
131
 
132
+ // ── HillChart (E-02: task work-cycle position for hill chart visualisation) ─
133
+ // One HillChart node per Task. Connected via (t:Task)-[:HAS_HILL_CHART]->(h:HillChart).
134
+ // position: float 0.0 (start) → 0.5 (peak/pivot) → 1.0 (done).
135
+ // phase: 'uphill' | 'downhill' | 'complete'
136
+ `CREATE CONSTRAINT FOR (n:HillChart) REQUIRE n.id IS UNIQUE`,
137
+ `CREATE INDEX FOR (n:HillChart) ON (n.taskId)`,
138
+ `CREATE INDEX FOR (n:HillChart) ON (n.companyId)`,
139
+ `CREATE INDEX FOR (n:HillChart) ON (n.phase)`,
140
+ `CREATE INDEX FOR (n:HillChart) ON (n.updatedAt)`,
141
+
132
142
  // ── KnowledgeAsset ────────────────────────────────────────────────────────
133
143
  `CREATE CONSTRAINT FOR (n:KnowledgeAsset) REQUIRE n.id IS UNIQUE`,
134
144
  `CREATE INDEX FOR (n:KnowledgeAsset) ON (n.companyId)`,
@@ -62,6 +62,28 @@ async function runProjectMigrations(mgQuery) {
62
62
  // SM-proj-10: DepartmentPage enhanced fields indexes
63
63
  `CREATE INDEX FOR (n:DepartmentPage) ON (n.exceptionLevel)`,
64
64
  `CREATE INDEX FOR (n:DepartmentPage) ON (n.version)`,
65
+
66
+ // ── SM-proj-11: DepartmentDocument node ────────────────────────────────
67
+ // User-editable Notion-feel living document scoped to a department.
68
+ // ID convention: ddoc:<companyId>:<deptSlug>:main
69
+ `CREATE CONSTRAINT FOR (n:DepartmentDocument) REQUIRE n.id IS UNIQUE`,
70
+ `CREATE INDEX FOR (n:DepartmentDocument) ON (n.companyId)`,
71
+ `CREATE INDEX FOR (n:DepartmentDocument) ON (n.department)`,
72
+ `CREATE INDEX FOR (n:DepartmentDocument) ON (n.updatedAt)`,
73
+
74
+ // ── SM-proj-12: DeptPendingDocumentUpdate node ──────────────────────────
75
+ // User-visible edit tracker for dept document (mirrors PendingDocumentUpdate).
76
+ `CREATE CONSTRAINT FOR (n:DeptPendingDocumentUpdate) REQUIRE n.id IS UNIQUE`,
77
+ `CREATE INDEX FOR (n:DeptPendingDocumentUpdate) ON (n.companyId)`,
78
+ `CREATE INDEX FOR (n:DeptPendingDocumentUpdate) ON (n.deptSlug)`,
79
+ `CREATE INDEX FOR (n:DeptPendingDocumentUpdate) ON (n.status)`,
80
+
81
+ // ── SM-proj-13: DeptPlanChangeProposal node ─────────────────────────────
82
+ // LLM analysis task for dept document edits (mirrors PlanChangeProposal).
83
+ `CREATE CONSTRAINT FOR (n:DeptPlanChangeProposal) REQUIRE n.id IS UNIQUE`,
84
+ `CREATE INDEX FOR (n:DeptPlanChangeProposal) ON (n.companyId)`,
85
+ `CREATE INDEX FOR (n:DeptPlanChangeProposal) ON (n.deptSlug)`,
86
+ `CREATE INDEX FOR (n:DeptPlanChangeProposal) ON (n.status)`,
65
87
  ];
66
88
 
67
89
  let success = 0, skipped = 0, failed = 0;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * extensions/__tests__/codebase-index.test.ts
3
+ * P3-N4: Codebase index extension tests
4
+ */
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+
9
+ const INDEX_PATH = path.resolve(__dirname, '../codebase-index.ts');
10
+
11
+ describe('codebase-index — module contract', () => {
12
+ it('codebase-index.ts default-exports a Pi factory function', async () => {
13
+ if (!fs.existsSync(INDEX_PATH)) return;
14
+ const mod = await import('../codebase-index.ts').catch(() => null) as any;
15
+ if (!mod) return;
16
+ const factory = mod.default ?? mod;
17
+ expect(typeof factory === 'function' || typeof factory === 'object').toBe(true);
18
+ });
19
+
20
+ it('factory called with mock Pi registers search_codebase tool', async () => {
21
+ if (!fs.existsSync(INDEX_PATH)) return;
22
+ const mod = await import('../codebase-index.ts').catch(() => null) as any;
23
+ if (!mod) return;
24
+ const factory = mod.default;
25
+ if (typeof factory !== 'function') return;
26
+
27
+ const registeredTools: string[] = [];
28
+ const mockPi: any = {
29
+ registerTool: (name: string, _def: unknown) => { registeredTools.push(name); },
30
+ on: vi.fn(),
31
+ registerCommand: vi.fn(),
32
+ registerSlashCommand: vi.fn(),
33
+ };
34
+
35
+ try { factory(mockPi); } catch { /* Pi runtime unavailable */ }
36
+
37
+ const hasSearchTool = registeredTools.includes('search_codebase') ||
38
+ registeredTools.some(t => t.includes('search') || t.includes('codebase'));
39
+ if (registeredTools.length > 0) {
40
+ expect(hasSearchTool).toBe(true);
41
+ }
42
+ });
43
+
44
+ it('incremental re-index triggers only for .ts/.tsx/.js/.jsx extensions (source check)', () => {
45
+ if (!fs.existsSync(INDEX_PATH)) return;
46
+ const source = fs.readFileSync(INDEX_PATH, 'utf8');
47
+ // Should have file extension filtering
48
+ expect(source).toContain('.ts');
49
+ const hasExtFilter = source.includes('.tsx') || source.includes('.jsx') || source.includes('extension');
50
+ expect(hasExtFilter).toBe(true);
51
+ });
52
+
53
+ it('lock file mechanism: source references LOCK_EXPIRY or lock file pattern', () => {
54
+ if (!fs.existsSync(INDEX_PATH)) return;
55
+ const source = fs.readFileSync(INDEX_PATH, 'utf8');
56
+ const hasLock = source.includes('LOCK') || source.includes('lock') || source.includes('.lock');
57
+ if (!hasLock) {
58
+ // Named gap: lock file mechanism not found — stale lock cleanup is unimplemented
59
+ console.warn('[codebase-index] SKIP: no lock file mechanism found in source — stale lock protection may be missing');
60
+ return;
61
+ }
62
+ // If lock references exist, they must be meaningful — not just a comment
63
+ expect(hasLock).toBe(true);
64
+ });
65
+
66
+ it('search_codebase tool source exists and has query parameter', () => {
67
+ if (!fs.existsSync(INDEX_PATH)) return;
68
+ const source = fs.readFileSync(INDEX_PATH, 'utf8');
69
+ expect(source).toContain('search_codebase');
70
+ const hasQuery = source.includes('query') || source.includes('search');
71
+ expect(hasQuery).toBe(true);
72
+ });
73
+ });
@@ -221,3 +221,38 @@ describe('data-model-gate.ts command registration contract', () => {
221
221
  expect(validation.valid).toBe(true);
222
222
  });
223
223
  });
224
+
225
+ // P2-C1: lifecycle-hooks mission-run handler invocation
226
+ describe('lifecycle-hooks.ts — mission-run handler invocation', () => {
227
+ it('mission-run command handler is registered and callable', async () => {
228
+ const registered: Array<{name: string; opts: unknown}> = [];
229
+ const mockPi: any = {
230
+ registerCommand: (name: string, opts: unknown) => registered.push({ name, opts }),
231
+ on: () => {},
232
+ registerTool: () => {},
233
+ registerSlashCommand: () => {},
234
+ };
235
+
236
+ // Attempt to load lifecycle-hooks — it may require Pi runtime so we guard
237
+ let loaded = false;
238
+ try {
239
+ const mod = await import('../lifecycle-hooks.ts') as any;
240
+ const factory = mod.default ?? mod;
241
+ if (typeof factory === 'function') {
242
+ try { factory(mockPi); } catch { /* Pi runtime unavailable */ }
243
+ loaded = true;
244
+ }
245
+ } catch {
246
+ loaded = false;
247
+ }
248
+
249
+ if (!loaded) return; // guard: module needs Pi runtime
250
+
251
+ const missionCmd = registered.find(r => r.name === 'mission-run');
252
+ if (!missionCmd) return; // guard: command may be registered differently
253
+
254
+ // Verify the handler exists and is callable
255
+ const opts = missionCmd.opts as any;
256
+ expect(typeof opts.handler === 'function' || typeof opts.execute === 'function').toBe(true);
257
+ });
258
+ });