@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.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/adapters/antigravity/adapter.js +196 -0
- package/adapters/antigravity/hooks.json +39 -0
- package/adapters/antigravity/instructions/AGENTS.md +638 -0
- package/adapters/antigravity/instructions/header.md +3 -0
- package/adapters/antigravity/instructions/surfaces.md +153 -0
- package/adapters/antigravity/instructions/vars.json +1 -0
- package/adapters/antigravity/scripts/antigravity-hook-adapter.sh +147 -0
- package/adapters/antigravity/skills/momentum-orient/SKILL.md +29 -0
- package/adapters/antigravity/skills/momentum-reviewer-architecture/SKILL.md +39 -0
- package/adapters/antigravity/skills/momentum-reviewer-qa/SKILL.md +38 -0
- package/adapters/antigravity/skills/momentum-reviewer-security/SKILL.md +42 -0
- package/adapters/antigravity/skills/swarm-supervisor/SKILL.md +75 -0
- package/adapters/antigravity/workflows/continue.md +39 -0
- package/adapters/antigravity/workflows/dispatch.md +49 -0
- package/adapters/antigravity/workflows/handoff.md +38 -0
- package/adapters/antigravity/workflows/review-code.md +84 -0
- package/adapters/antigravity/workflows/scout.md +42 -0
- package/adapters/antigravity/workflows/swarm.md +203 -0
- package/adapters/claude-code/adapter.js +148 -0
- package/adapters/claude-code/commands/continue.md +82 -0
- package/adapters/claude-code/commands/dispatch.md +154 -0
- package/adapters/claude-code/commands/handoff.md +85 -0
- package/adapters/claude-code/commands/review-code.md +194 -0
- package/adapters/claude-code/commands/scout.md +119 -0
- package/adapters/claude-code/commands/swarm.md +242 -0
- package/adapters/claude-code/instructions/header.md +3 -0
- package/adapters/claude-code/instructions/vars.json +1 -0
- package/adapters/claude-code/settings.json +36 -0
- package/adapters/codex/adapter.js +225 -0
- package/adapters/codex/agents/momentum-reviewer-architecture.toml +57 -0
- package/adapters/codex/agents/momentum-reviewer-qa.toml +52 -0
- package/adapters/codex/agents/momentum-reviewer-security.toml +57 -0
- package/adapters/codex/agents/swarm-supervisor.toml +76 -0
- package/adapters/codex/commands/continue.md +40 -0
- package/adapters/codex/commands/dispatch.md +111 -0
- package/adapters/codex/commands/handoff.md +57 -0
- package/adapters/codex/commands/scout.md +90 -0
- package/adapters/codex/commands/swarm.md +334 -0
- package/adapters/codex/hooks.json +36 -0
- package/adapters/codex/instructions/AGENTS.md +691 -0
- package/adapters/codex/instructions/header.md +3 -0
- package/adapters/codex/instructions/surfaces.md +206 -0
- package/adapters/codex/instructions/vars.json +1 -0
- package/adapters/codex/skills/momentum-orient/SKILL.md +29 -0
- package/adapters/opencode/adapter.js +224 -0
- package/adapters/opencode/agents/momentum-reviewer-architecture.md +54 -0
- package/adapters/opencode/agents/momentum-reviewer-qa.md +48 -0
- package/adapters/opencode/agents/momentum-reviewer-security.md +48 -0
- package/adapters/opencode/agents/swarm-supervisor.md +51 -0
- package/adapters/opencode/commands/continue.md +40 -0
- package/adapters/opencode/commands/dispatch.md +111 -0
- package/adapters/opencode/commands/handoff.md +57 -0
- package/adapters/opencode/commands/review-code.md +194 -0
- package/adapters/opencode/commands/scout.md +90 -0
- package/adapters/opencode/commands/swarm.md +334 -0
- package/adapters/opencode/instructions/AGENTS.md +604 -0
- package/adapters/opencode/instructions/header.md +3 -0
- package/adapters/opencode/instructions/surfaces.md +119 -0
- package/adapters/opencode/instructions/vars.json +1 -0
- package/adapters/opencode/plugins/momentum.js +219 -0
- package/adapters/opencode/skills/momentum-lanes/SKILL.md +30 -0
- package/adapters/opencode/skills/momentum-orient/SKILL.md +29 -0
- package/adapters/opencode/skills/momentum-track/SKILL.md +28 -0
- package/adapters/opencode/skills/momentum-validate/SKILL.md +31 -0
- package/bin/antigravity.js +112 -0
- package/bin/ecosystem.js +1302 -0
- package/bin/lanes.js +300 -0
- package/bin/momentum.js +1857 -0
- package/bin/okf.js +72 -0
- package/bin/orchestration-commands.js +305 -0
- package/bin/state-commands.js +218 -0
- package/bin/swarm.js +1104 -0
- package/bin/waves.js +147 -0
- package/core/adapter-capabilities.md +112 -0
- package/core/adapter-parity-matrix.md +170 -0
- package/core/commands/brainstorm-idea.md +98 -0
- package/core/commands/brainstorm-phase.md +129 -0
- package/core/commands/complete-phase.md +130 -0
- package/core/commands/ecosystem.md +161 -0
- package/core/commands/hotfix.md +64 -0
- package/core/commands/initiative.md +119 -0
- package/core/commands/lanes.md +94 -0
- package/core/commands/log.md +45 -0
- package/core/commands/migrate.md +70 -0
- package/core/commands/review.md +31 -0
- package/core/commands/session.md +79 -0
- package/core/commands/start-phase.md +161 -0
- package/core/commands/start-project.md +142 -0
- package/core/commands/sync-docs.md +81 -0
- package/core/commands/systematic-debug.md +46 -0
- package/core/commands/track.md +54 -0
- package/core/commands/validate.md +96 -0
- package/core/ecosystem/layout.md +128 -0
- package/core/ecosystem/lib/index.js +245 -0
- package/core/ecosystem/lib/initiative.js +258 -0
- package/core/ecosystem/lib/pointer.js +198 -0
- package/core/ecosystem/lib/state.js +295 -0
- package/core/ecosystem/schema/ecosystem.schema.json +85 -0
- package/core/ecosystem/schema/initiative.schema.json +61 -0
- package/core/ecosystem/scripts/session-append.sh +167 -0
- package/core/ecosystem/templates/ecosystem-agents.md +79 -0
- package/core/ecosystem/templates/ecosystem-claude.md +79 -0
- package/core/ecosystem/templates/ecosystem-settings-claude.json +14 -0
- package/core/ecosystem/templates/initiative-template.md +50 -0
- package/core/engines/subagent-dispatch.md +80 -0
- package/core/git-hooks/commit-msg +14 -0
- package/core/git-hooks/contract.js +176 -0
- package/core/git-hooks/pre-push +15 -0
- package/core/git-hooks/run-check.js +160 -0
- package/core/instructions/legacy-project-md-hashes.json +12 -0
- package/core/instructions/navigation.md +14 -0
- package/core/instructions/rules-body.md +454 -0
- package/core/lanes/lib/board.js +161 -0
- package/core/lanes/lib/land.js +301 -0
- package/core/lanes/lib/signals.js +207 -0
- package/core/lanes/lib/state.js +325 -0
- package/core/lib/frontmatter.js +163 -0
- package/core/lib/okf-migrate.js +374 -0
- package/core/lib/okf-types.js +61 -0
- package/core/lifecycle-contract.md +59 -0
- package/core/orchestration/capability-routing.js +120 -0
- package/core/orchestration/continue.js +102 -0
- package/core/orchestration/dispatch.js +366 -0
- package/core/orchestration/events.js +197 -0
- package/core/orchestration/handoff.js +317 -0
- package/core/orchestration/index.js +50 -0
- package/core/orchestration/run-artifact.js +93 -0
- package/core/orchestration/scout.js +327 -0
- package/core/orchestration/session-log.js +123 -0
- package/core/orchestration/tracking.js +180 -0
- package/core/orchestration/types.js +131 -0
- package/core/scripts/brainstorm-gate.sh +157 -0
- package/core/scripts/check-history-reminder.sh +110 -0
- package/core/scripts/sessionstart-handoff.sh +180 -0
- package/core/specs-templates/CLAUDE.md +484 -0
- package/core/specs-templates/README.md +32 -0
- package/core/specs-templates/specs/README.md +30 -0
- package/core/specs-templates/specs/adhoc/README.md +31 -0
- package/core/specs-templates/specs/adhoc/_TEMPLATE.md +29 -0
- package/core/specs-templates/specs/architecture/ecosystem.md +214 -0
- package/core/specs-templates/specs/backlog/backlog.md +46 -0
- package/core/specs-templates/specs/backlog/details/.gitkeep +0 -0
- package/core/specs-templates/specs/changelog/.gitkeep +0 -0
- package/core/specs-templates/specs/decisions/0000-template.md +33 -0
- package/core/specs-templates/specs/decisions/README.md +29 -0
- package/core/specs-templates/specs/decisions/impact-map.md +14 -0
- package/core/specs-templates/specs/decisions/index.md +4 -0
- package/core/specs-templates/specs/index.md +16 -0
- package/core/specs-templates/specs/phases/README.md +41 -0
- package/core/specs-templates/specs/phases/index.md +6 -0
- package/core/specs-templates/specs/planning/roadmap.md +23 -0
- package/core/specs-templates/specs/status.md +70 -0
- package/core/specs-templates/specs/vision/principles.md +16 -0
- package/core/specs-templates/specs/vision/project-charter.md +35 -0
- package/core/specs-templates/specs/vision/success-criteria.md +19 -0
- package/core/swarm/absorb.js +294 -0
- package/core/swarm/conductor.js +499 -0
- package/core/swarm/focus.js +151 -0
- package/core/swarm/inbox.js +222 -0
- package/core/swarm/join.js +138 -0
- package/core/swarm/lib/board.js +178 -0
- package/core/swarm/lib/brief.js +157 -0
- package/core/swarm/lib/git-sha-cache.js +71 -0
- package/core/swarm/lib/incremental-log.js +117 -0
- package/core/swarm/lib/manifest.js +578 -0
- package/core/swarm/lib/pre-merge.js +112 -0
- package/core/swarm/lib/saga.js +146 -0
- package/core/swarm/lib/sessions.js +83 -0
- package/core/swarm/lib/tokens.js +255 -0
- package/core/swarm/lib/wave-ordering.js +55 -0
- package/core/swarm/schema/board.schema.json +54 -0
- package/core/swarm/schema/contract.schema.json +56 -0
- package/core/swarm/schema/dispatch-run.schema.json +70 -0
- package/core/swarm/schema/manifest.schema.json +234 -0
- package/core/swarm/schema/signal.schema.json +61 -0
- package/core/swarm/signals.js +296 -0
- package/core/swarm/supervise.md +81 -0
- package/core/waves/lib/plan-graph.js +175 -0
- package/core/waves/lib/waves.js +83 -0
- 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 };
|