@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,222 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Swarm inbox protocol.
|
|
5
|
+
*
|
|
6
|
+
* Supervisors write `<eco>/swarms/<id>/inbox/NNNN-<slug>.md` when they
|
|
7
|
+
* need a user decision they can't make alone. The conductor surfaces
|
|
8
|
+
* these per turn and resolves them interactively. After resolution the
|
|
9
|
+
* item is moved to `inbox/resolved/` (preserved for audit) and the
|
|
10
|
+
* INDEX is regenerated.
|
|
11
|
+
*
|
|
12
|
+
* Numbering: monotonic across the swarm's lifetime, including
|
|
13
|
+
* resolved. Filename: `NNNN-<slug>.md` matching the regex
|
|
14
|
+
* `/^\d{4}-[a-z][a-z0-9-]*\.md$/`.
|
|
15
|
+
*
|
|
16
|
+
* Concurrency: writes use the same mkdir-lock as manifest.js.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const manifestLib = require('./lib/manifest');
|
|
23
|
+
|
|
24
|
+
const INBOX_DIR = 'inbox';
|
|
25
|
+
const RESOLVED_DIR = 'resolved';
|
|
26
|
+
const INDEX_FILENAME = 'INDEX.md';
|
|
27
|
+
|
|
28
|
+
const FILENAME = /^(\d{4})-([a-z][a-z0-9-]*)\.md$/;
|
|
29
|
+
|
|
30
|
+
function inboxDir(ecosystemRoot, swarmId) {
|
|
31
|
+
return path.join(manifestLib.swarmDir(ecosystemRoot, swarmId), INBOX_DIR);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolvedDir(ecosystemRoot, swarmId) {
|
|
35
|
+
return path.join(inboxDir(ecosystemRoot, swarmId), RESOLVED_DIR);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function indexPath(ecosystemRoot, swarmId) {
|
|
39
|
+
return path.join(inboxDir(ecosystemRoot, swarmId), INDEX_FILENAME);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ensureLayout(ecosystemRoot, swarmId) {
|
|
43
|
+
manifestLib.ensureSwarmLayout(ecosystemRoot, swarmId);
|
|
44
|
+
fs.mkdirSync(resolvedDir(ecosystemRoot, swarmId), { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function nextInboxId(ecosystemRoot, swarmId) {
|
|
48
|
+
ensureLayout(ecosystemRoot, swarmId);
|
|
49
|
+
let max = 0;
|
|
50
|
+
for (const name of fs.readdirSync(inboxDir(ecosystemRoot, swarmId))) {
|
|
51
|
+
const m = name.match(FILENAME);
|
|
52
|
+
if (m) {
|
|
53
|
+
const n = parseInt(m[1], 10);
|
|
54
|
+
if (n > max) max = n;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const name of fs.readdirSync(resolvedDir(ecosystemRoot, swarmId))) {
|
|
58
|
+
const m = name.match(FILENAME);
|
|
59
|
+
if (m) {
|
|
60
|
+
const n = parseInt(m[1], 10);
|
|
61
|
+
if (n > max) max = n;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return String(max + 1).padStart(4, '0');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Supervisor-side write. Returns { id, slug, filePath }.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} args
|
|
71
|
+
* @param {string} args.ecosystemRoot
|
|
72
|
+
* @param {string} args.swarmId
|
|
73
|
+
* @param {string} args.repo ecosystem member id
|
|
74
|
+
* @param {string} args.slug kebab slug summarizing the question
|
|
75
|
+
* @param {string} args.question markdown body (full question)
|
|
76
|
+
* @param {string[]} [args.options] optional structured choices (each one line)
|
|
77
|
+
* @param {string} args.nowIso
|
|
78
|
+
*/
|
|
79
|
+
function writeInboxItem(args) {
|
|
80
|
+
const { ecosystemRoot, swarmId, repo, slug, question, options = [], nowIso } = args;
|
|
81
|
+
if (typeof slug !== 'string' || !/^[a-z][a-z0-9-]*$/.test(slug)) {
|
|
82
|
+
throw new TypeError(`writeInboxItem: invalid slug ${JSON.stringify(slug)}`);
|
|
83
|
+
}
|
|
84
|
+
if (typeof question !== 'string' || question.length === 0) {
|
|
85
|
+
throw new TypeError('writeInboxItem: question required');
|
|
86
|
+
}
|
|
87
|
+
if (typeof repo !== 'string' || !/^[a-z][a-z0-9-]*$/.test(repo)) {
|
|
88
|
+
throw new TypeError(`writeInboxItem: invalid repo ${JSON.stringify(repo)}`);
|
|
89
|
+
}
|
|
90
|
+
ensureLayout(ecosystemRoot, swarmId);
|
|
91
|
+
const id = nextInboxId(ecosystemRoot, swarmId);
|
|
92
|
+
const filePath = path.join(inboxDir(ecosystemRoot, swarmId), `${id}-${slug}.md`);
|
|
93
|
+
const lines = [
|
|
94
|
+
`# ${id} — ${slug}`,
|
|
95
|
+
'',
|
|
96
|
+
`- Repo: \`${repo}\``,
|
|
97
|
+
`- Asked at: ${nowIso}`,
|
|
98
|
+
`- Status: pending`,
|
|
99
|
+
'',
|
|
100
|
+
'## Question',
|
|
101
|
+
'',
|
|
102
|
+
question.trim(),
|
|
103
|
+
'',
|
|
104
|
+
];
|
|
105
|
+
if (options.length) {
|
|
106
|
+
lines.push('## Options');
|
|
107
|
+
lines.push('');
|
|
108
|
+
for (const o of options) lines.push(`- ${o}`);
|
|
109
|
+
lines.push('');
|
|
110
|
+
}
|
|
111
|
+
manifestLib.withLock(filePath, () => {
|
|
112
|
+
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
rebuildIndex(ecosystemRoot, swarmId);
|
|
116
|
+
return { id, slug, filePath };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Conductor-side: list pending items (excluding resolved/).
|
|
121
|
+
*/
|
|
122
|
+
function listPendingInboxItems(ecosystemRoot, swarmId) {
|
|
123
|
+
const dir = inboxDir(ecosystemRoot, swarmId);
|
|
124
|
+
if (!fs.existsSync(dir)) return [];
|
|
125
|
+
const items = [];
|
|
126
|
+
for (const name of fs.readdirSync(dir)) {
|
|
127
|
+
const m = name.match(FILENAME);
|
|
128
|
+
if (!m) continue;
|
|
129
|
+
const filePath = path.join(dir, name);
|
|
130
|
+
items.push(parseItemHeader(filePath, m[1], m[2]));
|
|
131
|
+
}
|
|
132
|
+
items.sort((a, b) => a.id.localeCompare(b.id));
|
|
133
|
+
return items;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseItemHeader(filePath, id, slug) {
|
|
137
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
138
|
+
const lines = raw.split('\n');
|
|
139
|
+
let repo = '';
|
|
140
|
+
let asked = '';
|
|
141
|
+
let status = 'pending';
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
let m;
|
|
144
|
+
if ((m = line.match(/^- Repo:\s*`([^`]+)`/))) repo = m[1];
|
|
145
|
+
else if ((m = line.match(/^- Asked at:\s*(.+)$/))) asked = m[1].trim();
|
|
146
|
+
else if ((m = line.match(/^- Status:\s*(.+)$/))) status = m[1].trim();
|
|
147
|
+
}
|
|
148
|
+
return { id, slug, repo, asked, status, filePath };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Conductor-side: resolve an item with the user's answer. Moves the
|
|
153
|
+
* file into resolved/ with the answer + ts appended.
|
|
154
|
+
*/
|
|
155
|
+
function resolveInboxItem(args) {
|
|
156
|
+
const { ecosystemRoot, swarmId, id, answer, nowIso } = args;
|
|
157
|
+
if (typeof id !== 'string' || !/^\d{4}$/.test(id)) {
|
|
158
|
+
throw new TypeError(`resolveInboxItem: invalid id ${JSON.stringify(id)}`);
|
|
159
|
+
}
|
|
160
|
+
if (typeof answer !== 'string' || answer.length === 0) {
|
|
161
|
+
throw new TypeError('resolveInboxItem: answer required');
|
|
162
|
+
}
|
|
163
|
+
ensureLayout(ecosystemRoot, swarmId);
|
|
164
|
+
const dir = inboxDir(ecosystemRoot, swarmId);
|
|
165
|
+
const match = fs.readdirSync(dir).find((n) => n.startsWith(`${id}-`) && n.endsWith('.md'));
|
|
166
|
+
if (!match) throw new Error(`resolveInboxItem: no pending inbox item ${id}`);
|
|
167
|
+
const fromPath = path.join(dir, match);
|
|
168
|
+
const toPath = path.join(resolvedDir(ecosystemRoot, swarmId), match);
|
|
169
|
+
|
|
170
|
+
const raw = fs.readFileSync(fromPath, 'utf8');
|
|
171
|
+
const updated =
|
|
172
|
+
raw.replace(/^- Status: pending\b/m, '- Status: resolved') +
|
|
173
|
+
`\n## Answer (resolved at ${nowIso})\n\n${answer.trim()}\n`;
|
|
174
|
+
|
|
175
|
+
manifestLib.withLock(fromPath, () => {
|
|
176
|
+
fs.writeFileSync(toPath, updated, 'utf8');
|
|
177
|
+
fs.unlinkSync(fromPath);
|
|
178
|
+
});
|
|
179
|
+
rebuildIndex(ecosystemRoot, swarmId);
|
|
180
|
+
|
|
181
|
+
manifestLib.appendAudit(ecosystemRoot, swarmId, {
|
|
182
|
+
ts: nowIso, actor: 'conductor', event: 'inbox-resolved',
|
|
183
|
+
detail: `${id} — ${answer.slice(0, 200)}`,
|
|
184
|
+
});
|
|
185
|
+
return { id, resolvedPath: toPath };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Regenerate inbox/INDEX.md from pending items. Cheap — runs on every
|
|
190
|
+
* write/resolve.
|
|
191
|
+
*/
|
|
192
|
+
function rebuildIndex(ecosystemRoot, swarmId) {
|
|
193
|
+
ensureLayout(ecosystemRoot, swarmId);
|
|
194
|
+
const items = listPendingInboxItems(ecosystemRoot, swarmId);
|
|
195
|
+
const lines = ['# Inbox — pending items', ''];
|
|
196
|
+
if (items.length === 0) {
|
|
197
|
+
lines.push('_(no pending items)_');
|
|
198
|
+
} else {
|
|
199
|
+
lines.push('| ID | Repo | Slug | Asked at |');
|
|
200
|
+
lines.push('|----|------|------|----------|');
|
|
201
|
+
for (const it of items) {
|
|
202
|
+
lines.push(`| ${it.id} | ${it.repo} | ${it.slug} | ${it.asked} |`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
lines.push('');
|
|
206
|
+
fs.writeFileSync(indexPath(ecosystemRoot, swarmId), lines.join('\n'), 'utf8');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
INBOX_DIR,
|
|
211
|
+
RESOLVED_DIR,
|
|
212
|
+
INDEX_FILENAME,
|
|
213
|
+
inboxDir,
|
|
214
|
+
resolvedDir,
|
|
215
|
+
indexPath,
|
|
216
|
+
ensureLayout,
|
|
217
|
+
nextInboxId,
|
|
218
|
+
writeInboxItem,
|
|
219
|
+
listPendingInboxItems,
|
|
220
|
+
resolveInboxItem,
|
|
221
|
+
rebuildIndex,
|
|
222
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* /swarm join <swarm-id> — register a session with an existing swarm.
|
|
5
|
+
*
|
|
6
|
+
* Phase 17.5 portability primitive. Three call shapes:
|
|
7
|
+
*
|
|
8
|
+
* join(swarmId, sessionId)
|
|
9
|
+
* Registration only. Adds to sessions[] (idempotent — touch on
|
|
10
|
+
* re-join), auto-renews any repos this session already owns.
|
|
11
|
+
*
|
|
12
|
+
* join(swarmId, sessionId, { token })
|
|
13
|
+
* Consume an opaque transfer token. If kind=focus, claim ONLY the
|
|
14
|
+
* token's target_repo (FOCUSING sentinel allows takeover). If
|
|
15
|
+
* kind=join, no automatic claim — equivalent to plain registration.
|
|
16
|
+
*
|
|
17
|
+
* join(swarmId, sessionId, { claim: <repo> })
|
|
18
|
+
* Explicit claim shortcut. Composes `manifestLib.updateManifestAsOwner`
|
|
19
|
+
* on the named repo — bound by the same lease rules as `/swarm claim`.
|
|
20
|
+
*
|
|
21
|
+
* Returns: {
|
|
22
|
+
* swarmId,
|
|
23
|
+
* sessionId,
|
|
24
|
+
* sessions: <new sessions[] array>,
|
|
25
|
+
* claimed?: { repo, owner, lease_expires_at },
|
|
26
|
+
* token?: <consumed token record>,
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* Throws on:
|
|
30
|
+
* - swarm missing
|
|
31
|
+
* - token missing / expired
|
|
32
|
+
* - claim rejected (EOWNERSHIP propagates)
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const manifestLib = require('./lib/manifest');
|
|
36
|
+
const tokensLib = require('./lib/tokens');
|
|
37
|
+
const sessionsLib = require('./lib/sessions');
|
|
38
|
+
const boardLib = require('./lib/board');
|
|
39
|
+
|
|
40
|
+
const DEFAULT_LEASE_MS = 24 * 60 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
function join(args) {
|
|
43
|
+
const {
|
|
44
|
+
ecosystemRoot, swarmId, sessionId, nowIso,
|
|
45
|
+
token: tokenStr, claim: claimRepo,
|
|
46
|
+
leaseMs = DEFAULT_LEASE_MS,
|
|
47
|
+
} = args;
|
|
48
|
+
|
|
49
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
50
|
+
throw new TypeError('join: sessionId required');
|
|
51
|
+
}
|
|
52
|
+
if (typeof nowIso !== 'string' || nowIso.length === 0) {
|
|
53
|
+
throw new TypeError('join: nowIso required');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
|
|
57
|
+
if (!manifest) throw new Error(`join: swarm ${swarmId} not found at ${ecosystemRoot}`);
|
|
58
|
+
|
|
59
|
+
// 1. Register / touch session
|
|
60
|
+
sessionsLib.touchSession(ecosystemRoot, swarmId, sessionId, nowIso);
|
|
61
|
+
|
|
62
|
+
let claimed = null;
|
|
63
|
+
let consumedToken = null;
|
|
64
|
+
let claimTargetRepo = claimRepo || null;
|
|
65
|
+
|
|
66
|
+
// 2. Optional token consumption — may set claimTargetRepo
|
|
67
|
+
if (tokenStr) {
|
|
68
|
+
consumedToken = tokensLib.consumeToken(ecosystemRoot, swarmId, tokenStr, nowIso);
|
|
69
|
+
if (consumedToken.kind === 'focus') {
|
|
70
|
+
// Focus token narrows the claim to its target_repo
|
|
71
|
+
claimTargetRepo = consumedToken.target_repo;
|
|
72
|
+
}
|
|
73
|
+
// kind=join: no implicit claim, registration is enough
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 3. Optional / token-implied repo claim
|
|
77
|
+
if (claimTargetRepo) {
|
|
78
|
+
const expiresAt = isoFromMs(Date.parse(nowIso) + leaseMs);
|
|
79
|
+
manifestLib.updateManifestAsOwner({
|
|
80
|
+
ecosystemRoot, swarmId, sessionId, repo: claimTargetRepo, nowIso,
|
|
81
|
+
mutate: (m, decision) => {
|
|
82
|
+
const previousOwner = m.repos[claimTargetRepo].owner;
|
|
83
|
+
m.repos[claimTargetRepo].owner = sessionId;
|
|
84
|
+
m.repos[claimTargetRepo].claimed_by_session = sessionId;
|
|
85
|
+
m.repos[claimTargetRepo].lease_renewed_at = nowIso;
|
|
86
|
+
m.repos[claimTargetRepo].lease_expires_at = expiresAt;
|
|
87
|
+
if (!Array.isArray(m.audit)) m.audit = [];
|
|
88
|
+
m.audit.push({
|
|
89
|
+
ts: nowIso, actor: sessionId, event: 'claim', repo: claimTargetRepo,
|
|
90
|
+
detail: `${previousOwner} → ${sessionId} (via join${consumedToken ? ` --token kind=${consumedToken.kind}` : ''})`,
|
|
91
|
+
});
|
|
92
|
+
if (decision.expired) {
|
|
93
|
+
m.audit.push({
|
|
94
|
+
ts: nowIso, actor: sessionId, event: 'lease-takeover', repo: claimTargetRepo,
|
|
95
|
+
detail: `previous owner ${previousOwner} lease expired`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
claimed = { repo: claimTargetRepo, owner: sessionId, lease_expires_at: expiresAt };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 4. Audit-log the join itself
|
|
104
|
+
manifestLib.appendAudit(ecosystemRoot, swarmId, {
|
|
105
|
+
ts: nowIso, actor: sessionId, event: 'join',
|
|
106
|
+
detail: tokenStr
|
|
107
|
+
? `via token kind=${consumedToken.kind}${claimTargetRepo ? ` (claimed ${claimTargetRepo})` : ''}`
|
|
108
|
+
: (claimRepo ? `with --claim ${claimRepo}` : 'registration only'),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// 5. Auto-renew owned-repo leases. Pulls conductor.renewLeases without
|
|
112
|
+
// requiring it as a hard dep — the conductor module does require
|
|
113
|
+
// this file's siblings, so we lazy-require here to avoid cycles.
|
|
114
|
+
const conductor = require('./conductor');
|
|
115
|
+
conductor.renewLeases(ecosystemRoot, swarmId, sessionId, nowIso);
|
|
116
|
+
|
|
117
|
+
// 6. Refresh board
|
|
118
|
+
boardLib.refreshBoard(ecosystemRoot, swarmId, nowIso);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
swarmId,
|
|
122
|
+
sessionId,
|
|
123
|
+
sessions: sessionsLib.listSessions(ecosystemRoot, swarmId),
|
|
124
|
+
claimed,
|
|
125
|
+
token: consumedToken,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isoFromMs(ms) {
|
|
130
|
+
// Match the same no-millis format `bin/swarm.js::nowIso()` produces so
|
|
131
|
+
// joined manifest timestamps look consistent across writers.
|
|
132
|
+
return new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
join,
|
|
137
|
+
DEFAULT_LEASE_MS,
|
|
138
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Materialized board cache for swarms.
|
|
5
|
+
*
|
|
6
|
+
* Strategy A from the indexing design: the conductor reads ONLY
|
|
7
|
+
* board.json on most turns. Inputs: manifest.json + per-repo
|
|
8
|
+
* dispatch-run-NNN.json records. Output: board.json (≈1–3KB).
|
|
9
|
+
*
|
|
10
|
+
* Pre-rendered display strings keep the conductor stateless — no
|
|
11
|
+
* computation per turn. The materializer is invoked from any code path
|
|
12
|
+
* that mutates manifest or supervisor saga records.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const manifestLib = require('./manifest');
|
|
19
|
+
|
|
20
|
+
const BOARD_FILENAME = 'board.json';
|
|
21
|
+
|
|
22
|
+
function boardPath(ecosystemRoot, swarmId) {
|
|
23
|
+
return path.join(manifestLib.swarmDir(ecosystemRoot, swarmId), BOARD_FILENAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// Render helpers
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function tasksDisplay(done, total) {
|
|
31
|
+
if (done == null && total == null) return '';
|
|
32
|
+
return `${done || 0}/${total || 0}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tokensDisplay(used, budget) {
|
|
36
|
+
const k = (n) => `${Math.round((n || 0) / 1000)}k`;
|
|
37
|
+
if (used == null && budget == null) return '';
|
|
38
|
+
return `${k(used)}/${k(budget)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function shortTs(iso) {
|
|
42
|
+
// "2026-06-12T17:42:11Z" → "17:42"
|
|
43
|
+
if (typeof iso !== 'string') return '';
|
|
44
|
+
const m = iso.match(/T(\d{2}):(\d{2})/);
|
|
45
|
+
return m ? `${m[1]}:${m[2]}` : '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function deriveOverallStatus(manifest) {
|
|
49
|
+
const repos = Object.values(manifest.repos || {});
|
|
50
|
+
if (repos.length === 0) return 'running';
|
|
51
|
+
if (repos.every((r) => r.status === 'complete')) return 'complete';
|
|
52
|
+
if (repos.some((r) => r.status === 'failed')) return 'failed';
|
|
53
|
+
if (repos.every((r) => r.status === 'cancelled' || r.status === 'complete')
|
|
54
|
+
&& repos.some((r) => r.status === 'cancelled')) {
|
|
55
|
+
return 'cancelled';
|
|
56
|
+
}
|
|
57
|
+
return manifest.status || 'running';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function activeWave(manifest) {
|
|
61
|
+
const waves = manifest.waves || [];
|
|
62
|
+
for (const w of waves) {
|
|
63
|
+
if (w.status === 'running') return w.index;
|
|
64
|
+
}
|
|
65
|
+
for (const w of waves) {
|
|
66
|
+
if (w.status === 'queued' || w.status === undefined) return w.index;
|
|
67
|
+
}
|
|
68
|
+
// All complete — return last wave's index
|
|
69
|
+
return waves.length ? waves[waves.length - 1].index : 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function inboxCount(ecosystemRoot, swarmId) {
|
|
73
|
+
const inboxDir = path.join(manifestLib.swarmDir(ecosystemRoot, swarmId), 'inbox');
|
|
74
|
+
if (!fs.existsSync(inboxDir)) return 0;
|
|
75
|
+
return fs.readdirSync(inboxDir)
|
|
76
|
+
.filter((n) => /^\d{4}-[a-z][a-z0-9-]*\.md$/.test(n))
|
|
77
|
+
.length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
// Materialization
|
|
82
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Derive a board cache from manifest state. Pure function over manifest
|
|
86
|
+
* — disk reads are limited to the inbox dir for the count. `nowIso` is
|
|
87
|
+
* injected for determinism (the conductor passes current time; tests
|
|
88
|
+
* pass a fixed value).
|
|
89
|
+
*/
|
|
90
|
+
function materializeBoard(ecosystemRoot, swarmId, manifest, nowIso) {
|
|
91
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
92
|
+
throw new TypeError('materializeBoard: manifest must be an object');
|
|
93
|
+
}
|
|
94
|
+
const board = {
|
|
95
|
+
swarm_id: manifest.swarm_id,
|
|
96
|
+
version: 1,
|
|
97
|
+
saga_id: manifest.saga_id,
|
|
98
|
+
mode: manifest.mode,
|
|
99
|
+
rendered_at: nowIso,
|
|
100
|
+
status: deriveOverallStatus(manifest),
|
|
101
|
+
active_wave: activeWave(manifest),
|
|
102
|
+
repos: [],
|
|
103
|
+
inbox_count: inboxCount(ecosystemRoot, swarmId),
|
|
104
|
+
recent_activity: [],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for (const [name, r] of Object.entries(manifest.repos || {})) {
|
|
108
|
+
const row = {
|
|
109
|
+
name,
|
|
110
|
+
wave: r.wave,
|
|
111
|
+
status: r.status,
|
|
112
|
+
};
|
|
113
|
+
if (r.tasks_done != null || r.tasks_total != null) {
|
|
114
|
+
row.tasks = tasksDisplay(r.tasks_done, r.tasks_total);
|
|
115
|
+
}
|
|
116
|
+
if (r.tokens_used != null || r.tokens_budget != null) {
|
|
117
|
+
row.tokens = tokensDisplay(r.tokens_used, r.tokens_budget);
|
|
118
|
+
}
|
|
119
|
+
if (r.commits != null) row.commits = r.commits;
|
|
120
|
+
if (r.current_task) row.current = r.current_task;
|
|
121
|
+
if (r.inbox_count != null) row.inbox_count = r.inbox_count;
|
|
122
|
+
if (Array.isArray(r.waiting_on) && r.waiting_on.length) {
|
|
123
|
+
row.waiting_on = r.waiting_on.join(', ');
|
|
124
|
+
}
|
|
125
|
+
board.repos.push(row);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (Array.isArray(manifest.audit)) {
|
|
129
|
+
const tail = manifest.audit.slice(-10).reverse();
|
|
130
|
+
for (const a of tail) {
|
|
131
|
+
const msg = a.detail || a.event;
|
|
132
|
+
const entry = { ts: shortTs(a.ts), msg };
|
|
133
|
+
if (a.repo) entry.repo = a.repo;
|
|
134
|
+
board.recent_activity.push(entry);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return board;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function writeBoard(ecosystemRoot, swarmId, board) {
|
|
142
|
+
const file = boardPath(ecosystemRoot, swarmId);
|
|
143
|
+
manifestLib.ensureSwarmLayout(ecosystemRoot, swarmId);
|
|
144
|
+
return manifestLib.withLock(file, () => {
|
|
145
|
+
fs.writeFileSync(file, JSON.stringify(board, null, 2) + '\n', 'utf8');
|
|
146
|
+
return file;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function loadBoard(ecosystemRoot, swarmId) {
|
|
151
|
+
const file = boardPath(ecosystemRoot, swarmId);
|
|
152
|
+
if (!fs.existsSync(file)) return null;
|
|
153
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Convenience: materialize from current manifest + write atomically.
|
|
158
|
+
*/
|
|
159
|
+
function refreshBoard(ecosystemRoot, swarmId, nowIso) {
|
|
160
|
+
const manifest = manifestLib.loadManifest(ecosystemRoot, swarmId);
|
|
161
|
+
if (!manifest) throw new Error(`refreshBoard: no manifest for swarm ${swarmId}`);
|
|
162
|
+
const board = materializeBoard(ecosystemRoot, swarmId, manifest, nowIso);
|
|
163
|
+
writeBoard(ecosystemRoot, swarmId, board);
|
|
164
|
+
return board;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
BOARD_FILENAME,
|
|
169
|
+
boardPath,
|
|
170
|
+
materializeBoard,
|
|
171
|
+
writeBoard,
|
|
172
|
+
loadBoard,
|
|
173
|
+
refreshBoard,
|
|
174
|
+
deriveOverallStatus,
|
|
175
|
+
activeWave,
|
|
176
|
+
tasksDisplay,
|
|
177
|
+
tokensDisplay,
|
|
178
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase-brief frontmatter — swarm-context extension.
|
|
5
|
+
*
|
|
6
|
+
* A solo (non-swarm) phase brief has NO frontmatter — it's plain
|
|
7
|
+
* markdown beginning with `# Phase N — ...`. A swarm-member phase
|
|
8
|
+
* brief MAY have an optional YAML frontmatter block declaring its
|
|
9
|
+
* swarm context. The fields are:
|
|
10
|
+
*
|
|
11
|
+
* swarm: NNNN-<slug> # swarm directory name
|
|
12
|
+
* wave: <integer> # 1-based wave index
|
|
13
|
+
* initiative: <slug> # /eco/initiatives/<NNNN>-<slug>.md
|
|
14
|
+
* claimed_by_session: <uuid> # current owning session
|
|
15
|
+
*
|
|
16
|
+
* All four are optional individually — a brief with `swarm:` but no
|
|
17
|
+
* `wave:` is malformed and validation fails. Solo briefs (no
|
|
18
|
+
* frontmatter at all) are byte-shape-compatible with v0.19.0.
|
|
19
|
+
*
|
|
20
|
+
* This module is intentionally narrow: parseSwarmFrontmatter,
|
|
21
|
+
* serializeSwarmFrontmatter, validateSwarmFrontmatter,
|
|
22
|
+
* injectSwarmFrontmatter (rewrites an existing brief atomically). It
|
|
23
|
+
* does NOT duplicate the broader markdown parsing already in
|
|
24
|
+
* ecosystem/lib/initiative.js — it's a focused helper for the
|
|
25
|
+
* supervisor recipe + /validate.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
|
|
30
|
+
const FENCE = '---';
|
|
31
|
+
|
|
32
|
+
const SLUG = /^[a-z][a-z0-9-]*$/;
|
|
33
|
+
const SWARM_ID = /^[0-9]{4}-[a-z][a-z0-9-]*$/;
|
|
34
|
+
|
|
35
|
+
const SWARM_FIELDS = Object.freeze([
|
|
36
|
+
'swarm',
|
|
37
|
+
'wave',
|
|
38
|
+
'initiative',
|
|
39
|
+
'claimed_by_session',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
function parseSwarmFrontmatter(content) {
|
|
43
|
+
if (typeof content !== 'string') {
|
|
44
|
+
return { frontmatter: null, body: content == null ? '' : String(content) };
|
|
45
|
+
}
|
|
46
|
+
const trimmed = content.replace(/^/, '');
|
|
47
|
+
if (!trimmed.startsWith(FENCE)) {
|
|
48
|
+
return { frontmatter: null, body: content };
|
|
49
|
+
}
|
|
50
|
+
const lines = trimmed.split('\n');
|
|
51
|
+
let end = -1;
|
|
52
|
+
for (let i = 1; i < lines.length; i++) {
|
|
53
|
+
if (lines[i].trim() === FENCE) { end = i; break; }
|
|
54
|
+
}
|
|
55
|
+
if (end === -1) return { frontmatter: null, body: content };
|
|
56
|
+
const fm = {};
|
|
57
|
+
for (const raw of lines.slice(1, end)) {
|
|
58
|
+
const line = raw.trim();
|
|
59
|
+
if (!line || line.startsWith('#')) continue;
|
|
60
|
+
const colon = line.indexOf(':');
|
|
61
|
+
if (colon === -1) continue;
|
|
62
|
+
const key = line.slice(0, colon).trim();
|
|
63
|
+
let value = line.slice(colon + 1).trim();
|
|
64
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
65
|
+
value = value.slice(1, -1);
|
|
66
|
+
} else if (/^-?\d+$/.test(value)) {
|
|
67
|
+
value = parseInt(value, 10);
|
|
68
|
+
}
|
|
69
|
+
fm[key] = value;
|
|
70
|
+
}
|
|
71
|
+
const body = lines.slice(end + 1).join('\n').replace(/^\n+/, '');
|
|
72
|
+
return { frontmatter: fm, body };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function serializeSwarmFrontmatter(fm) {
|
|
76
|
+
const lines = [FENCE];
|
|
77
|
+
for (const key of SWARM_FIELDS) {
|
|
78
|
+
if (fm[key] === undefined || fm[key] === null) continue;
|
|
79
|
+
const v = fm[key];
|
|
80
|
+
if (typeof v === 'number') {
|
|
81
|
+
lines.push(`${key}: ${v}`);
|
|
82
|
+
} else {
|
|
83
|
+
const s = String(v);
|
|
84
|
+
lines.push(`${key}: ${s}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
lines.push(FENCE);
|
|
88
|
+
return lines.join('\n') + '\n';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validateSwarmFrontmatter(fm) {
|
|
92
|
+
const errors = [];
|
|
93
|
+
if (fm == null) return { ok: true }; // solo brief — no frontmatter required
|
|
94
|
+
if (typeof fm !== 'object' || Array.isArray(fm)) {
|
|
95
|
+
return { ok: false, errors: [{ path: '$', message: 'frontmatter must be an object' }] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// If ANY swarm field is present, swarm + wave are both required (the
|
|
99
|
+
// pair locks the brief into a swarm context).
|
|
100
|
+
const present = SWARM_FIELDS.filter((k) => fm[k] !== undefined);
|
|
101
|
+
const anyPresent = present.length > 0;
|
|
102
|
+
if (anyPresent) {
|
|
103
|
+
if (typeof fm.swarm !== 'string' || !SWARM_ID.test(fm.swarm)) {
|
|
104
|
+
errors.push({ path: '$.swarm', message: 'required when any swarm field is set; must match NNNN-<slug>' });
|
|
105
|
+
}
|
|
106
|
+
if (!Number.isInteger(fm.wave) || fm.wave < 1) {
|
|
107
|
+
errors.push({ path: '$.wave', message: 'required when any swarm field is set; positive integer' });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (fm.initiative !== undefined) {
|
|
112
|
+
if (typeof fm.initiative !== 'string' || !SLUG.test(fm.initiative)) {
|
|
113
|
+
errors.push({ path: '$.initiative', message: 'must be a slug' });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (fm.claimed_by_session !== undefined) {
|
|
117
|
+
if (typeof fm.claimed_by_session !== 'string' || fm.claimed_by_session.length === 0) {
|
|
118
|
+
errors.push({ path: '$.claimed_by_session', message: 'must be a non-empty string' });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Atomically inject (or update) swarm frontmatter in an existing brief
|
|
127
|
+
* file. Preserves the body verbatim. Validates the merged frontmatter
|
|
128
|
+
* before writing.
|
|
129
|
+
*
|
|
130
|
+
* If the brief has no frontmatter, prepends a new block. If it already
|
|
131
|
+
* has frontmatter, merges (new keys override existing).
|
|
132
|
+
*/
|
|
133
|
+
function injectSwarmFrontmatter(filePath, swarmFields) {
|
|
134
|
+
if (typeof filePath !== 'string' || filePath.length === 0) {
|
|
135
|
+
throw new TypeError('injectSwarmFrontmatter: filePath required');
|
|
136
|
+
}
|
|
137
|
+
const exists = fs.existsSync(filePath);
|
|
138
|
+
const raw = exists ? fs.readFileSync(filePath, 'utf8') : '';
|
|
139
|
+
const { frontmatter, body } = parseSwarmFrontmatter(raw);
|
|
140
|
+
const merged = Object.assign({}, frontmatter || {}, swarmFields);
|
|
141
|
+
const v = validateSwarmFrontmatter(merged);
|
|
142
|
+
if (!v.ok) {
|
|
143
|
+
const summary = v.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
|
|
144
|
+
throw new Error(`injectSwarmFrontmatter: validation failed:\n${summary}`);
|
|
145
|
+
}
|
|
146
|
+
const fmBlock = serializeSwarmFrontmatter(merged);
|
|
147
|
+
fs.writeFileSync(filePath, fmBlock + body, 'utf8');
|
|
148
|
+
return merged;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
SWARM_FIELDS,
|
|
153
|
+
parseSwarmFrontmatter,
|
|
154
|
+
serializeSwarmFrontmatter,
|
|
155
|
+
validateSwarmFrontmatter,
|
|
156
|
+
injectSwarmFrontmatter,
|
|
157
|
+
};
|