@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,327 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scout — read-only single-repo context fetch.
|
|
4
|
+
//
|
|
5
|
+
// Walks the target repo's spec tree + recent history, summarises what
|
|
6
|
+
// it finds relative to the user's prompt, and returns a structured
|
|
7
|
+
// ScoutResult. Cheap-layer side effects: writes a scout-NNN.md run
|
|
8
|
+
// artifact and appends one line to the ecosystem session log.
|
|
9
|
+
//
|
|
10
|
+
// In-process mode (CLI floor): file-system walker only. Returns a
|
|
11
|
+
// summary stitched from file contents — no LLM synthesis available
|
|
12
|
+
// from raw Node.
|
|
13
|
+
//
|
|
14
|
+
// In-agent mode (invoked from a slash command on Claude Code / Codex /
|
|
15
|
+
// Antigravity): the agent does the reading + summarising in its own
|
|
16
|
+
// context (via Task tool or equivalent). The agent then calls back
|
|
17
|
+
// into this library with a structured result to record the artifact +
|
|
18
|
+
// session log line.
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const eventsLib = require('./events');
|
|
24
|
+
const runArtifact = require('./run-artifact');
|
|
25
|
+
const sessionLog = require('./session-log');
|
|
26
|
+
const types = require('./types');
|
|
27
|
+
|
|
28
|
+
const DEFAULT_SCAN_FILES = [
|
|
29
|
+
'specs/status.md',
|
|
30
|
+
'specs/backlog/backlog.md',
|
|
31
|
+
'README.md',
|
|
32
|
+
'CLAUDE.md',
|
|
33
|
+
'AGENTS.md',
|
|
34
|
+
'package.json',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const DEFAULT_SCAN_DIRS = [
|
|
38
|
+
'specs/architecture',
|
|
39
|
+
'specs/phases',
|
|
40
|
+
'specs/decisions',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const MAX_FILE_BYTES = 64 * 1024;
|
|
44
|
+
const MAX_FILES_READ = 30;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run a scout in-process against the target repo.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} opts
|
|
50
|
+
* @param {string} opts.repo absolute path to target repo
|
|
51
|
+
* @param {string} opts.prompt user-provided scope prompt
|
|
52
|
+
* @param {string} [opts.originatingRepo] absolute path to invoking repo (defaults to opts.repo)
|
|
53
|
+
* @param {object} [opts.adapter] adapter module (only used for events context)
|
|
54
|
+
* @param {object} [opts.emitter] pre-built event emitter (otherwise a quiet one is created)
|
|
55
|
+
* @param {object} [opts.ecosystem] { rootPath, memberId } — when present, session log is written
|
|
56
|
+
* @param {boolean} [opts.silent] suppress default renderer (tests)
|
|
57
|
+
* @returns {Promise<ScoutResult>}
|
|
58
|
+
*/
|
|
59
|
+
async function scout(opts) {
|
|
60
|
+
const { repo, prompt } = opts;
|
|
61
|
+
if (!repo || !prompt) {
|
|
62
|
+
throw new Error('orchestration/scout: opts.repo and opts.prompt are required');
|
|
63
|
+
}
|
|
64
|
+
const originatingRepo = opts.originatingRepo || repo;
|
|
65
|
+
const start = Date.now();
|
|
66
|
+
|
|
67
|
+
const emitter = opts.emitter || eventsLib.createEmitter({ primitive: 'scout', repo });
|
|
68
|
+
if (!opts.emitter && !opts.silent) {
|
|
69
|
+
emitter.on(eventsLib.createRenderer({ stream: process.stdout }));
|
|
70
|
+
}
|
|
71
|
+
if (opts.ecosystem) {
|
|
72
|
+
emitter.on(eventsLib.createPersister({
|
|
73
|
+
ecosystemRoot: opts.ecosystem.rootPath,
|
|
74
|
+
memberId: opts.ecosystem.memberId,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
emitter.emit('started', { message: prompt });
|
|
79
|
+
|
|
80
|
+
const filesRead = [];
|
|
81
|
+
const sections = [];
|
|
82
|
+
|
|
83
|
+
for (const rel of DEFAULT_SCAN_FILES) {
|
|
84
|
+
const abs = path.join(repo, rel);
|
|
85
|
+
if (!fs.existsSync(abs)) continue;
|
|
86
|
+
if (filesRead.length >= MAX_FILES_READ) break;
|
|
87
|
+
const content = readClampedFile(abs);
|
|
88
|
+
if (content == null) continue;
|
|
89
|
+
filesRead.push(rel);
|
|
90
|
+
emitter.emit('step', { message: `reading ${rel}` });
|
|
91
|
+
const snippet = extractRelevantSnippet(content, prompt);
|
|
92
|
+
if (snippet) sections.push({ source: rel, snippet });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const relDir of DEFAULT_SCAN_DIRS) {
|
|
96
|
+
if (filesRead.length >= MAX_FILES_READ) break;
|
|
97
|
+
const absDir = path.join(repo, relDir);
|
|
98
|
+
if (!fs.existsSync(absDir)) continue;
|
|
99
|
+
for (const file of listMarkdownFiles(absDir, MAX_FILES_READ - filesRead.length)) {
|
|
100
|
+
const abs = path.join(absDir, file);
|
|
101
|
+
const rel = path.join(relDir, file);
|
|
102
|
+
const content = readClampedFile(abs);
|
|
103
|
+
if (content == null) continue;
|
|
104
|
+
filesRead.push(rel);
|
|
105
|
+
emitter.emit('step', { message: `reading ${rel}` });
|
|
106
|
+
const snippet = extractRelevantSnippet(content, prompt);
|
|
107
|
+
if (snippet) sections.push({ source: rel, snippet });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const summary = renderSummary({ repo, prompt, sections });
|
|
112
|
+
const findings = []; // in-process scout does not infer findings; agent-driven scout adds them via record()
|
|
113
|
+
const duration = Date.now() - start;
|
|
114
|
+
|
|
115
|
+
const { runId, artifactPath } = runArtifact.writeWithAllocatedId({
|
|
116
|
+
repo: originatingRepo,
|
|
117
|
+
primitive: 'scout',
|
|
118
|
+
bodyFor: (id) => renderArtifact({ runId: id, repo, prompt, filesRead, sections, summary, findings, duration }),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
emitter.context.runId = runId;
|
|
122
|
+
emitter.emit('finished', { duration, runArtifactPath: artifactPath });
|
|
123
|
+
|
|
124
|
+
const result = {
|
|
125
|
+
repo,
|
|
126
|
+
prompt,
|
|
127
|
+
summary,
|
|
128
|
+
findings,
|
|
129
|
+
filesRead,
|
|
130
|
+
duration,
|
|
131
|
+
runArtifactPath: artifactPath,
|
|
132
|
+
runId,
|
|
133
|
+
};
|
|
134
|
+
return types.validateScoutResult(result);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Record a scout that was performed by an external agent (e.g., a
|
|
139
|
+
* Claude Code subagent invoked via a slash command). The agent passes
|
|
140
|
+
* in the result fields; we write the artifact + log line uniformly so
|
|
141
|
+
* slash + CLI doors produce identical output shape.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} opts
|
|
144
|
+
* @param {string} opts.repo
|
|
145
|
+
* @param {string} opts.prompt
|
|
146
|
+
* @param {string} opts.summary
|
|
147
|
+
* @param {Finding[]} [opts.findings]
|
|
148
|
+
* @param {string[]} [opts.filesRead]
|
|
149
|
+
* @param {string} [opts.originatingRepo]
|
|
150
|
+
* @param {object} [opts.ecosystem]
|
|
151
|
+
* @param {number} [opts.duration]
|
|
152
|
+
* @returns {ScoutResult}
|
|
153
|
+
*/
|
|
154
|
+
function record(opts) {
|
|
155
|
+
const {
|
|
156
|
+
repo, prompt, summary,
|
|
157
|
+
findings = [], filesRead = [],
|
|
158
|
+
originatingRepo = repo,
|
|
159
|
+
ecosystem,
|
|
160
|
+
duration = 0,
|
|
161
|
+
} = opts;
|
|
162
|
+
if (!repo || !prompt || !summary) {
|
|
163
|
+
throw new Error('orchestration/scout.record: repo, prompt, summary required');
|
|
164
|
+
}
|
|
165
|
+
const { runId, artifactPath } = runArtifact.writeWithAllocatedId({
|
|
166
|
+
repo: originatingRepo,
|
|
167
|
+
primitive: 'scout',
|
|
168
|
+
bodyFor: (id) => renderArtifact({ runId: id, repo, prompt, filesRead, sections: [], summary, findings, duration }),
|
|
169
|
+
});
|
|
170
|
+
if (ecosystem && ecosystem.rootPath && ecosystem.memberId) {
|
|
171
|
+
sessionLog.appendLine({
|
|
172
|
+
ecosystemRoot: ecosystem.rootPath,
|
|
173
|
+
memberId: ecosystem.memberId,
|
|
174
|
+
kind: 'scout',
|
|
175
|
+
summary: `${repo}: ${prompt}`,
|
|
176
|
+
context: `run-${runId} → ${path.relative(originatingRepo, artifactPath)}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// Tracking contract: each finding proposed as [DISCOVERY] in the
|
|
180
|
+
// SCOUTED repo's active phase history when meaningful per Rule 3.
|
|
181
|
+
// No backlog.md writes — tracking returns a hint the caller can act
|
|
182
|
+
// on with explicit user confirmation.
|
|
183
|
+
const tracking = require('./tracking');
|
|
184
|
+
for (const finding of findings) {
|
|
185
|
+
tracking.proposeDiscovery({ primitive: 'scout', finding, targetRepo: repo });
|
|
186
|
+
}
|
|
187
|
+
const result = {
|
|
188
|
+
repo, prompt, summary,
|
|
189
|
+
findings, filesRead,
|
|
190
|
+
duration,
|
|
191
|
+
runArtifactPath: artifactPath,
|
|
192
|
+
runId,
|
|
193
|
+
};
|
|
194
|
+
return types.validateScoutResult(result);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
function readClampedFile(abs) {
|
|
200
|
+
try {
|
|
201
|
+
const stat = fs.statSync(abs);
|
|
202
|
+
if (!stat.isFile()) return null;
|
|
203
|
+
if (stat.size === 0) return '';
|
|
204
|
+
const buf = Buffer.alloc(Math.min(stat.size, MAX_FILE_BYTES));
|
|
205
|
+
const fd = fs.openSync(abs, 'r');
|
|
206
|
+
try {
|
|
207
|
+
const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
208
|
+
return buf.slice(0, bytes).toString('utf8');
|
|
209
|
+
} finally {
|
|
210
|
+
fs.closeSync(fd);
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function listMarkdownFiles(dir, max) {
|
|
218
|
+
const out = [];
|
|
219
|
+
function walk(rel) {
|
|
220
|
+
if (out.length >= max) return;
|
|
221
|
+
const abs = path.join(dir, rel);
|
|
222
|
+
let entries = [];
|
|
223
|
+
try {
|
|
224
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
225
|
+
} catch {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
for (const e of entries) {
|
|
229
|
+
if (out.length >= max) return;
|
|
230
|
+
if (e.name.startsWith('.') || e.name.startsWith('_')) continue;
|
|
231
|
+
const next = rel ? path.join(rel, e.name) : e.name;
|
|
232
|
+
if (e.isDirectory()) {
|
|
233
|
+
walk(next);
|
|
234
|
+
} else if (e.isFile() && /\.md$/.test(e.name)) {
|
|
235
|
+
out.push(next);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
walk('');
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function extractRelevantSnippet(content, prompt) {
|
|
244
|
+
const keywords = tokenize(prompt);
|
|
245
|
+
if (keywords.length === 0) {
|
|
246
|
+
return clampSnippet(content.split('\n').slice(0, 12).join('\n'));
|
|
247
|
+
}
|
|
248
|
+
const lines = content.split('\n');
|
|
249
|
+
const hits = [];
|
|
250
|
+
const lower = lines.map((l) => l.toLowerCase());
|
|
251
|
+
for (let i = 0; i < lines.length; i++) {
|
|
252
|
+
if (keywords.some((k) => lower[i].includes(k))) {
|
|
253
|
+
const start = Math.max(0, i - 2);
|
|
254
|
+
const end = Math.min(lines.length, i + 4);
|
|
255
|
+
hits.push(lines.slice(start, end).join('\n'));
|
|
256
|
+
if (hits.length >= 3) break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (hits.length === 0) return null;
|
|
260
|
+
return clampSnippet(hits.join('\n…\n'));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function tokenize(prompt) {
|
|
264
|
+
return prompt
|
|
265
|
+
.toLowerCase()
|
|
266
|
+
.split(/[^a-z0-9_-]+/i)
|
|
267
|
+
.filter((w) => w.length >= 3 && !STOPWORDS.has(w));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const STOPWORDS = new Set([
|
|
271
|
+
'the', 'and', 'for', 'with', 'this', 'that', 'from', 'are', 'was',
|
|
272
|
+
'have', 'has', 'will', 'what', 'how', 'why', 'when', 'where', 'shape',
|
|
273
|
+
'state', 'current', 'status',
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
function clampSnippet(s) {
|
|
277
|
+
if (s.length <= 800) return s;
|
|
278
|
+
return s.slice(0, 800) + '\n…[truncated]';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderSummary({ repo, prompt, sections }) {
|
|
282
|
+
if (sections.length === 0) {
|
|
283
|
+
return `No content matching "${prompt}" was found in ${repo}'s spec tree.`;
|
|
284
|
+
}
|
|
285
|
+
const head = `Found ${sections.length} relevant section(s) in ${repo} for prompt "${prompt}":`;
|
|
286
|
+
const bullets = sections.map((s) => ` - ${s.source}`).join('\n');
|
|
287
|
+
return `${head}\n${bullets}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function renderArtifact({ runId, repo, prompt, filesRead, sections, summary, findings, duration }) {
|
|
291
|
+
const lines = [
|
|
292
|
+
`# scout-${runId} — ${repo}`,
|
|
293
|
+
'',
|
|
294
|
+
`**Prompt:** ${prompt}`,
|
|
295
|
+
`**Duration:** ${duration}ms`,
|
|
296
|
+
`**Files read (${filesRead.length}):**`,
|
|
297
|
+
filesRead.length ? filesRead.map((f) => ` - ${f}`).join('\n') : ' (none)',
|
|
298
|
+
'',
|
|
299
|
+
'## Summary',
|
|
300
|
+
'',
|
|
301
|
+
summary,
|
|
302
|
+
'',
|
|
303
|
+
];
|
|
304
|
+
if (findings.length) {
|
|
305
|
+
lines.push('## Findings', '');
|
|
306
|
+
for (const f of findings) {
|
|
307
|
+
lines.push(`- **${f.kind || 'note'}**: ${f.title}`);
|
|
308
|
+
if (f.detail) lines.push(` ${f.detail}`);
|
|
309
|
+
}
|
|
310
|
+
lines.push('');
|
|
311
|
+
}
|
|
312
|
+
if (sections.length) {
|
|
313
|
+
lines.push('## Sections', '');
|
|
314
|
+
for (const s of sections) {
|
|
315
|
+
lines.push(`### ${s.source}`, '', '```', s.snippet, '```', '');
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return lines.join('\n');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = {
|
|
322
|
+
scout,
|
|
323
|
+
record,
|
|
324
|
+
// exported for tests:
|
|
325
|
+
extractRelevantSnippet,
|
|
326
|
+
tokenize,
|
|
327
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared session-log writer for orchestration primitives.
|
|
4
|
+
//
|
|
5
|
+
// Appends a single line to <ecosystem-root>/sessions/YYYY-MM-DD.md per
|
|
6
|
+
// primitive invocation. Uses the same mkdir-based lock pattern as
|
|
7
|
+
// `core/ecosystem/scripts/session-append.sh` (Phase 10 BUG-004 fix)
|
|
8
|
+
// so we don't race with the PostToolUse hook commits.
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
|
|
13
|
+
const LOCK_BUDGET_MS = 5000;
|
|
14
|
+
const LOCK_POLL_MS = 50;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Append a single event line to today's ecosystem session log.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} opts
|
|
20
|
+
* @param {string} opts.ecosystemRoot absolute path to ecosystem root
|
|
21
|
+
* @param {string} opts.memberId member id of the originating repo
|
|
22
|
+
* @param {string} opts.kind event kind, e.g. 'scout' | 'dispatch' | 'handoff'
|
|
23
|
+
* @param {string} opts.summary one-line summary (no embedded newlines)
|
|
24
|
+
* @param {string} [opts.context] optional context (run id, artifact path, …)
|
|
25
|
+
* @param {Date} [opts.now] override clock for tests
|
|
26
|
+
* @returns {{ sessionFile: string, line: string, written: boolean }}
|
|
27
|
+
*/
|
|
28
|
+
function appendLine(opts) {
|
|
29
|
+
const { ecosystemRoot, memberId, kind, summary, context = '', now = new Date() } = opts || {};
|
|
30
|
+
if (!ecosystemRoot || !memberId || !kind || !summary) {
|
|
31
|
+
return { sessionFile: '', line: '', written: false };
|
|
32
|
+
}
|
|
33
|
+
if (!fs.existsSync(path.join(ecosystemRoot, 'ecosystem.json'))) {
|
|
34
|
+
return { sessionFile: '', line: '', written: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sessionsDir = path.join(ecosystemRoot, 'sessions');
|
|
38
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
39
|
+
const today = isoDate(now);
|
|
40
|
+
const sessionFile = path.join(sessionsDir, `${today}.md`);
|
|
41
|
+
const hhmm = isoHourMinute(now);
|
|
42
|
+
|
|
43
|
+
const cleanSummary = summary.replace(/\r?\n/g, ' ').trim();
|
|
44
|
+
const cleanContext = context.replace(/\r?\n/g, ' ').trim();
|
|
45
|
+
const line = cleanContext
|
|
46
|
+
? `${hhmm}Z [${memberId}] ${kind}: ${cleanSummary} (${cleanContext})`
|
|
47
|
+
: `${hhmm}Z [${memberId}] ${kind}: ${cleanSummary}`;
|
|
48
|
+
|
|
49
|
+
const lockDir = `${sessionFile}.lock`;
|
|
50
|
+
if (!acquireLock(lockDir)) {
|
|
51
|
+
// Drop event silently rather than corrupt the file. Session events
|
|
52
|
+
// are advisory; momentum's correctness does not depend on every
|
|
53
|
+
// line landing.
|
|
54
|
+
return { sessionFile, line, written: false };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
if (!fs.existsSync(sessionFile)) {
|
|
59
|
+
const header = [`# Session ${today}`, ''];
|
|
60
|
+
const activePath = path.join(ecosystemRoot, '.state', 'active-initiative');
|
|
61
|
+
if (fs.existsSync(activePath)) {
|
|
62
|
+
const active = fs.readFileSync(activePath, 'utf8').trim();
|
|
63
|
+
if (active) header.splice(1, 0, `Active initiative: ${active}`);
|
|
64
|
+
}
|
|
65
|
+
fs.writeFileSync(sessionFile, header.join('\n') + '\n');
|
|
66
|
+
}
|
|
67
|
+
fs.appendFileSync(sessionFile, line + '\n');
|
|
68
|
+
|
|
69
|
+
const statePath = path.join(ecosystemRoot, '.state');
|
|
70
|
+
fs.mkdirSync(statePath, { recursive: true });
|
|
71
|
+
fs.writeFileSync(path.join(statePath, 'last-session'), today + '\n');
|
|
72
|
+
} finally {
|
|
73
|
+
releaseLock(lockDir);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { sessionFile, line, written: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function acquireLock(lockDir) {
|
|
80
|
+
const start = Date.now();
|
|
81
|
+
while (Date.now() - start < LOCK_BUDGET_MS) {
|
|
82
|
+
try {
|
|
83
|
+
fs.mkdirSync(lockDir);
|
|
84
|
+
return true;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err && err.code !== 'EEXIST') throw err;
|
|
87
|
+
// Busy-wait briefly. Acceptable here — the lock holder is on the
|
|
88
|
+
// same machine and releases within milliseconds in practice.
|
|
89
|
+
sleepSync(LOCK_POLL_MS);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function releaseLock(lockDir) {
|
|
96
|
+
try {
|
|
97
|
+
fs.rmdirSync(lockDir);
|
|
98
|
+
} catch {
|
|
99
|
+
// Lock directory may have already been released or never acquired.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sleepSync(ms) {
|
|
104
|
+
const end = Date.now() + ms;
|
|
105
|
+
while (Date.now() < end) {
|
|
106
|
+
// intentional spin — only used briefly during lock contention
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isoDate(d) {
|
|
111
|
+
return d.toISOString().slice(0, 10);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isoHourMinute(d) {
|
|
115
|
+
return d.toISOString().slice(11, 16);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
appendLine,
|
|
120
|
+
// Exported for tests:
|
|
121
|
+
acquireLock,
|
|
122
|
+
releaseLock,
|
|
123
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// tracking — meaningful-only writes to curated docs (history.md / backlog.md).
|
|
4
|
+
//
|
|
5
|
+
// Cheap layer (session log + run artifact + handoff inbox) is auto-
|
|
6
|
+
// written by the individual primitives. This module handles the
|
|
7
|
+
// curated layer:
|
|
8
|
+
//
|
|
9
|
+
// - proposeDiscovery({ primitive, finding, targetRepo })
|
|
10
|
+
// Applies Rule 3 criteria (real bug, real tech debt, real
|
|
11
|
+
// enhancement). If meaningful, appends a [DISCOVERY] entry to
|
|
12
|
+
// the target repo's active phase history. Otherwise no-op.
|
|
13
|
+
//
|
|
14
|
+
// - proposeHistoryNote({ primitive, originatingRepo, message,
|
|
15
|
+
// runArtifactRef })
|
|
16
|
+
// For dispatch synthesis or other meaningful cross-repo notes.
|
|
17
|
+
// Appends a [NOTE] in originating repo's active phase history.
|
|
18
|
+
//
|
|
19
|
+
// Hard invariants:
|
|
20
|
+
// - NO new entry types. Reuses [DISCOVERY] / [DECISION] / [NOTE].
|
|
21
|
+
// - NO writes to backlog.md without explicit user confirmation.
|
|
22
|
+
// We propose entries; caller decides whether to materialise them.
|
|
23
|
+
// - Empty/whitespace findings never make it through.
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
|
|
28
|
+
const handoffLib = require('./handoff');
|
|
29
|
+
|
|
30
|
+
const VALID_BACKLOG_PRIORITIES = new Set(['P0', 'P1', 'P2', 'P3']);
|
|
31
|
+
const VALID_BACKLOG_TYPES = new Set(['bug', 'tech-debt', 'enhancement', 'feature']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decide whether a Finding warrants a [DISCOVERY] entry in the target
|
|
35
|
+
* repo's active phase history.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} opts
|
|
38
|
+
* @param {string} opts.primitive 'scout' | 'dispatch'
|
|
39
|
+
* @param {Finding} opts.finding
|
|
40
|
+
* @param {string} opts.targetRepo where the finding was observed
|
|
41
|
+
* @returns {{
|
|
42
|
+
* shouldWrite: boolean,
|
|
43
|
+
* reason: string,
|
|
44
|
+
* historyEntry?: string,
|
|
45
|
+
* historyPath?: string,
|
|
46
|
+
* }}
|
|
47
|
+
*/
|
|
48
|
+
function proposeDiscovery(opts) {
|
|
49
|
+
const { primitive, finding, targetRepo } = opts;
|
|
50
|
+
if (!finding || typeof finding !== 'object') {
|
|
51
|
+
return { shouldWrite: false, reason: 'no finding provided' };
|
|
52
|
+
}
|
|
53
|
+
if (!finding.title || typeof finding.title !== 'string' || !finding.title.trim()) {
|
|
54
|
+
return { shouldWrite: false, reason: 'finding has no title' };
|
|
55
|
+
}
|
|
56
|
+
// Rule 3 thresholds — must look like a real backlog candidate.
|
|
57
|
+
if (
|
|
58
|
+
!VALID_BACKLOG_TYPES.has(finding.recommendedBacklogType) &&
|
|
59
|
+
!VALID_BACKLOG_PRIORITIES.has(finding.recommendedBacklogPriority)
|
|
60
|
+
) {
|
|
61
|
+
return {
|
|
62
|
+
shouldWrite: false,
|
|
63
|
+
reason: 'finding does not declare a recommendedBacklogType or recommendedBacklogPriority — not meaningful per Rule 3',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const activePhase = handoffLib.detectActivePhase(targetRepo);
|
|
68
|
+
if (!activePhase) {
|
|
69
|
+
return { shouldWrite: false, reason: 'target repo has no active phase to log into' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const historyPath = path.join(targetRepo, 'specs', 'phases', activePhase, 'history.md');
|
|
73
|
+
if (!fs.existsSync(historyPath)) {
|
|
74
|
+
return { shouldWrite: false, reason: `history.md missing at ${historyPath}` };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
78
|
+
const topics = ['orchestration', primitive, 'discovery'];
|
|
79
|
+
if (finding.recommendedBacklogType) topics.push(finding.recommendedBacklogType);
|
|
80
|
+
|
|
81
|
+
const entry = [
|
|
82
|
+
'',
|
|
83
|
+
`### [DISCOVERY] ${date} — ${finding.title}`,
|
|
84
|
+
`Topics: ${topics.join(', ')}`,
|
|
85
|
+
`Affects-phases: ${activePhase}`,
|
|
86
|
+
`Affects-specs: ${(finding.filesTouched || []).join(', ') || 'none'}`,
|
|
87
|
+
`Detail: ${finding.detail || finding.title}${formatBacklogHint(finding)}`,
|
|
88
|
+
'',
|
|
89
|
+
'---',
|
|
90
|
+
'',
|
|
91
|
+
].join('\n');
|
|
92
|
+
|
|
93
|
+
fs.appendFileSync(historyPath, entry);
|
|
94
|
+
return {
|
|
95
|
+
shouldWrite: true,
|
|
96
|
+
reason: 'meaningful per Rule 3',
|
|
97
|
+
historyEntry: entry,
|
|
98
|
+
historyPath,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Append a [NOTE] entry in the originating repo's active phase history.
|
|
104
|
+
*
|
|
105
|
+
* Used for dispatch synthesis and any other cross-repo orchestration
|
|
106
|
+
* note worth a future reader's time. Does NOT use a new entry type.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} opts
|
|
109
|
+
* @param {string} opts.primitive
|
|
110
|
+
* @param {string} opts.originatingRepo
|
|
111
|
+
* @param {string} opts.message one-line description
|
|
112
|
+
* @param {string} [opts.runArtifactRef] relative path to .momentum/runs/<primitive>-NNN.md
|
|
113
|
+
* @returns {{ shouldWrite: boolean, reason: string, historyEntry?: string, historyPath?: string }}
|
|
114
|
+
*/
|
|
115
|
+
function proposeHistoryNote(opts) {
|
|
116
|
+
const { primitive, originatingRepo, message, runArtifactRef } = opts;
|
|
117
|
+
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
118
|
+
return { shouldWrite: false, reason: 'empty message' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const activePhase = handoffLib.detectActivePhase(originatingRepo);
|
|
122
|
+
if (!activePhase) {
|
|
123
|
+
return { shouldWrite: false, reason: 'originating repo has no active phase' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const historyPath = path.join(originatingRepo, 'specs', 'phases', activePhase, 'history.md');
|
|
127
|
+
if (!fs.existsSync(historyPath)) {
|
|
128
|
+
return { shouldWrite: false, reason: `history.md missing at ${historyPath}` };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
132
|
+
const entry = [
|
|
133
|
+
'',
|
|
134
|
+
`### [NOTE] ${date} — ${primitive} synthesis`,
|
|
135
|
+
`Topics: orchestration, ${primitive}, synthesis`,
|
|
136
|
+
`Affects-phases: ${activePhase}`,
|
|
137
|
+
`Affects-specs: ${runArtifactRef || 'none'}`,
|
|
138
|
+
`Detail: ${message}`,
|
|
139
|
+
'',
|
|
140
|
+
'---',
|
|
141
|
+
'',
|
|
142
|
+
].join('\n');
|
|
143
|
+
|
|
144
|
+
fs.appendFileSync(historyPath, entry);
|
|
145
|
+
return {
|
|
146
|
+
shouldWrite: true,
|
|
147
|
+
reason: 'meaningful synthesis',
|
|
148
|
+
historyEntry: entry,
|
|
149
|
+
historyPath,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Helper: would this finding pass the Rule 3 threshold? Used by tests
|
|
155
|
+
* and orchestrator code that wants to decide BEFORE proposing.
|
|
156
|
+
*/
|
|
157
|
+
function isMeaningfulFinding(finding) {
|
|
158
|
+
if (!finding || typeof finding !== 'object') return false;
|
|
159
|
+
if (!finding.title || !finding.title.trim()) return false;
|
|
160
|
+
return (
|
|
161
|
+
VALID_BACKLOG_TYPES.has(finding.recommendedBacklogType) ||
|
|
162
|
+
VALID_BACKLOG_PRIORITIES.has(finding.recommendedBacklogPriority)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function formatBacklogHint(finding) {
|
|
167
|
+
const parts = [];
|
|
168
|
+
if (finding.recommendedBacklogType) parts.push(finding.recommendedBacklogType);
|
|
169
|
+
if (finding.recommendedBacklogPriority) parts.push(finding.recommendedBacklogPriority);
|
|
170
|
+
if (parts.length === 0) return '';
|
|
171
|
+
return ` (backlog candidate: ${parts.join(' / ')})`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
proposeDiscovery,
|
|
176
|
+
proposeHistoryNote,
|
|
177
|
+
isMeaningfulFinding,
|
|
178
|
+
VALID_BACKLOG_PRIORITIES,
|
|
179
|
+
VALID_BACKLOG_TYPES,
|
|
180
|
+
};
|