@limina-labs/momentum 0.30.2

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 (182) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/adapters/antigravity/adapter.js +196 -0
  4. package/adapters/antigravity/hooks.json +39 -0
  5. package/adapters/antigravity/instructions/AGENTS.md +638 -0
  6. package/adapters/antigravity/instructions/header.md +3 -0
  7. package/adapters/antigravity/instructions/surfaces.md +153 -0
  8. package/adapters/antigravity/instructions/vars.json +1 -0
  9. package/adapters/antigravity/scripts/antigravity-hook-adapter.sh +147 -0
  10. package/adapters/antigravity/skills/momentum-orient/SKILL.md +29 -0
  11. package/adapters/antigravity/skills/momentum-reviewer-architecture/SKILL.md +39 -0
  12. package/adapters/antigravity/skills/momentum-reviewer-qa/SKILL.md +38 -0
  13. package/adapters/antigravity/skills/momentum-reviewer-security/SKILL.md +42 -0
  14. package/adapters/antigravity/skills/swarm-supervisor/SKILL.md +75 -0
  15. package/adapters/antigravity/workflows/continue.md +39 -0
  16. package/adapters/antigravity/workflows/dispatch.md +49 -0
  17. package/adapters/antigravity/workflows/handoff.md +38 -0
  18. package/adapters/antigravity/workflows/review-code.md +84 -0
  19. package/adapters/antigravity/workflows/scout.md +42 -0
  20. package/adapters/antigravity/workflows/swarm.md +203 -0
  21. package/adapters/claude-code/adapter.js +148 -0
  22. package/adapters/claude-code/commands/continue.md +82 -0
  23. package/adapters/claude-code/commands/dispatch.md +154 -0
  24. package/adapters/claude-code/commands/handoff.md +85 -0
  25. package/adapters/claude-code/commands/review-code.md +194 -0
  26. package/adapters/claude-code/commands/scout.md +119 -0
  27. package/adapters/claude-code/commands/swarm.md +242 -0
  28. package/adapters/claude-code/instructions/header.md +3 -0
  29. package/adapters/claude-code/instructions/vars.json +1 -0
  30. package/adapters/claude-code/settings.json +36 -0
  31. package/adapters/codex/adapter.js +225 -0
  32. package/adapters/codex/agents/momentum-reviewer-architecture.toml +57 -0
  33. package/adapters/codex/agents/momentum-reviewer-qa.toml +52 -0
  34. package/adapters/codex/agents/momentum-reviewer-security.toml +57 -0
  35. package/adapters/codex/agents/swarm-supervisor.toml +76 -0
  36. package/adapters/codex/commands/continue.md +40 -0
  37. package/adapters/codex/commands/dispatch.md +111 -0
  38. package/adapters/codex/commands/handoff.md +57 -0
  39. package/adapters/codex/commands/scout.md +90 -0
  40. package/adapters/codex/commands/swarm.md +334 -0
  41. package/adapters/codex/hooks.json +36 -0
  42. package/adapters/codex/instructions/AGENTS.md +691 -0
  43. package/adapters/codex/instructions/header.md +3 -0
  44. package/adapters/codex/instructions/surfaces.md +206 -0
  45. package/adapters/codex/instructions/vars.json +1 -0
  46. package/adapters/codex/skills/momentum-orient/SKILL.md +29 -0
  47. package/adapters/opencode/adapter.js +224 -0
  48. package/adapters/opencode/agents/momentum-reviewer-architecture.md +54 -0
  49. package/adapters/opencode/agents/momentum-reviewer-qa.md +48 -0
  50. package/adapters/opencode/agents/momentum-reviewer-security.md +48 -0
  51. package/adapters/opencode/agents/swarm-supervisor.md +51 -0
  52. package/adapters/opencode/commands/continue.md +40 -0
  53. package/adapters/opencode/commands/dispatch.md +111 -0
  54. package/adapters/opencode/commands/handoff.md +57 -0
  55. package/adapters/opencode/commands/review-code.md +194 -0
  56. package/adapters/opencode/commands/scout.md +90 -0
  57. package/adapters/opencode/commands/swarm.md +334 -0
  58. package/adapters/opencode/instructions/AGENTS.md +604 -0
  59. package/adapters/opencode/instructions/header.md +3 -0
  60. package/adapters/opencode/instructions/surfaces.md +119 -0
  61. package/adapters/opencode/instructions/vars.json +1 -0
  62. package/adapters/opencode/plugins/momentum.js +219 -0
  63. package/adapters/opencode/skills/momentum-lanes/SKILL.md +30 -0
  64. package/adapters/opencode/skills/momentum-orient/SKILL.md +29 -0
  65. package/adapters/opencode/skills/momentum-track/SKILL.md +28 -0
  66. package/adapters/opencode/skills/momentum-validate/SKILL.md +31 -0
  67. package/bin/antigravity.js +112 -0
  68. package/bin/ecosystem.js +1302 -0
  69. package/bin/lanes.js +300 -0
  70. package/bin/momentum.js +1857 -0
  71. package/bin/okf.js +72 -0
  72. package/bin/orchestration-commands.js +305 -0
  73. package/bin/state-commands.js +218 -0
  74. package/bin/swarm.js +1104 -0
  75. package/bin/waves.js +147 -0
  76. package/core/adapter-capabilities.md +112 -0
  77. package/core/adapter-parity-matrix.md +170 -0
  78. package/core/commands/brainstorm-idea.md +98 -0
  79. package/core/commands/brainstorm-phase.md +129 -0
  80. package/core/commands/complete-phase.md +130 -0
  81. package/core/commands/ecosystem.md +161 -0
  82. package/core/commands/hotfix.md +64 -0
  83. package/core/commands/initiative.md +119 -0
  84. package/core/commands/lanes.md +94 -0
  85. package/core/commands/log.md +45 -0
  86. package/core/commands/migrate.md +70 -0
  87. package/core/commands/review.md +31 -0
  88. package/core/commands/session.md +79 -0
  89. package/core/commands/start-phase.md +161 -0
  90. package/core/commands/start-project.md +142 -0
  91. package/core/commands/sync-docs.md +81 -0
  92. package/core/commands/systematic-debug.md +46 -0
  93. package/core/commands/track.md +54 -0
  94. package/core/commands/validate.md +96 -0
  95. package/core/ecosystem/layout.md +128 -0
  96. package/core/ecosystem/lib/index.js +245 -0
  97. package/core/ecosystem/lib/initiative.js +258 -0
  98. package/core/ecosystem/lib/pointer.js +198 -0
  99. package/core/ecosystem/lib/state.js +295 -0
  100. package/core/ecosystem/schema/ecosystem.schema.json +85 -0
  101. package/core/ecosystem/schema/initiative.schema.json +61 -0
  102. package/core/ecosystem/scripts/session-append.sh +167 -0
  103. package/core/ecosystem/templates/ecosystem-agents.md +79 -0
  104. package/core/ecosystem/templates/ecosystem-claude.md +79 -0
  105. package/core/ecosystem/templates/ecosystem-settings-claude.json +14 -0
  106. package/core/ecosystem/templates/initiative-template.md +50 -0
  107. package/core/engines/subagent-dispatch.md +80 -0
  108. package/core/git-hooks/commit-msg +14 -0
  109. package/core/git-hooks/contract.js +176 -0
  110. package/core/git-hooks/pre-push +15 -0
  111. package/core/git-hooks/run-check.js +160 -0
  112. package/core/instructions/legacy-project-md-hashes.json +12 -0
  113. package/core/instructions/navigation.md +14 -0
  114. package/core/instructions/rules-body.md +454 -0
  115. package/core/lanes/lib/board.js +161 -0
  116. package/core/lanes/lib/land.js +301 -0
  117. package/core/lanes/lib/signals.js +207 -0
  118. package/core/lanes/lib/state.js +325 -0
  119. package/core/lib/frontmatter.js +163 -0
  120. package/core/lib/okf-migrate.js +374 -0
  121. package/core/lib/okf-types.js +61 -0
  122. package/core/lifecycle-contract.md +59 -0
  123. package/core/orchestration/capability-routing.js +120 -0
  124. package/core/orchestration/continue.js +102 -0
  125. package/core/orchestration/dispatch.js +366 -0
  126. package/core/orchestration/events.js +197 -0
  127. package/core/orchestration/handoff.js +317 -0
  128. package/core/orchestration/index.js +50 -0
  129. package/core/orchestration/run-artifact.js +93 -0
  130. package/core/orchestration/scout.js +327 -0
  131. package/core/orchestration/session-log.js +123 -0
  132. package/core/orchestration/tracking.js +180 -0
  133. package/core/orchestration/types.js +131 -0
  134. package/core/scripts/brainstorm-gate.sh +157 -0
  135. package/core/scripts/check-history-reminder.sh +110 -0
  136. package/core/scripts/sessionstart-handoff.sh +180 -0
  137. package/core/specs-templates/CLAUDE.md +484 -0
  138. package/core/specs-templates/README.md +32 -0
  139. package/core/specs-templates/specs/README.md +30 -0
  140. package/core/specs-templates/specs/adhoc/README.md +31 -0
  141. package/core/specs-templates/specs/adhoc/_TEMPLATE.md +29 -0
  142. package/core/specs-templates/specs/architecture/ecosystem.md +214 -0
  143. package/core/specs-templates/specs/backlog/backlog.md +46 -0
  144. package/core/specs-templates/specs/backlog/details/.gitkeep +0 -0
  145. package/core/specs-templates/specs/changelog/.gitkeep +0 -0
  146. package/core/specs-templates/specs/decisions/0000-template.md +33 -0
  147. package/core/specs-templates/specs/decisions/README.md +29 -0
  148. package/core/specs-templates/specs/decisions/impact-map.md +14 -0
  149. package/core/specs-templates/specs/decisions/index.md +4 -0
  150. package/core/specs-templates/specs/index.md +16 -0
  151. package/core/specs-templates/specs/phases/README.md +41 -0
  152. package/core/specs-templates/specs/phases/index.md +6 -0
  153. package/core/specs-templates/specs/planning/roadmap.md +23 -0
  154. package/core/specs-templates/specs/status.md +70 -0
  155. package/core/specs-templates/specs/vision/principles.md +16 -0
  156. package/core/specs-templates/specs/vision/project-charter.md +35 -0
  157. package/core/specs-templates/specs/vision/success-criteria.md +19 -0
  158. package/core/swarm/absorb.js +294 -0
  159. package/core/swarm/conductor.js +499 -0
  160. package/core/swarm/focus.js +151 -0
  161. package/core/swarm/inbox.js +222 -0
  162. package/core/swarm/join.js +138 -0
  163. package/core/swarm/lib/board.js +178 -0
  164. package/core/swarm/lib/brief.js +157 -0
  165. package/core/swarm/lib/git-sha-cache.js +71 -0
  166. package/core/swarm/lib/incremental-log.js +117 -0
  167. package/core/swarm/lib/manifest.js +578 -0
  168. package/core/swarm/lib/pre-merge.js +112 -0
  169. package/core/swarm/lib/saga.js +146 -0
  170. package/core/swarm/lib/sessions.js +83 -0
  171. package/core/swarm/lib/tokens.js +255 -0
  172. package/core/swarm/lib/wave-ordering.js +55 -0
  173. package/core/swarm/schema/board.schema.json +54 -0
  174. package/core/swarm/schema/contract.schema.json +56 -0
  175. package/core/swarm/schema/dispatch-run.schema.json +70 -0
  176. package/core/swarm/schema/manifest.schema.json +234 -0
  177. package/core/swarm/schema/signal.schema.json +61 -0
  178. package/core/swarm/signals.js +296 -0
  179. package/core/swarm/supervise.md +81 -0
  180. package/core/waves/lib/plan-graph.js +175 -0
  181. package/core/waves/lib/waves.js +83 -0
  182. package/package.json +46 -0
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ // continueHandoff — receiving-side counterpart to handoff.
4
+ //
5
+ // Reads a pending inbox file, parses the HandoffBlock, marks the file
6
+ // as read (move to .momentum/inbox/read/), and emits a [NOTE] in the
7
+ // receiving repo's active phase history. Returns the HandoffBlock so
8
+ // the agent or CLI can act on it.
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ const eventsLib = require('./events');
14
+ const handoffLib = require('./handoff');
15
+ const sessionLog = require('./session-log');
16
+
17
+ /**
18
+ * Pick up a pending handoff in the receiving repo.
19
+ *
20
+ * @param {object} opts
21
+ * @param {string} opts.repo absolute path to receiving repo
22
+ * @param {string} [opts.handoffId] pick a specific id; default: oldest pending
23
+ * @param {object} [opts.ecosystem]
24
+ * @param {object} [opts.emitter]
25
+ * @param {boolean} [opts.silent]
26
+ * @returns {Promise<HandoffBlock|null>} null when no pending handoff
27
+ */
28
+ async function continueHandoff(opts) {
29
+ const { repo, ecosystem } = opts;
30
+ if (!repo) throw new Error('orchestration/continue: opts.repo required');
31
+
32
+ const pending = handoffLib.listPending(repo);
33
+ if (pending.length === 0) return null;
34
+
35
+ let chosen;
36
+ if (opts.handoffId) {
37
+ const id = String(opts.handoffId).padStart(3, '0');
38
+ chosen = pending.find((p) => path.basename(p) === `handoff-${id}.md`);
39
+ if (!chosen) {
40
+ throw new Error(`orchestration/continue: handoff-${id}.md not found in ${repo}`);
41
+ }
42
+ } else {
43
+ chosen = pending[0];
44
+ }
45
+
46
+ const emitter = opts.emitter || eventsLib.createEmitter({ primitive: 'continue', repo });
47
+ if (!opts.emitter && !opts.silent) {
48
+ emitter.on(eventsLib.createRenderer({ stream: process.stdout }));
49
+ }
50
+ if (ecosystem) {
51
+ emitter.on(eventsLib.createPersister({
52
+ ecosystemRoot: ecosystem.rootPath,
53
+ memberId: ecosystem.memberId,
54
+ }));
55
+ }
56
+
57
+ const block = handoffLib.parseInbox(chosen);
58
+ emitter.emit('started', { message: `picking up ${path.basename(chosen)} from ${path.basename(block.fromRepo)}` });
59
+ const readPath = handoffLib.markRead(chosen);
60
+ emitter.emit('step', { message: `marked ${path.basename(readPath)} read` });
61
+
62
+ // Append a [NOTE] entry in the receiving repo's active phase history.
63
+ const activePhase = handoffLib.detectActivePhase(repo);
64
+ if (activePhase) {
65
+ appendReceivingNote({ repo, activePhase, block });
66
+ }
67
+
68
+ emitter.emit('finished', { runArtifactPath: readPath });
69
+ return block;
70
+ }
71
+
72
+ /**
73
+ * List pending handoffs (paths) in the given repo. Convenience wrapper
74
+ * over handoffLib.listPending so callers don't have to know about the
75
+ * inbox layout.
76
+ */
77
+ function listPending(repo) {
78
+ return handoffLib.listPending(repo);
79
+ }
80
+
81
+ function appendReceivingNote({ repo, activePhase, block }) {
82
+ const historyPath = path.join(repo, 'specs', 'phases', activePhase, 'history.md');
83
+ if (!fs.existsSync(historyPath)) return;
84
+ const date = new Date().toISOString().slice(0, 10);
85
+ const entry = [
86
+ '',
87
+ `### [NOTE] ${date} — Picked up handoff-${block.handoffId} from ${path.basename(block.fromRepo)}`,
88
+ `Topics: orchestration, handoff-pickup, handoff-${block.handoffId}`,
89
+ `Affects-phases: ${activePhase} (or "none")`,
90
+ `Affects-specs: ${path.relative(repo, path.join(repo, handoffLib.INBOX_DIRNAME, 'read', `handoff-${block.handoffId}.md`))}`,
91
+ `Detail: Received handoff from ${path.basename(block.fromRepo)}. Summary: ${block.summary}`,
92
+ '',
93
+ '---',
94
+ '',
95
+ ].join('\n');
96
+ fs.appendFileSync(historyPath, entry);
97
+ }
98
+
99
+ module.exports = {
100
+ continueHandoff,
101
+ listPending,
102
+ };
@@ -0,0 +1,366 @@
1
+ 'use strict';
2
+
3
+ // dispatch — parallel multi-repo fan-out + synthesis.
4
+ //
5
+ // In-process mode (CLI floor): runs scout against each repo. On
6
+ // `mode='parallel'` they run concurrently via Promise.all; on
7
+ // `mode='sequential'` they run one at a time and the user is told
8
+ // up front via a `started` event note. Either way, sub-agent failures
9
+ // are captured in `failures[]` — the call never throws.
10
+ //
11
+ // In-agent mode (slash command on Claude Code / Codex / Antigravity):
12
+ // the agent does the per-repo fan-out using its own sub-agent surface
13
+ // (Task tool on Claude Code, etc.), then calls record() with the
14
+ // structured results so the artifact + log line + tracking-contract
15
+ // hand-off all happen via the same code path as the CLI.
16
+ //
17
+ // Synthesis: in-process, the synthesizer concatenates per-repo
18
+ // summaries with a header — no LLM available from raw Node. Agent-
19
+ // driven dispatch produces a true synthesis in the originating agent's
20
+ // context, then calls record() with `synthesis` text included.
21
+
22
+ const path = require('node:path');
23
+
24
+ const eventsLib = require('./events');
25
+ const runArtifact = require('./run-artifact');
26
+ const sessionLog = require('./session-log');
27
+ const types = require('./types');
28
+ const routing = require('./capability-routing');
29
+ const scoutLib = require('./scout');
30
+
31
+ // ENH-034 — CLI / in-process dispatch degraded-mode notice. Emitted as
32
+ // the FIRST event before `started`, and rendered as a `> [!NOTE]`
33
+ // admonition inside the artifact + synthesis body. Three surfaces, one
34
+ // string so the message stays consistent everywhere agents/users see it.
35
+ const CLI_MODE_NOTICE =
36
+ 'MODE NOTICE: CLI mode produces keyword summaries only (no LLM synthesis). ' +
37
+ 'For full synthesis, invoke /dispatch as a slash command from an agent surface ' +
38
+ '(Claude Code Task tool, Codex sub-agent, etc.).';
39
+
40
+ /**
41
+ * Run a dispatch in-process across the given repos.
42
+ *
43
+ * @param {object} opts
44
+ * @param {string[]} opts.repos absolute paths to target repos
45
+ * @param {string} opts.userIntent high-level prompt
46
+ * @param {string} [opts.originatingRepo] defaults to first repo
47
+ * @param {object} [opts.adapter] adapter module for capability routing
48
+ * @param {object} [opts.ecosystem] { rootPath, memberId }
49
+ * @param {boolean} [opts.forceSequential] force sequential mode (testing aid)
50
+ * @param {boolean} [opts.silent]
51
+ * @returns {Promise<DispatchResult>}
52
+ */
53
+ async function dispatch(opts) {
54
+ const { repos, userIntent } = opts;
55
+ if (!Array.isArray(repos) || repos.length === 0) {
56
+ throw new Error('orchestration/dispatch: opts.repos must be a non-empty array');
57
+ }
58
+ if (!userIntent) {
59
+ throw new Error('orchestration/dispatch: opts.userIntent is required');
60
+ }
61
+ const originatingRepo = opts.originatingRepo || repos[0];
62
+
63
+ // Decide mode: explicit override > adapter capability > parallel default.
64
+ let mode = 'parallel';
65
+ let modeNotes = [];
66
+ if (opts.forceSequential) {
67
+ mode = 'sequential';
68
+ modeNotes.push('sequential mode forced via --sequential');
69
+ } else if (opts.adapter) {
70
+ const decision = routing.chooseMode(opts.adapter, 'dispatch');
71
+ mode = decision.mode;
72
+ modeNotes = decision.notes;
73
+ }
74
+
75
+ const start = Date.now();
76
+ const emitter = opts.emitter || eventsLib.createEmitter({ primitive: 'dispatch', repo: originatingRepo });
77
+ if (!opts.emitter && !opts.silent) {
78
+ emitter.on(eventsLib.createRenderer({ stream: process.stdout }));
79
+ }
80
+ if (opts.ecosystem) {
81
+ emitter.on(eventsLib.createPersister({
82
+ ecosystemRoot: opts.ecosystem.rootPath,
83
+ memberId: opts.ecosystem.memberId,
84
+ }));
85
+ }
86
+
87
+ // ENH-034 — surface the in-process degraded-mode notice UPFRONT
88
+ // (before the `started` event) so it is the literal first user-visible
89
+ // output line. Without this, agents and users were silently getting
90
+ // keyword-grep summaries and concluding "this is the answer."
91
+ //
92
+ // The `record()` path (agent-driven dispatch from a slash command)
93
+ // never reaches dispatch(), so this notice only fires for CLI /
94
+ // in-process callers — exactly where it's needed.
95
+ emitter.emit('note', { message: CLI_MODE_NOTICE });
96
+
97
+ emitter.emit('started', {
98
+ message: `${repos.length} repo(s): ${userIntent}`,
99
+ });
100
+ for (const note of modeNotes) {
101
+ emitter.emit('note', { message: note });
102
+ }
103
+
104
+ const perRepoResults = [];
105
+ const failures = [];
106
+
107
+ async function runOne(repo) {
108
+ emitter.emit('subagent-started', { repo });
109
+ const perRepoPrompt = tailorPrompt(userIntent, repo);
110
+ const t0 = Date.now();
111
+ try {
112
+ const scoutResult = await scoutLib.scout({
113
+ repo,
114
+ prompt: perRepoPrompt,
115
+ originatingRepo,
116
+ // Skip session log on sub-scouts so the ecosystem log isn't
117
+ // peppered with per-repo lines; the dispatch parent emits one
118
+ // line summarising the whole run.
119
+ ecosystem: null,
120
+ silent: true,
121
+ });
122
+ const entry = {
123
+ repo,
124
+ prompt: perRepoPrompt,
125
+ summary: scoutResult.summary,
126
+ findings: scoutResult.findings,
127
+ filesRead: scoutResult.filesRead,
128
+ duration: Date.now() - t0,
129
+ };
130
+ perRepoResults.push(entry);
131
+ emitter.emit('subagent-finished', { repo, duration: entry.duration });
132
+ return entry;
133
+ } catch (err) {
134
+ const entry = {
135
+ repo,
136
+ prompt: perRepoPrompt,
137
+ error: { message: err.message },
138
+ duration: Date.now() - t0,
139
+ };
140
+ perRepoResults.push(entry);
141
+ failures.push(entry);
142
+ emitter.emit('subagent-failed', { repo, error: err.message });
143
+ return entry;
144
+ }
145
+ }
146
+
147
+ if (mode === 'parallel') {
148
+ await Promise.all(repos.map(runOne));
149
+ } else {
150
+ for (const repo of repos) {
151
+ await runOne(repo);
152
+ }
153
+ }
154
+
155
+ const synthesis = synthesizeInProcess({ userIntent, perRepoResults, failures });
156
+ emitter.emit('synthesized', { summary: oneLineSynthesis(perRepoResults, failures) });
157
+
158
+ const duration = Date.now() - start;
159
+ const { runId, artifactPath } = runArtifact.writeWithAllocatedId({
160
+ repo: originatingRepo,
161
+ primitive: 'dispatch',
162
+ bodyFor: (id) => renderArtifact({ runId: id, repos, userIntent, mode, modeNotes, perRepoResults, failures, synthesis, duration }),
163
+ });
164
+ emitter.context.runId = runId;
165
+ emitter.emit('finished', { duration, runArtifactPath: artifactPath });
166
+
167
+ const result = {
168
+ repos,
169
+ userIntent,
170
+ mode,
171
+ modeNotes,
172
+ perRepoResults,
173
+ failures,
174
+ synthesis,
175
+ duration,
176
+ runArtifactPath: artifactPath,
177
+ runId,
178
+ };
179
+ return types.validateDispatchResult(result);
180
+ }
181
+
182
+ /**
183
+ * Record a dispatch performed by an external agent.
184
+ *
185
+ * The agent does its own sub-agent fan-out (e.g., Claude Code Task
186
+ * tool) and synthesis, then calls record() with the structured shape
187
+ * so the artifact + session log line + tracking-contract hand-off all
188
+ * happen via the same code path the CLI uses.
189
+ */
190
+ function record(opts) {
191
+ const {
192
+ repos,
193
+ userIntent,
194
+ mode = 'parallel',
195
+ modeNotes = [],
196
+ perRepoResults = [],
197
+ failures = [],
198
+ synthesis,
199
+ originatingRepo,
200
+ ecosystem,
201
+ duration = 0,
202
+ } = opts;
203
+ if (!originatingRepo) {
204
+ throw new Error('orchestration/dispatch.record: originatingRepo required');
205
+ }
206
+ const { runId, artifactPath } = runArtifact.writeWithAllocatedId({
207
+ repo: originatingRepo,
208
+ primitive: 'dispatch',
209
+ bodyFor: (id) => renderArtifact({ runId: id, repos, userIntent, mode, modeNotes, perRepoResults, failures, synthesis, duration, isAgentDriven: true }),
210
+ });
211
+ if (ecosystem && ecosystem.rootPath && ecosystem.memberId) {
212
+ sessionLog.appendLine({
213
+ ecosystemRoot: ecosystem.rootPath,
214
+ memberId: ecosystem.memberId,
215
+ kind: 'dispatch',
216
+ summary: `${repos.length} repos: ${userIntent}`,
217
+ context: `run-${runId} → ${path.relative(originatingRepo, artifactPath)}`,
218
+ });
219
+ }
220
+ // Tracking contract: each per-repo finding proposed as [DISCOVERY]
221
+ // in THAT repo's active phase history (only when meaningful per
222
+ // Rule 3). The synthesis is proposed as a [NOTE] in the
223
+ // ORIGINATING repo's active phase history when present.
224
+ const tracking = require('./tracking');
225
+ for (const perRepo of perRepoResults) {
226
+ if (!perRepo.findings) continue;
227
+ for (const finding of perRepo.findings) {
228
+ tracking.proposeDiscovery({ primitive: 'dispatch', finding, targetRepo: perRepo.repo });
229
+ }
230
+ }
231
+ if (synthesis) {
232
+ tracking.proposeHistoryNote({
233
+ primitive: 'dispatch',
234
+ originatingRepo,
235
+ message: `dispatch across ${repos.length} repo(s) for: ${userIntent}`,
236
+ runArtifactRef: path.relative(originatingRepo, artifactPath),
237
+ });
238
+ }
239
+ const result = {
240
+ repos, userIntent, mode, modeNotes,
241
+ perRepoResults, failures, synthesis,
242
+ duration, runArtifactPath: artifactPath, runId,
243
+ };
244
+ return types.validateDispatchResult(result);
245
+ }
246
+
247
+ // ── helpers ────────────────────────────────────────────────────────────────
248
+
249
+ function tailorPrompt(userIntent, repo) {
250
+ // In-process mode reuses the same prompt verbatim — the scout
251
+ // tokenizer is the limiting factor anyway. Agent-driven dispatch
252
+ // can produce repo-specific phrasing in the originating agent's
253
+ // context before calling record().
254
+ const repoLabel = path.basename(repo);
255
+ return `In repo ${repoLabel}: ${userIntent}`;
256
+ }
257
+
258
+ function synthesizeInProcess({ userIntent, perRepoResults, failures }) {
259
+ const successes = perRepoResults.filter((r) => !r.error);
260
+ // ENH-034 — admonition at the TOP of the synthesis block so anyone
261
+ // reading the in-process synthesis sees the mode caveat BEFORE the
262
+ // per-repo summaries (previously buried at the bottom as a paren).
263
+ const lines = [
264
+ '> [!NOTE]',
265
+ '> **MODE: CLI / in-process — keyword summaries only.**',
266
+ '> For LLM synthesis, invoke /dispatch via an agent slash command.',
267
+ '',
268
+ `Dispatch across ${perRepoResults.length} repo(s) for: ${userIntent}`,
269
+ '',
270
+ `- ${successes.length} succeeded, ${failures.length} failed.`,
271
+ ];
272
+ if (successes.length) {
273
+ lines.push('', '## Per-repo summaries');
274
+ for (const r of successes) {
275
+ const repoLabel = path.basename(r.repo);
276
+ lines.push(`- **${repoLabel}** (${r.duration}ms): ${truncateForSynth(r.summary)}`);
277
+ }
278
+ }
279
+ if (failures.length) {
280
+ lines.push('', '## Failures');
281
+ for (const f of failures) {
282
+ const repoLabel = path.basename(f.repo);
283
+ lines.push(`- **${repoLabel}**: ${f.error && f.error.message ? f.error.message : 'unknown error'}`);
284
+ }
285
+ }
286
+ return lines.join('\n');
287
+ }
288
+
289
+ function oneLineSynthesis(perRepoResults, failures) {
290
+ const okCount = perRepoResults.length - failures.length;
291
+ return `${okCount}/${perRepoResults.length} succeeded${failures.length ? `, ${failures.length} failed` : ''}`;
292
+ }
293
+
294
+ function truncateForSynth(s) {
295
+ if (!s) return '(empty)';
296
+ if (s.length <= 200) return s.replace(/\n+/g, ' ');
297
+ return s.slice(0, 200).replace(/\n+/g, ' ') + '…';
298
+ }
299
+
300
+ function renderArtifact({ runId, repos, userIntent, mode, modeNotes, perRepoResults, failures, synthesis, duration, isAgentDriven = false }) {
301
+ // ENH-034 — admonition immediately under the Mode header so anyone
302
+ // opening the artifact sees the CLI-degradation caveat before reading
303
+ // the synthesis. Skip on agent-driven `record()` runs — those carry
304
+ // a real LLM synthesis so the caveat would mislead.
305
+ const modeAdmonition = isAgentDriven
306
+ ? []
307
+ : [
308
+ '> [!NOTE]',
309
+ '> **CLI / in-process mode — keyword summaries only.**',
310
+ '> Invoke /dispatch via an agent slash command for true LLM synthesis.',
311
+ '',
312
+ ];
313
+ const lines = [
314
+ `# dispatch-${runId}`,
315
+ '',
316
+ `**User intent:** ${userIntent}`,
317
+ `**Mode:** ${mode}${modeNotes.length ? ` (${modeNotes.join('; ')})` : ''}`,
318
+ `**Repos (${repos.length}):**`,
319
+ ...repos.map((r) => ` - ${r}`),
320
+ `**Duration:** ${duration}ms`,
321
+ '',
322
+ ...modeAdmonition,
323
+ '## Synthesis',
324
+ '',
325
+ synthesis || '(no synthesis recorded)',
326
+ '',
327
+ '## Per-repo results',
328
+ '',
329
+ ];
330
+ for (const r of perRepoResults) {
331
+ const repoLabel = path.basename(r.repo);
332
+ lines.push(`### ${repoLabel}`, '');
333
+ lines.push(`- **Prompt:** ${r.prompt || '(n/a)'}`);
334
+ lines.push(`- **Duration:** ${r.duration != null ? r.duration : 'n/a'}ms`);
335
+ if (r.error) {
336
+ lines.push(`- **Error:** ${r.error.message}`);
337
+ } else {
338
+ lines.push(`- **Files read:** ${(r.filesRead || []).length}`);
339
+ lines.push('', '```', r.summary || '(empty)', '```');
340
+ }
341
+ if (r.findings && r.findings.length) {
342
+ lines.push('', 'Findings:');
343
+ for (const f of r.findings) {
344
+ lines.push(` - **${f.kind || 'note'}**: ${f.title}`);
345
+ }
346
+ }
347
+ lines.push('');
348
+ }
349
+ if (failures.length) {
350
+ lines.push('## Failure manifest', '');
351
+ for (const f of failures) {
352
+ lines.push(`- ${path.basename(f.repo)}: ${f.error && f.error.message ? f.error.message : 'unknown'}`);
353
+ }
354
+ lines.push('');
355
+ }
356
+ return lines.join('\n');
357
+ }
358
+
359
+ module.exports = {
360
+ dispatch,
361
+ record,
362
+ CLI_MODE_NOTICE,
363
+ // exported for tests:
364
+ tailorPrompt,
365
+ synthesizeInProcess,
366
+ };
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ // Typed event emitter for orchestration primitives.
4
+ //
5
+ // Every primitive (scout, dispatch, handoff) emits structured events as
6
+ // it runs. Two default subscribers:
7
+ // - renderer: prints `▸ <message>` lines to stdout (chat surface).
8
+ // - persister: appends the same events to the ecosystem session log
9
+ // (durable audit surface).
10
+ //
11
+ // One source, two surfaces — keeps live narration and the persistent
12
+ // log in lockstep. New event types are listed in EVENT_KINDS; adding
13
+ // one requires updating that list AND any rendering / persistence code
14
+ // that depends on the kind.
15
+
16
+ const EVENT_KINDS = Object.freeze([
17
+ // Primitive lifecycle
18
+ 'started',
19
+ 'finished',
20
+ 'failed',
21
+
22
+ // Step-level progress (file reads, sub-agent spawns, etc.)
23
+ 'step',
24
+ 'note', // free-form info that should land in chat + log
25
+
26
+ // Sub-agent lifecycle (used by dispatch)
27
+ 'subagent-started',
28
+ 'subagent-finished',
29
+ 'subagent-failed',
30
+
31
+ // Result-level events
32
+ 'finding', // emitted by scout/dispatch when a notable finding is identified
33
+ 'synthesized', // emitted by dispatch after synthesis completes
34
+ ]);
35
+
36
+ function isValidKind(kind) {
37
+ return EVENT_KINDS.includes(kind);
38
+ }
39
+
40
+ /**
41
+ * Create a new event emitter for a single primitive invocation.
42
+ *
43
+ * @param {object} opts
44
+ * @param {string} opts.primitive one of: 'scout' | 'dispatch' | 'handoff' | 'continue'
45
+ * @param {string} [opts.runId] monotonic per-primitive run id (set later by run-artifact)
46
+ * @param {string} [opts.repo] originating repo path
47
+ * @returns {{ emit: Function, on: Function, kinds: string[], context: object }}
48
+ */
49
+ function createEmitter(opts = {}) {
50
+ const subscribers = [];
51
+ const context = { ...opts };
52
+
53
+ function emit(kind, payload = {}) {
54
+ if (!isValidKind(kind)) {
55
+ throw new Error(
56
+ `orchestration/events: unknown event kind "${kind}". ` +
57
+ `Valid kinds: ${EVENT_KINDS.join(', ')}`,
58
+ );
59
+ }
60
+ const event = {
61
+ kind,
62
+ timestamp: new Date().toISOString(),
63
+ primitive: context.primitive,
64
+ runId: context.runId,
65
+ repo: context.repo,
66
+ ...payload,
67
+ };
68
+ for (const subscriber of subscribers) {
69
+ try {
70
+ subscriber(event);
71
+ } catch (err) {
72
+ // Subscribers MUST NOT throw — but if one does, log and continue
73
+ // rather than letting it break the primitive.
74
+ process.stderr.write(
75
+ `orchestration/events: subscriber error: ${err.message}\n`,
76
+ );
77
+ }
78
+ }
79
+ return event;
80
+ }
81
+
82
+ function on(handler) {
83
+ if (typeof handler !== 'function') {
84
+ throw new TypeError('orchestration/events: subscriber must be a function');
85
+ }
86
+ subscribers.push(handler);
87
+ return () => {
88
+ const idx = subscribers.indexOf(handler);
89
+ if (idx >= 0) subscribers.splice(idx, 1);
90
+ };
91
+ }
92
+
93
+ return { emit, on, kinds: EVENT_KINDS, context };
94
+ }
95
+
96
+ /**
97
+ * Default renderer subscriber — prints `▸ <message>` lines to a stream
98
+ * (default: stdout). Mostly for the CLI doors; in-agent contexts
99
+ * synthesize their own rendering layer.
100
+ *
101
+ * @param {object} [opts]
102
+ * @param {NodeJS.WritableStream} [opts.stream] default: process.stdout
103
+ * @param {boolean} [opts.silent] if true, no output (useful for tests)
104
+ * @returns {Function} subscriber
105
+ */
106
+ function createRenderer(opts = {}) {
107
+ const stream = opts.stream || process.stdout;
108
+ return function rendererSubscriber(event) {
109
+ if (opts.silent) return;
110
+ const line = renderEvent(event);
111
+ if (line) stream.write(line + '\n');
112
+ };
113
+ }
114
+
115
+ function renderEvent(event) {
116
+ switch (event.kind) {
117
+ case 'started':
118
+ return `▸ ${event.primitive}${event.repo ? ` ${event.repo}` : ''}${
119
+ event.message ? `: ${event.message}` : ''
120
+ }`;
121
+ case 'finished':
122
+ return `▸ ${event.primitive} done${event.duration != null ? ` (${event.duration}ms)` : ''}${
123
+ event.runArtifactPath ? ` → ${event.runArtifactPath}` : ''
124
+ }`;
125
+ case 'failed':
126
+ return `▸ ${event.primitive} FAILED${event.error ? `: ${event.error}` : ''}`;
127
+ case 'step':
128
+ return ` ▸ ${event.message || ''}`;
129
+ case 'note':
130
+ return ` ▸ ${event.message || ''}`;
131
+ case 'subagent-started':
132
+ return ` ▸ ${event.repo}…`;
133
+ case 'subagent-finished':
134
+ return ` ▸ ${event.repo}… done${event.duration != null ? ` (${event.duration}ms)` : ''}`;
135
+ case 'subagent-failed':
136
+ return ` ▸ ${event.repo}… FAILED${event.error ? `: ${event.error}` : ''}`;
137
+ case 'finding':
138
+ return ` ▸ found: ${event.summary || event.message || ''}`;
139
+ case 'synthesized':
140
+ return event.message ? `▸ Synthesis\n${event.message}` : '▸ Synthesis';
141
+ default:
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Default persister subscriber — appends events to the ecosystem
148
+ * session log. Uses the shared session-log writer (lock-aware) so we
149
+ * don't race with PostToolUse hook commits.
150
+ *
151
+ * @param {object} opts
152
+ * @param {string} opts.ecosystemRoot ecosystem root path
153
+ * @param {string} opts.memberId member id of the originating repo
154
+ * @returns {Function} subscriber
155
+ */
156
+ function createPersister(opts) {
157
+ const { appendLine } = require('./session-log');
158
+ return function persisterSubscriber(event) {
159
+ if (!opts || !opts.ecosystemRoot || !opts.memberId) return;
160
+ const line = persistEvent(event);
161
+ if (!line) return;
162
+ appendLine({
163
+ ecosystemRoot: opts.ecosystemRoot,
164
+ memberId: opts.memberId,
165
+ kind: event.primitive,
166
+ summary: line,
167
+ context: event.runId ? `run-${event.runId}` : '',
168
+ });
169
+ };
170
+ }
171
+
172
+ function persistEvent(event) {
173
+ // Only persist primitive-level lifecycle events; step-level events
174
+ // would flood the session log.
175
+ switch (event.kind) {
176
+ case 'started':
177
+ return `${event.primitive} ${event.repo || ''}${event.message ? `: ${event.message}` : ''}`.trim();
178
+ case 'finished':
179
+ return `${event.primitive} done${event.runArtifactPath ? ` → ${event.runArtifactPath}` : ''}`;
180
+ case 'failed':
181
+ return `${event.primitive} FAILED${event.error ? `: ${event.error}` : ''}`;
182
+ case 'synthesized':
183
+ return event.summary ? `${event.primitive} synthesis: ${event.summary}` : null;
184
+ default:
185
+ return null;
186
+ }
187
+ }
188
+
189
+ module.exports = {
190
+ EVENT_KINDS,
191
+ isValidKind,
192
+ createEmitter,
193
+ createRenderer,
194
+ createPersister,
195
+ renderEvent, // exported for tests
196
+ persistEvent, // exported for tests
197
+ };