@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,301 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `momentum lanes land` — merge queue with graded evidence gates
5
+ * (Phase 21b G5, FEAT-027 — see ADR-0002 §4).
6
+ *
7
+ * Validate-first: without --execute the command only reports the
8
+ * checklist. Checks, in order:
9
+ * 1. lane exists and is marked `done`
10
+ * 2. turn — FIFO by doneAt among done lanes (--force overrides, loudly)
11
+ * 3. freshness — the integration ref must be an ancestor of the lane
12
+ * branch (the lane already rebased/merged it); never forceable
13
+ * 4. graded evidence gate (Rule 14 → Rule 12 lineage):
14
+ * spike → none
15
+ * quick-task → specs/adhoc/<lane-id>/record.md exists
16
+ * phase → specs/phases/<planNode.ref>/retrospective.md has a
17
+ * non-empty "## Verification Evidence" section
18
+ * 5. touch-overlap re-warning (advisory)
19
+ *
20
+ * Gate evidence is read from the LANE BRANCH first (`git show
21
+ * <branch>:<path>` — a record committed on the lane arrives WITH the
22
+ * merge, so the invoking worktree can't see it beforehand; ENH-048),
23
+ * falling back to the invoking worktree's filesystem for the 21b G5
24
+ * pattern of uncommitted records.
25
+ *
26
+ * --execute merges (--no-ff) into the CURRENT branch (which must equal
27
+ * --into when given), marks the lane `landed`, and drops an advisory
28
+ * `message` signal into every other open lane's inbox. It never pushes —
29
+ * protected-branch pushes stay behind the Phase-19 hooks + operator
30
+ * approval.
31
+ *
32
+ * --mark-landed is bookkeeping only (ENH-048): when the merge already
33
+ * happened out-of-band, it records `landed` + landedAt and sends the
34
+ * advisory nudges — no turn/freshness/gate checks, no merge. Requires
35
+ * status `done` and the lane branch already merged into HEAD.
36
+ */
37
+
38
+ const fs = require('fs');
39
+ const os = require('os');
40
+ const path = require('path');
41
+ const { spawnSync } = require('child_process');
42
+
43
+ const state = require('./state');
44
+
45
+ function git(cwd, ...args) {
46
+ return spawnSync('git', args, { cwd, encoding: 'utf8' });
47
+ }
48
+
49
+ function parseFlags(argv) {
50
+ const flags = {};
51
+ const positional = [];
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const a = argv[i];
54
+ if (a === '--execute' || a === '--force' || a === '--json' || a === '--mark-landed') flags[a.slice(2)] = true;
55
+ else if (a.startsWith('--')) flags[a.slice(2)] = argv[++i];
56
+ else positional.push(a);
57
+ }
58
+ return { flags, positional };
59
+ }
60
+
61
+ /** Minimal advisory signal write — inline so land.js has no module coupling. */
62
+ function writeAdvisorySignal(anchor, laneId, text, from) {
63
+ const inbox = state.inboxDir(anchor, laneId);
64
+ const processed = state.processedDir(anchor, laneId);
65
+ state.withLock(anchor, () => {
66
+ fs.mkdirSync(inbox, { recursive: true });
67
+ const seqs = [];
68
+ for (const dir of [inbox, processed]) {
69
+ let names = [];
70
+ try { names = fs.readdirSync(dir); } catch { /* absent */ }
71
+ for (const n of names) {
72
+ const m = n.match(/^(\d{4})-/);
73
+ if (m) seqs.push(Number(m[1]));
74
+ }
75
+ }
76
+ const seq = (seqs.length ? Math.max(...seqs) : 0) + 1;
77
+ const file = path.join(inbox, `${String(seq).padStart(4, '0')}-message.json`);
78
+ const tmp = `${file}.${process.pid}.tmp`;
79
+ fs.writeFileSync(tmp, JSON.stringify({
80
+ stateVersion: state.STATE_VERSION,
81
+ seq,
82
+ type: 'message',
83
+ text,
84
+ from,
85
+ at: new Date().toISOString(),
86
+ }, null, 2) + os.EOL);
87
+ fs.renameSync(tmp, file);
88
+ });
89
+ }
90
+
91
+ /** Blob content at `<branch>:<relPath>` (repo-root-relative), or null when absent. */
92
+ function readFromBranch(cwd, branch, relPath) {
93
+ const res = git(cwd, 'show', `${branch}:${relPath}`);
94
+ return res.status === 0 ? res.stdout : null;
95
+ }
96
+
97
+ /** Trimmed "## Verification Evidence" section body, or null when missing/empty. */
98
+ function evidenceSection(body) {
99
+ const m = body.match(/^## Verification Evidence\s*$([\s\S]*?)(?=^## |\s*$(?![\s\S]))/m);
100
+ return (m && m[1] && m[1].trim()) || null;
101
+ }
102
+
103
+ /**
104
+ * Gate check per ADR-0002 §4. Returns { ok, detail }.
105
+ *
106
+ * Evidence is read from the LANE BRANCH first (ENH-048 — a record
107
+ * committed on the lane only arrives in the integration worktree WITH
108
+ * the merge), falling back to the invoking worktree's filesystem for
109
+ * the 21b G5 pattern of uncommitted records. `cwd` is where git runs.
110
+ */
111
+ function gateCheck(cwd, repoRoot, lane) {
112
+ if (lane.grade === 'spike') {
113
+ return { ok: true, detail: 'spike — gate-exempt by declaration' };
114
+ }
115
+ if (lane.grade === 'quick-task') {
116
+ const rel = `specs/adhoc/${lane.id}/record.md`;
117
+ const fromBranch = readFromBranch(cwd, lane.branch, rel);
118
+ if (fromBranch && fromBranch.trim()) {
119
+ return { ok: true, detail: `ad-hoc record present (${rel}, read from the lane branch)` };
120
+ }
121
+ const rec = path.join(repoRoot, 'specs', 'adhoc', lane.id, 'record.md');
122
+ return fs.existsSync(rec)
123
+ ? { ok: true, detail: `ad-hoc record present (${rel})` }
124
+ : { ok: false, detail: `missing ad-hoc record: ${rel} — not on '${lane.branch}' nor in the worktree (Rule 14 quick-task evidence)` };
125
+ }
126
+ // phase grade
127
+ const ref = (lane.planNode && lane.planNode.ref) || lane.id;
128
+ const rel = `specs/phases/${ref}/retrospective.md`;
129
+ const fromBranch = readFromBranch(cwd, lane.branch, rel);
130
+ if (fromBranch !== null) {
131
+ const section = evidenceSection(fromBranch);
132
+ if (section) {
133
+ return { ok: true, detail: `retrospective Verification Evidence present (${section.length} chars, read from the lane branch)` };
134
+ }
135
+ // present on the branch but section missing/empty — the worktree copy may still carry it
136
+ }
137
+ const retro = path.join(repoRoot, 'specs', 'phases', ref, 'retrospective.md');
138
+ if (!fs.existsSync(retro)) {
139
+ return fromBranch !== null
140
+ ? { ok: false, detail: `retrospective on '${lane.branch}' exists but "## Verification Evidence" is missing or empty (${rel})` }
141
+ : { ok: false, detail: `missing retrospective: ${rel} (Rule 12 phase evidence)` };
142
+ }
143
+ const section = evidenceSection(fs.readFileSync(retro, 'utf8'));
144
+ return section
145
+ ? { ok: true, detail: `retrospective Verification Evidence present (${section.length} chars)` }
146
+ : { ok: false, detail: `retrospective exists but "## Verification Evidence" is missing or empty (${rel})` };
147
+ }
148
+
149
+ function cmdLand(cwd, argv) {
150
+ const { flags, positional } = parseFlags(argv);
151
+ const id = positional[0];
152
+ if (!id) {
153
+ console.error('✗ usage: momentum lanes land <lane-id> [--into <ref>] [--execute] [--force] [--mark-landed]');
154
+ return 1;
155
+ }
156
+ const anchor = state.resolveAnchor(cwd);
157
+ if (!anchor) {
158
+ console.error('✗ not inside a git repository');
159
+ return 1;
160
+ }
161
+ const repoRoot = state.worktreeRoot(cwd);
162
+ const lane = state.readManifest(anchor, id);
163
+ if (!lane) {
164
+ console.error(`✗ no such lane: '${id}'`);
165
+ return 1;
166
+ }
167
+
168
+ const currentBranch = (git(cwd, 'rev-parse', '--abbrev-ref', 'HEAD').stdout || '').trim();
169
+ const into = flags.into || currentBranch;
170
+
171
+ // ENH-050: `land` merges into the CURRENT branch — invoking it from the
172
+ // lane's own worktree would merge the lane onto itself and mark it landed
173
+ // while the integration branch got nothing (observed live, twice).
174
+ if (into === lane.branch) {
175
+ console.error(`✗ refusing to land '${id}' onto its own branch ('${into}') — run this from the integration branch's checkout (e.g. main): cd <primary checkout> && momentum lanes land ${id}${flags.execute ? ' --execute' : ''}`);
176
+ return 1;
177
+ }
178
+
179
+ // ── --mark-landed: bookkeeping for a merge that already happened
180
+ // out-of-band (ENH-048). No turn/freshness/gate checks, no merge.
181
+ if (flags['mark-landed']) {
182
+ if (lane.status !== 'done') {
183
+ console.error(`✗ --mark-landed requires status 'done' (lane '${id}' is '${lane.status}') — mark it first: momentum lanes done ${id}`);
184
+ return 1;
185
+ }
186
+ const merged = git(cwd, 'merge-base', '--is-ancestor', lane.branch, 'HEAD').status === 0;
187
+ if (!merged) {
188
+ console.error(`✗ --mark-landed: '${lane.branch}' is not merged into HEAD — this flag only RECORDS an out-of-band merge that already happened (to actually merge: momentum lanes land ${id} --execute)`);
189
+ return 1;
190
+ }
191
+ const landed = state.updateLane(anchor, id, {
192
+ status: 'landed',
193
+ landedAt: new Date().toISOString(),
194
+ note: `${lane.note ? lane.note + '; ' : ''}marked landed out-of-band (--mark-landed)`,
195
+ });
196
+ const others = state.listLanes(anchor).filter((l) => l.id !== id && l.status === 'open');
197
+ for (const other of others) {
198
+ writeAdvisorySignal(anchor, other.id,
199
+ `lane '${id}' landed on ${currentBranch} — rebase your lane before landing (git rebase ${currentBranch})`,
200
+ currentBranch);
201
+ }
202
+ console.log(`✓ lane '${id}' marked landed (${landed.landedAt}) — bookkeeping only, the merge was already in HEAD`);
203
+ if (others.length) {
204
+ console.log(`ℹ advisory rebase signal sent to ${others.length} open lane(s): ${others.map((l) => l.id).join(', ')}`);
205
+ }
206
+ return 0;
207
+ }
208
+
209
+ const checks = [];
210
+ let ok = true;
211
+
212
+ // 1. status
213
+ if (lane.status === 'done') {
214
+ checks.push(`✓ status: done (since ${lane.doneAt})`);
215
+ } else {
216
+ checks.push(`✗ status: '${lane.status}' — mark it first: momentum lanes done ${id}`);
217
+ ok = false;
218
+ }
219
+
220
+ // 2. turn (FIFO by doneAt)
221
+ const queue = state.listLanes(anchor)
222
+ .filter((l) => l.status === 'done')
223
+ .sort((a, b) => String(a.doneAt).localeCompare(String(b.doneAt)));
224
+ const pos = queue.findIndex((l) => l.id === id);
225
+ if (pos === 0 || queue.length === 0) {
226
+ checks.push('✓ turn: head of the landing queue');
227
+ } else if (pos > 0 && flags.force) {
228
+ checks.push(`⚠ turn: position ${pos + 1} of ${queue.length} — landing OUT OF TURN (--force; ahead: ${queue.slice(0, pos).map((l) => l.id).join(', ')})`);
229
+ } else if (pos > 0) {
230
+ checks.push(`✗ turn: position ${pos + 1} of ${queue.length} — '${queue[0].id}' lands first (or use --force, loudly)`);
231
+ ok = false;
232
+ }
233
+
234
+ // 3. freshness — `into` must be an ancestor of the lane branch
235
+ const fresh = git(cwd, 'merge-base', '--is-ancestor', into, lane.branch).status === 0;
236
+ if (fresh) {
237
+ checks.push(`✓ freshness: '${into}' is contained in '${lane.branch}'`);
238
+ } else {
239
+ checks.push(`✗ freshness: '${lane.branch}' does not contain '${into}' — rebase the lane first (git rebase ${into}); freshness is never forceable`);
240
+ ok = false;
241
+ }
242
+
243
+ // 4. graded gate
244
+ const gate = gateCheck(cwd, repoRoot, lane);
245
+ checks.push(`${gate.ok ? '✓' : '✗'} gate[${lane.grade}]: ${gate.detail}`);
246
+ if (!gate.ok) ok = false;
247
+
248
+ // 5. overlap advisory
249
+ for (const w of state.overlapWarnings(anchor, id, lane.touches)) {
250
+ checks.push(`⚠ touch overlap with '${w.laneId}': ${w.mine} ↔ ${w.theirs} (advisory)`);
251
+ }
252
+
253
+ console.log(`landing checklist for '${id}' → ${into}:`);
254
+ for (const c of checks) console.log(` ${c}`);
255
+
256
+ if (!ok) {
257
+ console.error(`✗ lane '${id}' is not landable`);
258
+ return 1;
259
+ }
260
+
261
+ if (!flags.execute) {
262
+ console.log(`✓ landable — run with --execute to merge into '${into}'`);
263
+ return 0;
264
+ }
265
+
266
+ if (into !== currentBranch) {
267
+ console.error(`✗ --execute merges into the CURRENT branch ('${currentBranch}') but --into is '${into}' — check out '${into}' first`);
268
+ return 1;
269
+ }
270
+
271
+ const merge = git(cwd, 'merge', '--no-ff', lane.branch, '-m',
272
+ `merge: land lane '${id}' → ${into} (momentum lanes)`);
273
+ if (merge.status !== 0) {
274
+ console.error(`✗ merge failed:\n${merge.stderr || merge.stdout}`);
275
+ console.error(' resolve manually, or abort with: git merge --abort');
276
+ return 1;
277
+ }
278
+
279
+ const landed = state.updateLane(anchor, id, {
280
+ status: 'landed',
281
+ landedAt: new Date().toISOString(),
282
+ note: flags.force ? `${lane.note ? lane.note + '; ' : ''}landed out of turn (--force)` : lane.note,
283
+ });
284
+
285
+ // Advisory rebase nudge to every other open lane (ADR-0002 — advisory only).
286
+ const others = state.listLanes(anchor).filter((l) => l.id !== id && l.status === 'open');
287
+ for (const other of others) {
288
+ writeAdvisorySignal(anchor, other.id,
289
+ `lane '${id}' landed on ${into} — rebase your lane before landing (git rebase ${into})`,
290
+ currentBranch);
291
+ }
292
+
293
+ console.log(`✓ lane '${id}' landed on '${into}' (${landed.landedAt})`);
294
+ if (others.length) {
295
+ console.log(`ℹ advisory rebase signal sent to ${others.length} open lane(s): ${others.map((l) => l.id).join(', ')}`);
296
+ }
297
+ console.log('ℹ run the suite on the updated branch before the next landing (Rule 6 Landing Order)');
298
+ return 0;
299
+ }
300
+
301
+ module.exports = { cmdLand, gateCheck };
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `momentum lanes signal` / `momentum lanes inbox` — cross-session lane
5
+ * signaling (Phase 21b G4, FEAT-026 — see ADR-0002).
6
+ *
7
+ * A signal is one JSON file written under the state lock into the lane's
8
+ * inbox at the shared git dir:
9
+ *
10
+ * <git-common-dir>/momentum/lanes/<id>/inbox/NNNN-<type>.json
11
+ *
12
+ * Because the anchor lives in the git common dir, a signal written from
13
+ * ANY worktree (or session) is readable from any other — no daemon, no
14
+ * sockets, just files. Ack moves the file to inbox/processed/; seqs are
15
+ * monotonic across BOTH unread and processed history, so acking never
16
+ * causes a seq to be reused.
17
+ *
18
+ * Contract with bin/lanes.js: print our own errors, return 1 on failure,
19
+ * undefined on success. Zero dependencies — node builtins only.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const path = require('path');
25
+ const { spawnSync } = require('child_process');
26
+
27
+ const state = require('./state');
28
+
29
+ const SEQ_WIDTH = 4;
30
+
31
+ // ─── helpers ─────────────────────────────────────────────────────────────
32
+
33
+ function fail(msg) {
34
+ console.error(`✗ ${msg}`);
35
+ return 1;
36
+ }
37
+
38
+ /** Current branch of the sender's worktree; 'unknown' outside a repo. */
39
+ function currentBranch(cwd) {
40
+ const res = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
41
+ cwd, encoding: 'utf8',
42
+ });
43
+ if (res.status !== 0) return 'unknown';
44
+ return res.stdout.trim() || 'unknown';
45
+ }
46
+
47
+ /** Local copy of the state.js writeJsonAtomic idiom (not exported there). */
48
+ function writeJsonAtomic(file, obj) {
49
+ fs.mkdirSync(path.dirname(file), { recursive: true });
50
+ const tmp = path.join(
51
+ path.dirname(file),
52
+ `.${path.basename(file)}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`
53
+ );
54
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + os.EOL);
55
+ fs.renameSync(tmp, file);
56
+ }
57
+
58
+ /** Leading-4-digit seqs of *.json filenames in dir; [] when unreadable. */
59
+ function seqsIn(dir) {
60
+ let names;
61
+ try {
62
+ names = fs.readdirSync(dir);
63
+ } catch {
64
+ return [];
65
+ }
66
+ return names
67
+ .filter((n) => n.endsWith('.json'))
68
+ .map((n) => (/^(\d{4})-/.exec(n) || [])[1])
69
+ .filter(Boolean)
70
+ .map(Number);
71
+ }
72
+
73
+ /** Next seq = 1 + max across unread AND processed (0 when none). */
74
+ function nextSeq(anchor, id) {
75
+ const all = [
76
+ ...seqsIn(state.inboxDir(anchor, id)),
77
+ ...seqsIn(state.processedDir(anchor, id)),
78
+ ];
79
+ return (all.length ? Math.max(...all) : 0) + 1;
80
+ }
81
+
82
+ function pad(seq) {
83
+ return String(seq).padStart(SEQ_WIDTH, '0');
84
+ }
85
+
86
+ /** Seq of an unread entry — body when sane, filename digits as fallback. */
87
+ function seqOf(sig) {
88
+ if (Number.isInteger(sig.seq)) return sig.seq;
89
+ const m = /^(\d{4})-/.exec(sig.file || '');
90
+ return m ? Number(m[1]) : NaN;
91
+ }
92
+
93
+ // ─── momentum lanes signal <id> <type> [text ...] ────────────────────────
94
+
95
+ function cmdSignal(cwd, argv) {
96
+ const usage = 'usage: momentum lanes signal <lane-id> <pause|resume|redirect|kill|message> [text ...]';
97
+ const [id, type, ...rest] = argv || [];
98
+ if (!id || !type) return fail(usage);
99
+
100
+ const anchor = state.resolveAnchor(cwd);
101
+ if (!anchor) return fail('not inside a git repository');
102
+ if (!state.readManifest(anchor, id)) return fail(`no such lane: '${id}'`);
103
+ if (!state.SIGNAL_TYPES.includes(type)) {
104
+ return fail(`invalid signal type '${type}' (expected ${state.SIGNAL_TYPES.join('|')})`);
105
+ }
106
+
107
+ const text = rest.join(' ').trim();
108
+ if (!text && (type === 'redirect' || type === 'message')) {
109
+ console.log(`⚠ '${type}' usually carries text — writing the signal without any`);
110
+ }
111
+
112
+ let name;
113
+ try {
114
+ state.withLock(anchor, () => {
115
+ const seq = nextSeq(anchor, id);
116
+ name = `${pad(seq)}-${type}`;
117
+ writeJsonAtomic(path.join(state.inboxDir(anchor, id), `${name}.json`), {
118
+ stateVersion: state.STATE_VERSION,
119
+ seq,
120
+ type,
121
+ text: text || null,
122
+ from: currentBranch(cwd),
123
+ at: new Date().toISOString(),
124
+ });
125
+ });
126
+ } catch (err) {
127
+ return fail(err.message);
128
+ }
129
+ console.log(`✓ signal ${name} → lane '${id}'`);
130
+ }
131
+
132
+ // ─── momentum lanes inbox <id> [--ack <seq>|--ack-all] ───────────────────
133
+
134
+ function cmdInbox(cwd, argv) {
135
+ const usage = 'usage: momentum lanes inbox <lane-id> [--ack <seq>|--ack-all]';
136
+ let id = null;
137
+ let ack = null;
138
+ let ackAll = false;
139
+ const args = argv || [];
140
+ for (let i = 0; i < args.length; i++) {
141
+ const a = args[i];
142
+ if (a === '--ack-all') {
143
+ ackAll = true;
144
+ } else if (a === '--ack') {
145
+ const v = args[++i];
146
+ if (v === undefined || !/^\d+$/.test(v)) return fail(`--ack needs a numeric seq — ${usage}`);
147
+ ack = Number(v);
148
+ } else if (a.startsWith('--')) {
149
+ return fail(`unknown flag '${a}' — ${usage}`);
150
+ } else if (!id) {
151
+ id = a;
152
+ } else {
153
+ return fail(`unexpected argument '${a}' — ${usage}`);
154
+ }
155
+ }
156
+ if (!id) return fail(usage);
157
+
158
+ const anchor = state.resolveAnchor(cwd);
159
+ if (!anchor) return fail('not inside a git repository');
160
+ if (!state.readManifest(anchor, id)) return fail(`no such lane: '${id}'`);
161
+
162
+ // ── ack path ──
163
+ if (ack !== null || ackAll) {
164
+ let acked;
165
+ try {
166
+ acked = state.withLock(anchor, () => {
167
+ const unread = state.unreadSignals(anchor, id);
168
+ const targets = ackAll ? unread : unread.filter((s) => seqOf(s) === ack);
169
+ if (!ackAll && targets.length === 0) {
170
+ throw new Error(`no unread signal with seq ${pad(ack)} in lane '${id}'`);
171
+ }
172
+ const dest = state.processedDir(anchor, id);
173
+ fs.mkdirSync(dest, { recursive: true });
174
+ for (const s of targets) {
175
+ fs.renameSync(
176
+ path.join(state.inboxDir(anchor, id), s.file),
177
+ path.join(dest, s.file)
178
+ );
179
+ }
180
+ return targets;
181
+ });
182
+ } catch (err) {
183
+ return fail(err.message);
184
+ }
185
+ if (acked.length === 0) {
186
+ console.log('inbox empty — nothing to ack');
187
+ return;
188
+ }
189
+ for (const s of acked) {
190
+ console.log(`✓ acked ${s.file.replace(/\.json$/, '')} → processed/`);
191
+ }
192
+ return;
193
+ }
194
+
195
+ // ── list path (lock-free, oldest first) ──
196
+ const unread = state.unreadSignals(anchor, id);
197
+ if (unread.length === 0) {
198
+ console.log('inbox empty');
199
+ return;
200
+ }
201
+ for (const s of unread) {
202
+ const text = s.text === null || s.text === undefined ? '-' : s.text;
203
+ console.log(`${pad(seqOf(s))} ${s.type} ${s.from} ${s.at} — ${text}`);
204
+ }
205
+ }
206
+
207
+ module.exports = { cmdSignal, cmdInbox };