@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,325 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lane state layer (Phase 21b, FEAT-026 — see ADR-0002).
|
|
5
|
+
*
|
|
6
|
+
* State lives ONCE per repo inside the shared git directory:
|
|
7
|
+
*
|
|
8
|
+
* <git-common-dir>/momentum/lanes/
|
|
9
|
+
* ├── registry.json { stateVersion, lanes: [<id>...] }
|
|
10
|
+
* └── <id>/
|
|
11
|
+
* ├── manifest.json lane manifest (shape below)
|
|
12
|
+
* └── inbox/ signals (Phase 21b G4); processed/ after ack
|
|
13
|
+
*
|
|
14
|
+
* Anchoring at `git rev-parse --git-common-dir` makes the state
|
|
15
|
+
* untracked by construction, shared across every linked worktree, and
|
|
16
|
+
* gone with the repo. The format is INTERNAL (stateVersion 1) — the
|
|
17
|
+
* publish-as-contract decision is deferred to the 21c close per the
|
|
18
|
+
* platform direction's one-way-door rule.
|
|
19
|
+
*
|
|
20
|
+
* Concurrency: all writes go through a single mkdir-lock chokepoint
|
|
21
|
+
* (the pattern proven in core/swarm/lib/manifest.js); reads are
|
|
22
|
+
* lock-free. Manifest writes are tmp-file + rename for atomicity.
|
|
23
|
+
*
|
|
24
|
+
* Zero dependencies — node builtins only.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const os = require('os');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const { spawnSync } = require('child_process');
|
|
31
|
+
|
|
32
|
+
const STATE_VERSION = 1;
|
|
33
|
+
const LANES_SUBDIR = path.join('momentum', 'lanes');
|
|
34
|
+
const REGISTRY = 'registry.json';
|
|
35
|
+
const MANIFEST = 'manifest.json';
|
|
36
|
+
const LOCK_DIR = '.lock';
|
|
37
|
+
const LOCK_RETRIES = 50;
|
|
38
|
+
const LOCK_RETRY_MS = 100;
|
|
39
|
+
|
|
40
|
+
const STATUSES = Object.freeze(['open', 'done', 'landed', 'closed']);
|
|
41
|
+
const GRADES = Object.freeze(['phase', 'quick-task', 'spike']);
|
|
42
|
+
const ADHOC_BRANCH = /^(fix|chore|feat|refactor|infra)\//;
|
|
43
|
+
|
|
44
|
+
// ─── git helpers ─────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function git(cwd, ...args) {
|
|
47
|
+
const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
48
|
+
if (res.status !== 0) return null;
|
|
49
|
+
return res.stdout.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Absolute path of the shared git dir, from any worktree. Null outside a repo. */
|
|
53
|
+
function gitCommonDir(cwd) {
|
|
54
|
+
const out = git(cwd, 'rev-parse', '--git-common-dir');
|
|
55
|
+
if (!out) return null;
|
|
56
|
+
return path.resolve(cwd, out);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Root of the current worktree. Null outside a repo. */
|
|
60
|
+
function worktreeRoot(cwd) {
|
|
61
|
+
return git(cwd, 'rev-parse', '--show-toplevel');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Anchor dir for lane state (<common>/momentum/lanes). Null outside a repo. */
|
|
65
|
+
function resolveAnchor(cwd) {
|
|
66
|
+
const common = gitCommonDir(cwd || process.cwd());
|
|
67
|
+
if (!common) return null;
|
|
68
|
+
return path.join(common, LANES_SUBDIR);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── ids / inference ─────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function laneId(branch) {
|
|
74
|
+
return String(branch).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Infer the plan node a branch binds to (the Rule 15 convention as data).
|
|
79
|
+
* `repoRoot` (a worktree root) is used to check for the phase directory.
|
|
80
|
+
*/
|
|
81
|
+
function inferPlanNode(branch, repoRoot) {
|
|
82
|
+
if (/^phase-/.test(branch)) {
|
|
83
|
+
const dir = repoRoot && fs.existsSync(path.join(repoRoot, 'specs', 'phases', branch));
|
|
84
|
+
return { type: 'phase', ref: branch, dirExists: Boolean(dir) };
|
|
85
|
+
}
|
|
86
|
+
if (ADHOC_BRANCH.test(branch)) {
|
|
87
|
+
return { type: 'adhoc', ref: laneId(branch) };
|
|
88
|
+
}
|
|
89
|
+
return { type: 'unbound', ref: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function defaultGrade(planNode) {
|
|
93
|
+
if (planNode.type === 'phase') return 'phase';
|
|
94
|
+
return 'quick-task'; // spikes are always declared explicitly
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── locking ─────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function sleepMs(ms) {
|
|
100
|
+
// Synchronous sleep without deps: Atomics.wait on a throwaway buffer.
|
|
101
|
+
const sab = new Int32Array(new SharedArrayBuffer(4));
|
|
102
|
+
Atomics.wait(sab, 0, 0, ms);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Run fn holding the anchor-wide mkdir lock. Throws ELOCK on timeout. */
|
|
106
|
+
function withLock(anchor, fn) {
|
|
107
|
+
fs.mkdirSync(anchor, { recursive: true });
|
|
108
|
+
const lock = path.join(anchor, LOCK_DIR);
|
|
109
|
+
let held = false;
|
|
110
|
+
for (let i = 0; i < LOCK_RETRIES; i++) {
|
|
111
|
+
try {
|
|
112
|
+
fs.mkdirSync(lock);
|
|
113
|
+
held = true;
|
|
114
|
+
break;
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (err.code !== 'EEXIST') throw err;
|
|
117
|
+
sleepMs(LOCK_RETRY_MS);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!held) {
|
|
121
|
+
const e = new Error(`lane state lock busy: ${lock}`);
|
|
122
|
+
e.code = 'ELOCK';
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return fn();
|
|
127
|
+
} finally {
|
|
128
|
+
try { fs.rmdirSync(lock); } catch { /* already gone */ }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── reads (lock-free) ───────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
function readJson(file) {
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readRegistry(anchor) {
|
|
143
|
+
return readJson(path.join(anchor, REGISTRY)) || { stateVersion: STATE_VERSION, lanes: [] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function laneDir(anchor, id) {
|
|
147
|
+
return path.join(anchor, id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readManifest(anchor, id) {
|
|
151
|
+
return readJson(path.join(laneDir(anchor, id), MANIFEST));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** All manifests listed in the registry (missing ones skipped). */
|
|
155
|
+
function listLanes(anchor) {
|
|
156
|
+
const reg = readRegistry(anchor);
|
|
157
|
+
return reg.lanes.map((id) => readManifest(anchor, id)).filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── writes (locked) ─────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
function writeJsonAtomic(file, obj) {
|
|
163
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
164
|
+
const tmp = path.join(
|
|
165
|
+
path.dirname(file),
|
|
166
|
+
`.${path.basename(file)}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`
|
|
167
|
+
);
|
|
168
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + os.EOL);
|
|
169
|
+
fs.renameSync(tmp, file);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create + register a lane. Throws EEXIST if the id is already registered
|
|
174
|
+
* with a non-closed manifest.
|
|
175
|
+
*/
|
|
176
|
+
function createLane(anchor, manifest) {
|
|
177
|
+
const full = {
|
|
178
|
+
stateVersion: STATE_VERSION,
|
|
179
|
+
id: manifest.id,
|
|
180
|
+
branch: manifest.branch,
|
|
181
|
+
planNode: manifest.planNode,
|
|
182
|
+
worktree: manifest.worktree || null,
|
|
183
|
+
grade: manifest.grade,
|
|
184
|
+
touches: manifest.touches || [],
|
|
185
|
+
status: 'open',
|
|
186
|
+
opened: manifest.opened || new Date().toISOString(),
|
|
187
|
+
doneAt: null,
|
|
188
|
+
landedAt: null,
|
|
189
|
+
note: manifest.note || null,
|
|
190
|
+
};
|
|
191
|
+
if (!GRADES.includes(full.grade)) {
|
|
192
|
+
throw new Error(`invalid grade '${full.grade}' (expected ${GRADES.join('|')})`);
|
|
193
|
+
}
|
|
194
|
+
return withLock(anchor, () => {
|
|
195
|
+
const existing = readManifest(anchor, full.id);
|
|
196
|
+
if (existing && existing.status !== 'closed') {
|
|
197
|
+
const e = new Error(`lane '${full.id}' already open (status: ${existing.status})`);
|
|
198
|
+
e.code = 'EEXIST';
|
|
199
|
+
throw e;
|
|
200
|
+
}
|
|
201
|
+
const reg = readRegistry(anchor);
|
|
202
|
+
if (!reg.lanes.includes(full.id)) reg.lanes.push(full.id);
|
|
203
|
+
writeJsonAtomic(path.join(laneDir(anchor, full.id), MANIFEST), full);
|
|
204
|
+
writeJsonAtomic(path.join(anchor, REGISTRY), reg);
|
|
205
|
+
return full;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Patch a lane manifest. Throws ENOLANE when it doesn't exist. */
|
|
210
|
+
function updateLane(anchor, id, patch) {
|
|
211
|
+
return withLock(anchor, () => {
|
|
212
|
+
const current = readManifest(anchor, id);
|
|
213
|
+
if (!current) {
|
|
214
|
+
const e = new Error(`no such lane: '${id}'`);
|
|
215
|
+
e.code = 'ENOLANE';
|
|
216
|
+
throw e;
|
|
217
|
+
}
|
|
218
|
+
if (patch.status && !STATUSES.includes(patch.status)) {
|
|
219
|
+
throw new Error(`invalid status '${patch.status}' (expected ${STATUSES.join('|')})`);
|
|
220
|
+
}
|
|
221
|
+
const next = { ...current, ...patch };
|
|
222
|
+
writeJsonAtomic(path.join(laneDir(anchor, id), MANIFEST), next);
|
|
223
|
+
return next;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── inbox reads (writes live in signals.js — G4) ────────────────────────
|
|
228
|
+
|
|
229
|
+
const INBOX = 'inbox';
|
|
230
|
+
const PROCESSED = 'processed';
|
|
231
|
+
const SIGNAL_TYPES = Object.freeze(['pause', 'resume', 'redirect', 'kill', 'message']);
|
|
232
|
+
|
|
233
|
+
function inboxDir(anchor, id) {
|
|
234
|
+
return path.join(laneDir(anchor, id), INBOX);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function processedDir(anchor, id) {
|
|
238
|
+
return path.join(inboxDir(anchor, id), PROCESSED);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Unread signal files for a lane, oldest first. [{file, seq, type, ...body}] */
|
|
242
|
+
function unreadSignals(anchor, id) {
|
|
243
|
+
const dir = inboxDir(anchor, id);
|
|
244
|
+
let names;
|
|
245
|
+
try {
|
|
246
|
+
names = fs.readdirSync(dir);
|
|
247
|
+
} catch {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
return names
|
|
251
|
+
.filter((n) => n.endsWith('.json'))
|
|
252
|
+
.sort()
|
|
253
|
+
.map((n) => {
|
|
254
|
+
const body = readJson(path.join(dir, n)) || {};
|
|
255
|
+
return { file: n, ...body };
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function unreadCount(anchor, id) {
|
|
260
|
+
return unreadSignals(anchor, id).length;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─── touch-path overlap (ENH-047, advisory) ─────────────────────────────
|
|
264
|
+
|
|
265
|
+
/** Normalize a touch glob to a comparable path prefix. */
|
|
266
|
+
function touchPrefix(glob) {
|
|
267
|
+
return String(glob)
|
|
268
|
+
.replace(/\*+.*$/, '') // drop from the first wildcard
|
|
269
|
+
.replace(/\/+$/, '');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** True when two touch declarations plausibly overlap (prefix containment). */
|
|
273
|
+
function touchesOverlap(a, b) {
|
|
274
|
+
const pa = touchPrefix(a);
|
|
275
|
+
const pb = touchPrefix(b);
|
|
276
|
+
if (pa === '' || pb === '') return true; // bare '**' overlaps everything
|
|
277
|
+
return pa === pb || pa.startsWith(pb + '/') || pb.startsWith(pa + '/');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Overlap report between one lane's touches and every other active lane.
|
|
282
|
+
* Returns [{ laneId, mine, theirs }].
|
|
283
|
+
*/
|
|
284
|
+
function overlapWarnings(anchor, id, touches) {
|
|
285
|
+
const warnings = [];
|
|
286
|
+
if (!touches || touches.length === 0) return warnings;
|
|
287
|
+
for (const other of listLanes(anchor)) {
|
|
288
|
+
if (other.id === id) continue;
|
|
289
|
+
if (other.status === 'closed' || other.status === 'landed') continue;
|
|
290
|
+
for (const mine of touches) {
|
|
291
|
+
for (const theirs of other.touches || []) {
|
|
292
|
+
if (touchesOverlap(mine, theirs)) {
|
|
293
|
+
warnings.push({ laneId: other.id, mine, theirs });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return warnings;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
STATE_VERSION,
|
|
303
|
+
STATUSES,
|
|
304
|
+
GRADES,
|
|
305
|
+
resolveAnchor,
|
|
306
|
+
gitCommonDir,
|
|
307
|
+
worktreeRoot,
|
|
308
|
+
laneId,
|
|
309
|
+
inferPlanNode,
|
|
310
|
+
defaultGrade,
|
|
311
|
+
withLock,
|
|
312
|
+
readRegistry,
|
|
313
|
+
readManifest,
|
|
314
|
+
listLanes,
|
|
315
|
+
createLane,
|
|
316
|
+
updateLane,
|
|
317
|
+
laneDir,
|
|
318
|
+
touchesOverlap,
|
|
319
|
+
overlapWarnings,
|
|
320
|
+
SIGNAL_TYPES,
|
|
321
|
+
inboxDir,
|
|
322
|
+
processedDir,
|
|
323
|
+
unreadSignals,
|
|
324
|
+
unreadCount,
|
|
325
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Zero-dependency YAML-frontmatter subset parser/serializer (OKF v0.1).
|
|
5
|
+
*
|
|
6
|
+
* Momentum only ever EMITS this subset, so it only PARSES this subset:
|
|
7
|
+
* - string scalars: key: value | key: "value" | key: 'value'
|
|
8
|
+
* - inline string lists: key: [a, b, "c d"]
|
|
9
|
+
* - block string lists: key:\n - a\n - b
|
|
10
|
+
* - empty scalars: key:
|
|
11
|
+
*
|
|
12
|
+
* Reads are tolerant per the OKF conformance rules: anything outside the
|
|
13
|
+
* subset (nested maps, multi-line scalars, tabs) makes parse() return
|
|
14
|
+
* `data: null` — callers treat the file as opaque and leave it alone.
|
|
15
|
+
* parse() never throws. Round-trips preserve unknown keys and key order.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const OPEN = /^---\r?\n/;
|
|
19
|
+
|
|
20
|
+
/** True when content begins with a `---` frontmatter fence. */
|
|
21
|
+
function hasBlock(content) {
|
|
22
|
+
return typeof content === 'string' && OPEN.test(content);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function unquote(value) {
|
|
26
|
+
if (value.length >= 2) {
|
|
27
|
+
const first = value[0];
|
|
28
|
+
const last = value[value.length - 1];
|
|
29
|
+
if (first === last && (first === '"' || first === "'")) {
|
|
30
|
+
const inner = value.slice(1, -1);
|
|
31
|
+
return first === '"' ? inner.replace(/\\(["\\])/g, '$1') : inner;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseInlineList(value) {
|
|
38
|
+
const inner = value.slice(1, -1).trim();
|
|
39
|
+
if (inner === '') return [];
|
|
40
|
+
const items = [];
|
|
41
|
+
let current = '';
|
|
42
|
+
let quote = null;
|
|
43
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
44
|
+
const ch = inner[i];
|
|
45
|
+
if (quote) {
|
|
46
|
+
current += ch;
|
|
47
|
+
if (ch === quote && inner[i - 1] !== '\\') quote = null;
|
|
48
|
+
} else if (ch === '"' || ch === "'") {
|
|
49
|
+
current += ch;
|
|
50
|
+
quote = ch;
|
|
51
|
+
} else if (ch === ',') {
|
|
52
|
+
items.push(unquote(current.trim()));
|
|
53
|
+
current = '';
|
|
54
|
+
} else {
|
|
55
|
+
current += ch;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (quote) return null; // unterminated quote — outside the subset
|
|
59
|
+
items.push(unquote(current.trim()));
|
|
60
|
+
return items;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse frontmatter. Returns { data, body, raw }:
|
|
65
|
+
* data — object (string | string[] values), or null when there is no
|
|
66
|
+
* fence or the block falls outside the supported subset;
|
|
67
|
+
* body — content after the closing fence (original content when no
|
|
68
|
+
* fence / unparseable block);
|
|
69
|
+
* raw — the exact frontmatter text between the fences (null when no
|
|
70
|
+
* fence), preserved so callers can do textual edits instead of
|
|
71
|
+
* reserializing.
|
|
72
|
+
*/
|
|
73
|
+
function parse(content) {
|
|
74
|
+
if (!hasBlock(content)) return { data: null, body: content, raw: null };
|
|
75
|
+
|
|
76
|
+
const fenceEnd = content.match(/\r?\n---[ \t]*(\r?\n|$)/);
|
|
77
|
+
if (!fenceEnd) return { data: null, body: content, raw: null };
|
|
78
|
+
|
|
79
|
+
const open = content.match(OPEN)[0].length;
|
|
80
|
+
const rawBlock = content.slice(open, fenceEnd.index);
|
|
81
|
+
const body = content.slice(fenceEnd.index + fenceEnd[0].length);
|
|
82
|
+
|
|
83
|
+
const data = {};
|
|
84
|
+
const lines = rawBlock.split(/\r?\n/);
|
|
85
|
+
let pendingKey = null;
|
|
86
|
+
const emptyKeys = new Set();
|
|
87
|
+
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
if (line.trim() === '' || line.trim().startsWith('#')) continue;
|
|
90
|
+
const listItem = line.match(/^\s+-\s?(.*)$/);
|
|
91
|
+
if (listItem) {
|
|
92
|
+
if (pendingKey === null) return { data: null, body: content, raw: rawBlock };
|
|
93
|
+
data[pendingKey].push(unquote(listItem[1].trim()));
|
|
94
|
+
emptyKeys.delete(pendingKey);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (/^\s/.test(line)) return { data: null, body: content, raw: rawBlock }; // nested structure
|
|
98
|
+
const kv = line.match(/^([A-Za-z0-9_-]+):(.*)$/);
|
|
99
|
+
if (!kv) return { data: null, body: content, raw: rawBlock };
|
|
100
|
+
const key = kv[1];
|
|
101
|
+
const value = kv[2].trim();
|
|
102
|
+
if (value === '') {
|
|
103
|
+
data[key] = [];
|
|
104
|
+
pendingKey = key; // block list opener — or an empty scalar (normalized below)
|
|
105
|
+
emptyKeys.add(key);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
pendingKey = null;
|
|
109
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
110
|
+
const list = parseInlineList(value);
|
|
111
|
+
if (list === null) return { data: null, body: content, raw: rawBlock };
|
|
112
|
+
data[key] = list;
|
|
113
|
+
} else {
|
|
114
|
+
data[key] = unquote(value);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// `key:` that never received list items is an empty string scalar.
|
|
119
|
+
for (const key of emptyKeys) data[key] = '';
|
|
120
|
+
|
|
121
|
+
return { data, body, raw: rawBlock };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const NEEDS_QUOTES = /[:#\[\]{}&*!|>'"%@`,]|^[\s-]|\s$|^$|^(true|false|null|~|yes|no|on|off)$|^[\d.+-]/i;
|
|
125
|
+
|
|
126
|
+
function quoteScalar(value) {
|
|
127
|
+
const str = String(value);
|
|
128
|
+
if (!NEEDS_QUOTES.test(str)) return str;
|
|
129
|
+
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Serialize a data object to frontmatter YAML lines (no fences). */
|
|
133
|
+
function stringifyData(data) {
|
|
134
|
+
const lines = [];
|
|
135
|
+
for (const [key, value] of Object.entries(data)) {
|
|
136
|
+
if (Array.isArray(value)) {
|
|
137
|
+
lines.push(`${key}: [${value.map(quoteScalar).join(', ')}]`);
|
|
138
|
+
} else {
|
|
139
|
+
lines.push(`${key}: ${quoteScalar(value)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return lines.join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Compose a full document: fenced frontmatter + body. */
|
|
146
|
+
function compose(data, body) {
|
|
147
|
+
const yaml = stringifyData(data);
|
|
148
|
+
const sep = body && !body.startsWith('\n') ? '\n' : '';
|
|
149
|
+
return `---\n${yaml}\n---\n${sep}${body || ''}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Insert `type: <value>` as the first line of an EXISTING frontmatter
|
|
154
|
+
* block, textually — everything else in the file is preserved
|
|
155
|
+
* byte-for-byte. Returns null when the file has no block.
|
|
156
|
+
*/
|
|
157
|
+
function insertTypeLine(content, type) {
|
|
158
|
+
if (!hasBlock(content)) return null;
|
|
159
|
+
const open = content.match(OPEN)[0];
|
|
160
|
+
return `${open}type: ${quoteScalar(type)}\n${content.slice(open.length)}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { hasBlock, parse, stringifyData, compose, insertTypeLine, quoteScalar };
|