@polymorphism-tech/morph-spec 8.1.1 → 8.2.0
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/README.md +2 -2
- package/bin/morph-spec.js +15 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +6 -4
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +6 -3
- package/framework/MORPH.md +8 -2
- package/framework/hooks/claude-code/core/validate-completion.js +2 -1
- package/framework/hooks/claude-code/core/worktree-context.js +43 -0
- package/framework/hooks/shared/build-check.js +97 -24
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +2 -2
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/fleet.js +54 -0
- package/src/commands/project/worktree.js +56 -7
- package/src/commands/state/advance.js +8 -0
- package/src/commands/state/create.js +6 -0
- package/src/commands/state/handoff.js +49 -0
- package/src/lib/generators/handoff-generator.js +267 -0
- package/src/lib/generators/index.js +1 -3
- package/src/lib/validators/verify-runner.js +24 -7
- package/src/lib/worktree/env-allocator.js +149 -0
- package/src/lib/worktree/fleet-scanner.js +156 -0
- package/src/lib/context/context-builder.js +0 -289
- package/src/lib/generators/context-generator.js +0 -519
- package/src/lib/generators/mandate-generator.js +0 -468
- package/src/lib/generators/recap-generator.js +0 -285
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* env-allocator.js — deterministic per-worktree port-block allocation (fleet).
|
|
3
|
+
*
|
|
4
|
+
* Two parallel features that both start `next dev` (or `dotnet run`) collide on
|
|
5
|
+
* the same default port, and the failure is invisible until one server kills
|
|
6
|
+
* the other. Worktrees isolate files and branches, not runtime — this module
|
|
7
|
+
* closes that gap by giving every feature worktree its own stable block of
|
|
8
|
+
* ports.
|
|
9
|
+
*
|
|
10
|
+
* The registry lives at `<git-common-dir>/morph-spec/env-registry.json` — under
|
|
11
|
+
* `.git/` on purpose, for the same reasons as `finish-backup/`: it can never
|
|
12
|
+
* dirty the `git status` the worktree-finish gates read, it survives
|
|
13
|
+
* `git clean -fdx`, and the COMMON dir is the one location every linked
|
|
14
|
+
* worktree of the repo shares.
|
|
15
|
+
*
|
|
16
|
+
* Allocation is deterministic (first free index), idempotent per feature, and
|
|
17
|
+
* released only when `worktree finish` actually removed the worktree. Writes
|
|
18
|
+
* are tmp+rename so a crash never leaves a torn registry; a corrupt registry
|
|
19
|
+
* is treated as empty (fail-open — the cost is a re-allocation, never a crash).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_BASE_PORT = 4000;
|
|
26
|
+
const DEFAULT_BLOCK_SIZE = 10;
|
|
27
|
+
const MAX_PORT = 65535;
|
|
28
|
+
|
|
29
|
+
/** Registry file path for a repo's common git dir. */
|
|
30
|
+
export function registryPath(gitCommonDir) {
|
|
31
|
+
return join(gitCommonDir, 'morph-spec', 'env-registry.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readRegistry(gitCommonDir) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = readFileSync(registryPath(gitCommonDir), 'utf8').replace(/^/, '');
|
|
37
|
+
const parsed = JSON.parse(raw);
|
|
38
|
+
if (parsed && typeof parsed.allocations === 'object' && parsed.allocations !== null) {
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
} catch { /* fail-open: corrupt or absent registry restarts empty */ }
|
|
42
|
+
return { version: 1, allocations: {} };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeRegistry(gitCommonDir, registry) {
|
|
46
|
+
const path = registryPath(gitCommonDir);
|
|
47
|
+
mkdirSync(join(gitCommonDir, 'morph-spec'), { recursive: true });
|
|
48
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
49
|
+
writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
|
|
50
|
+
renameSync(tmp, path);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read fleet overrides from the project's committed config.json.
|
|
55
|
+
* Fail-open: missing/malformed config (or a Windows BOM) → defaults.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} rootPath - primary root of the project
|
|
58
|
+
* @returns {{ basePort: number, blockSize: number }}
|
|
59
|
+
*/
|
|
60
|
+
export function resolveFleetConfig(rootPath) {
|
|
61
|
+
const defaults = { basePort: DEFAULT_BASE_PORT, blockSize: DEFAULT_BLOCK_SIZE };
|
|
62
|
+
try {
|
|
63
|
+
const raw = readFileSync(join(rootPath, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, '');
|
|
64
|
+
const cfg = JSON.parse(raw);
|
|
65
|
+
const fleet = cfg?.fleet || {};
|
|
66
|
+
return {
|
|
67
|
+
basePort: Number.isInteger(fleet.basePort) && fleet.basePort > 0 ? fleet.basePort : defaults.basePort,
|
|
68
|
+
blockSize: Number.isInteger(fleet.blockSize) && fleet.blockSize > 0 ? fleet.blockSize : defaults.blockSize,
|
|
69
|
+
};
|
|
70
|
+
} catch {
|
|
71
|
+
return defaults;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Allocate (or return the existing) port block for a feature.
|
|
77
|
+
* First-free-index policy: released slots are reused, so long-lived repos
|
|
78
|
+
* don't creep toward the port ceiling.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} feature
|
|
81
|
+
* @param {{ gitCommonDir: string, basePort?: number, blockSize?: number }} opts
|
|
82
|
+
* @returns {{ index: number, base: number, range: [number, number], created: boolean }}
|
|
83
|
+
*/
|
|
84
|
+
export function allocateEnvBlock(feature, { gitCommonDir, basePort = DEFAULT_BASE_PORT, blockSize = DEFAULT_BLOCK_SIZE }) {
|
|
85
|
+
const registry = readRegistry(gitCommonDir);
|
|
86
|
+
|
|
87
|
+
const existing = registry.allocations[feature];
|
|
88
|
+
if (existing && Number.isInteger(existing.index)) {
|
|
89
|
+
const base = basePort + existing.index * blockSize;
|
|
90
|
+
return { index: existing.index, base, range: [base, base + blockSize - 1], created: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const used = new Set(
|
|
94
|
+
Object.values(registry.allocations)
|
|
95
|
+
.map((a) => a?.index)
|
|
96
|
+
.filter((i) => Number.isInteger(i)),
|
|
97
|
+
);
|
|
98
|
+
let index = 0;
|
|
99
|
+
while (used.has(index)) index += 1;
|
|
100
|
+
|
|
101
|
+
const base = basePort + index * blockSize;
|
|
102
|
+
if (base + blockSize - 1 > MAX_PORT) {
|
|
103
|
+
throw new Error(`env-allocator: port block ${base}-${base + blockSize - 1} exceeds ${MAX_PORT}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
registry.allocations[feature] = { index, base, createdAt: new Date().toISOString() };
|
|
107
|
+
writeRegistry(gitCommonDir, registry);
|
|
108
|
+
return { index, base, range: [base, base + blockSize - 1], created: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Release a feature's block. Returns false when nothing was allocated.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} feature
|
|
115
|
+
* @param {{ gitCommonDir: string }} opts
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
export function releaseEnvBlock(feature, { gitCommonDir }) {
|
|
119
|
+
const registry = readRegistry(gitCommonDir);
|
|
120
|
+
if (!registry.allocations[feature]) return false;
|
|
121
|
+
delete registry.allocations[feature];
|
|
122
|
+
writeRegistry(gitCommonDir, registry);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Render the `.morph/worktree.env` contents for a block, stack-aware.
|
|
128
|
+
* `PORT` covers next/vite/node dev servers; dotnet gets its own URL on base+1
|
|
129
|
+
* so a full-stack worktree runs web and api side by side inside one block.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} feature
|
|
132
|
+
* @param {{ base: number, range: [number, number] }} block
|
|
133
|
+
* @param {string} [stack] - config.json `project.stack` (e.g. "nextjs+dotnet")
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
export function buildEnvFileContent(feature, block, stack = '') {
|
|
137
|
+
const lines = [
|
|
138
|
+
`# Managed by morph-spec — fleet env desta worktree (feature: ${feature}).`,
|
|
139
|
+
'# Derivado e local: nunca commitar, nunca editar à mão (regenerado pelo worktree setup).',
|
|
140
|
+
`MORPH_FEATURE=${feature}`,
|
|
141
|
+
`MORPH_PORT_BASE=${block.base}`,
|
|
142
|
+
`MORPH_PORT_RANGE=${block.range[0]}-${block.range[1]}`,
|
|
143
|
+
`PORT=${block.base}`,
|
|
144
|
+
];
|
|
145
|
+
if (/\bdotnet\b/i.test(String(stack))) {
|
|
146
|
+
lines.push(`ASPNETCORE_URLS=http://localhost:${block.base + 1}`);
|
|
147
|
+
}
|
|
148
|
+
return `${lines.join('\n')}\n`;
|
|
149
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fleet-scanner.js — aggregate view of every active feature across the fleet.
|
|
3
|
+
*
|
|
4
|
+
* A fleet is N Claude Code instances, one per feature worktree, plus whatever
|
|
5
|
+
* is active in the primary root. Each instance only sees its own cwd; the
|
|
6
|
+
* operator loses track of "what is each session doing, what is waiting on me".
|
|
7
|
+
* This scanner answers that from the primary root's vantage point: one entry
|
|
8
|
+
* per active feature, worktree or root-resident, with the operational facts
|
|
9
|
+
* (phase, pending gates, task counts, port block, handoff freshness, last
|
|
10
|
+
* commit, dirtiness) resolved per entry.
|
|
11
|
+
*
|
|
12
|
+
* Read-only and fail-open per FIELD: a corrupt feature.json or a git hiccup
|
|
13
|
+
* nulls that field, never the scan.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execSync, execFileSync } from 'child_process';
|
|
17
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
18
|
+
import { join, resolve } from 'path';
|
|
19
|
+
import { isGitRepo, resolvePrimaryRoot, parseWorktreeList } from '../../commands/project/worktree.js';
|
|
20
|
+
import { registryPath, resolveFleetConfig } from './env-allocator.js';
|
|
21
|
+
import {
|
|
22
|
+
getFeature,
|
|
23
|
+
derivePhaseForFeature,
|
|
24
|
+
getPendingGates,
|
|
25
|
+
} from '../../../framework/hooks/shared/state-reader.js';
|
|
26
|
+
|
|
27
|
+
function taskCounts(featureDir) {
|
|
28
|
+
try {
|
|
29
|
+
const data = JSON.parse(readFileSync(join(featureDir, '2-plan', 'tasks.json'), 'utf8'));
|
|
30
|
+
if (!Array.isArray(data.tasks)) return null;
|
|
31
|
+
return { done: data.tasks.filter((t) => t.status === 'done').length, total: data.tasks.length };
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function handoffInfo(featureDir) {
|
|
38
|
+
try {
|
|
39
|
+
const path = join(featureDir, 'handoff.md');
|
|
40
|
+
if (!existsSync(path)) return { exists: false, generatedAt: null };
|
|
41
|
+
const generatedAt = readFileSync(path, 'utf8').match(/gerado em (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/)?.[1] || null;
|
|
42
|
+
return { exists: true, generatedAt };
|
|
43
|
+
} catch {
|
|
44
|
+
return { exists: false, generatedAt: null };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function lastCommitRelative(root, branch) {
|
|
49
|
+
try {
|
|
50
|
+
return execFileSync('git', ['log', '-1', '--format=%cr', branch], { cwd: root, stdio: 'pipe' })
|
|
51
|
+
.toString().trim() || null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function dirtyCount(worktreePath) {
|
|
58
|
+
try {
|
|
59
|
+
const out = execFileSync('git', ['status', '--porcelain'], { cwd: worktreePath, stdio: 'pipe' }).toString();
|
|
60
|
+
return out.split('\n').filter((l) => l.trim()).length;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildEntry(feature, { location, path, branch, root, allocations, blockSize }) {
|
|
67
|
+
let workType = null;
|
|
68
|
+
let status = 'unknown';
|
|
69
|
+
let phase = 'proposal';
|
|
70
|
+
let pendingGates = [];
|
|
71
|
+
try {
|
|
72
|
+
const fj = getFeature(feature, path);
|
|
73
|
+
workType = fj?.workType || null;
|
|
74
|
+
status = fj?.status || 'unknown';
|
|
75
|
+
phase = derivePhaseForFeature(feature, path);
|
|
76
|
+
pendingGates = getPendingGates(feature, path);
|
|
77
|
+
} catch { /* field-level fail-open */ }
|
|
78
|
+
|
|
79
|
+
const featureDir = join(path, '.morph', 'features', feature);
|
|
80
|
+
const alloc = allocations[feature];
|
|
81
|
+
return {
|
|
82
|
+
feature,
|
|
83
|
+
workType,
|
|
84
|
+
phase,
|
|
85
|
+
status,
|
|
86
|
+
pendingGates,
|
|
87
|
+
location,
|
|
88
|
+
path,
|
|
89
|
+
branch,
|
|
90
|
+
tasks: taskCounts(featureDir),
|
|
91
|
+
env: alloc && Number.isInteger(alloc.base)
|
|
92
|
+
? { base: alloc.base, range: [alloc.base, alloc.base + blockSize - 1] }
|
|
93
|
+
: null,
|
|
94
|
+
handoff: handoffInfo(featureDir),
|
|
95
|
+
lastCommit: location === 'worktree' && branch ? lastCommitRelative(root, branch) : null,
|
|
96
|
+
dirty: location === 'worktree' ? dirtyCount(path) : null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Scan the fleet from anywhere inside the repo.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} [cwd]
|
|
104
|
+
* @returns {{ root: string, entries: object[] }}
|
|
105
|
+
*/
|
|
106
|
+
export function scanFleet(cwd = process.cwd()) {
|
|
107
|
+
if (!isGitRepo(cwd)) {
|
|
108
|
+
return { root: resolve(cwd).replace(/\\/g, '/'), entries: [] };
|
|
109
|
+
}
|
|
110
|
+
const root = resolvePrimaryRoot(cwd);
|
|
111
|
+
|
|
112
|
+
let allocations = {};
|
|
113
|
+
try {
|
|
114
|
+
const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
115
|
+
if (common) {
|
|
116
|
+
const reg = JSON.parse(readFileSync(registryPath(resolve(root, common)), 'utf8'));
|
|
117
|
+
if (reg && typeof reg.allocations === 'object' && reg.allocations !== null) allocations = reg.allocations;
|
|
118
|
+
}
|
|
119
|
+
} catch { /* no registry yet — env stays null per entry */ }
|
|
120
|
+
const { blockSize } = resolveFleetConfig(root);
|
|
121
|
+
|
|
122
|
+
const entries = [];
|
|
123
|
+
const inWorktree = new Set();
|
|
124
|
+
|
|
125
|
+
for (const e of parseWorktreeList(root)) {
|
|
126
|
+
const m = e.branch && e.branch.match(/^refs\/heads\/morph\/(.+)$/);
|
|
127
|
+
if (!m) continue; // primary root entry and non-morph worktrees
|
|
128
|
+
inWorktree.add(m[1]);
|
|
129
|
+
entries.push(buildEntry(m[1], {
|
|
130
|
+
location: 'worktree',
|
|
131
|
+
path: e.path,
|
|
132
|
+
branch: e.branch.replace(/^refs\/heads\//, ''),
|
|
133
|
+
root,
|
|
134
|
+
allocations,
|
|
135
|
+
blockSize,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Root-resident features (no live worktree): whatever has a feature.json.
|
|
140
|
+
try {
|
|
141
|
+
const featuresDir = join(root, '.morph', 'features');
|
|
142
|
+
if (existsSync(featuresDir)) {
|
|
143
|
+
const names = readdirSync(featuresDir, { withFileTypes: true })
|
|
144
|
+
.filter((d) => d.isDirectory())
|
|
145
|
+
.map((d) => d.name)
|
|
146
|
+
.filter((name) => !inWorktree.has(name) && existsSync(join(featuresDir, name, 'feature.json')));
|
|
147
|
+
for (const name of names) {
|
|
148
|
+
entries.push(buildEntry(name, {
|
|
149
|
+
location: 'root', path: root, branch: null, root, allocations, blockSize,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch { /* fail-open */ }
|
|
154
|
+
|
|
155
|
+
return { root, entries };
|
|
156
|
+
}
|
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Context-Aware Dispatch — Agent Context Builder
|
|
3
|
-
*
|
|
4
|
-
* Assembles curated context payloads per agent, based on domain persona,
|
|
5
|
-
* phase, and active task. Agents get only what they need — not everything.
|
|
6
|
-
*
|
|
7
|
-
* @module context-builder
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { readFileSync, existsSync } from 'fs';
|
|
11
|
-
import { join, dirname } from 'path';
|
|
12
|
-
import { fileURLToPath } from 'url';
|
|
13
|
-
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
14
|
-
|
|
15
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const AGENTS_JSON_PATH = join(__dirname, '../../../framework/agents.json');
|
|
17
|
-
const STANDARDS_JSON_PATH = join(__dirname, '../../../framework/standards/STANDARDS.json');
|
|
18
|
-
|
|
19
|
-
let _agentsCache = null;
|
|
20
|
-
let _standardsCache = null;
|
|
21
|
-
|
|
22
|
-
function loadAgents() {
|
|
23
|
-
if (_agentsCache) return _agentsCache;
|
|
24
|
-
if (!existsSync(AGENTS_JSON_PATH)) return { agents: {} };
|
|
25
|
-
try {
|
|
26
|
-
_agentsCache = JSON.parse(readFileSync(AGENTS_JSON_PATH, 'utf8'));
|
|
27
|
-
return _agentsCache;
|
|
28
|
-
} catch { return { agents: {} }; }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function loadStandards() {
|
|
32
|
-
if (_standardsCache) return _standardsCache;
|
|
33
|
-
if (!existsSync(STANDARDS_JSON_PATH)) return { standards: [] };
|
|
34
|
-
try {
|
|
35
|
-
_standardsCache = JSON.parse(readFileSync(STANDARDS_JSON_PATH, 'utf8'));
|
|
36
|
-
return _standardsCache;
|
|
37
|
-
} catch { return { standards: [] }; }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Max chars for spec content (flat — v5 has no tier hierarchy) */
|
|
41
|
-
const SPEC_BUDGET = 8000;
|
|
42
|
-
|
|
43
|
-
/** Max number of standard digests */
|
|
44
|
-
const STANDARDS_BUDGET = 10;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Context assembly rules by phase.
|
|
48
|
-
* Controls which context sections are included.
|
|
49
|
-
*/
|
|
50
|
-
const PHASE_CONTEXT_PROFILES = {
|
|
51
|
-
proposal: {
|
|
52
|
-
includeSpec: false,
|
|
53
|
-
includeStandards: false,
|
|
54
|
-
includeTask: false,
|
|
55
|
-
includeCodePaths: false,
|
|
56
|
-
includePhaseArtifacts: false,
|
|
57
|
-
},
|
|
58
|
-
uiux: {
|
|
59
|
-
includeSpec: false,
|
|
60
|
-
includeStandards: true,
|
|
61
|
-
includeTask: false,
|
|
62
|
-
includeCodePaths: false,
|
|
63
|
-
includePhaseArtifacts: false,
|
|
64
|
-
},
|
|
65
|
-
plan: {
|
|
66
|
-
includeSpec: true,
|
|
67
|
-
includeStandards: true,
|
|
68
|
-
includeTask: false,
|
|
69
|
-
includeCodePaths: false,
|
|
70
|
-
includePhaseArtifacts: true,
|
|
71
|
-
},
|
|
72
|
-
implement: {
|
|
73
|
-
includeSpec: true,
|
|
74
|
-
includeStandards: true,
|
|
75
|
-
includeTask: true,
|
|
76
|
-
includeCodePaths: true,
|
|
77
|
-
includePhaseArtifacts: false,
|
|
78
|
-
},
|
|
79
|
-
review: {
|
|
80
|
-
includeSpec: true,
|
|
81
|
-
includeStandards: true,
|
|
82
|
-
includeTask: false,
|
|
83
|
-
includeCodePaths: true,
|
|
84
|
-
includePhaseArtifacts: true,
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Extract only the FR sections referenced by a task.
|
|
90
|
-
*
|
|
91
|
-
* @param {string} specContent - Full spec.md content
|
|
92
|
-
* @param {string[]} frIds - FR IDs (e.g., ['FR001', 'FR003'])
|
|
93
|
-
* @returns {string} Scoped spec with only referenced FRs
|
|
94
|
-
*/
|
|
95
|
-
export function scopeSpecToFRs(specContent, frIds) {
|
|
96
|
-
if (!frIds || frIds.length === 0 || !specContent) return specContent;
|
|
97
|
-
|
|
98
|
-
const sections = [];
|
|
99
|
-
|
|
100
|
-
// Always include overview/context sections
|
|
101
|
-
const overviewMatch = specContent.match(/^(#[^#][\s\S]*?)(?=\n##\s+FR\d)/m);
|
|
102
|
-
if (overviewMatch) {
|
|
103
|
-
sections.push(overviewMatch[1].trim());
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Extract each referenced FR section
|
|
107
|
-
for (const frId of frIds) {
|
|
108
|
-
const pattern = new RegExp(
|
|
109
|
-
`(##\\s+${frId}[:\\s—–\\-][\\s\\S]*?)(?=\\n##\\s+FR\\d|\\n##\\s+[^F]|$)`,
|
|
110
|
-
'm'
|
|
111
|
-
);
|
|
112
|
-
const match = pattern.exec(specContent);
|
|
113
|
-
if (match) {
|
|
114
|
-
sections.push(match[1].trim());
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return sections.length > 0 ? sections.join('\n\n---\n\n') : specContent;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Build standard digests for an agent based on their standards[] array.
|
|
123
|
-
*
|
|
124
|
-
* @param {string} agentId - Agent ID from agents.json
|
|
125
|
-
* @returns {string[]} Array of digest paragraphs
|
|
126
|
-
*/
|
|
127
|
-
export function buildAgentDigests(agentId) {
|
|
128
|
-
const agents = loadAgents();
|
|
129
|
-
const agentData = agents.agents?.[agentId];
|
|
130
|
-
if (!agentData?.standards || agentData.standards.length === 0) return [];
|
|
131
|
-
|
|
132
|
-
const standards = loadStandards();
|
|
133
|
-
const standardsList = standards.standards || [];
|
|
134
|
-
|
|
135
|
-
const digests = [];
|
|
136
|
-
for (const stdId of agentData.standards) {
|
|
137
|
-
const std = standardsList.find(s => s.id === stdId);
|
|
138
|
-
if (std?.digest) {
|
|
139
|
-
digests.push(`[${std.id}] ${std.name}: ${std.digest}`);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return digests;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Extract FR IDs referenced by a task. Reads `[FR001, FR003]` markers from the
|
|
148
|
-
* task title and any `FR...` references in the task description/body.
|
|
149
|
-
*
|
|
150
|
-
* @param {string} taskTitle - Task title that may contain [FR...] references
|
|
151
|
-
* @param {string} [taskBlock] - Task description/body for further references
|
|
152
|
-
* @returns {string[]}
|
|
153
|
-
*/
|
|
154
|
-
export function extractFRIds(taskTitle, taskBlock) {
|
|
155
|
-
const frIds = new Set();
|
|
156
|
-
|
|
157
|
-
// From title: [FR001, FR003]
|
|
158
|
-
const titleMatch = (taskTitle || '').match(/\[([^\]]*FR\d+[^\]]*)\]/);
|
|
159
|
-
if (titleMatch) {
|
|
160
|
-
const matches = titleMatch[1].match(/FR\d+/g);
|
|
161
|
-
if (matches) matches.forEach(id => frIds.add(id));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// From body: **FRs:** FR001, FR002
|
|
165
|
-
if (taskBlock) {
|
|
166
|
-
const bodyMatch = taskBlock.match(/\*\*FRs?:\*\*\s*(.+)/i);
|
|
167
|
-
if (bodyMatch) {
|
|
168
|
-
const matches = bodyMatch[1].match(/FR\d+/g);
|
|
169
|
-
if (matches) matches.forEach(id => frIds.add(id));
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return [...frIds];
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Build a curated context payload for a specific agent.
|
|
178
|
-
*
|
|
179
|
-
* @param {string} agentId - Agent ID from agents.json
|
|
180
|
-
* @param {Object} options
|
|
181
|
-
* @param {string} options.feature - Feature name
|
|
182
|
-
* @param {string} options.phase - Current phase
|
|
183
|
-
* @param {string} [options.taskId] - Active task ID (implement phase)
|
|
184
|
-
* @param {string} [options.projectPath='.'] - Project root
|
|
185
|
-
* @returns {AgentContext}
|
|
186
|
-
*
|
|
187
|
-
* @typedef {Object} AgentContext
|
|
188
|
-
* @property {string|null} spec - Full spec or task-scoped section
|
|
189
|
-
* @property {string[]} standards - Digest paragraphs for this agent's standards
|
|
190
|
-
* @property {string|null} task - Mandate task section or null
|
|
191
|
-
* @property {string[]} codeContext - Relevant file paths
|
|
192
|
-
* @property {string|null} phaseArtifacts - Previous phase outputs
|
|
193
|
-
* @property {Object} budget - Context budget hints
|
|
194
|
-
*/
|
|
195
|
-
export async function buildAgentContext(agentId, { feature, phase, taskId, projectPath = '.' }) {
|
|
196
|
-
const agents = loadAgents();
|
|
197
|
-
const profile = PHASE_CONTEXT_PROFILES[phase] || PHASE_CONTEXT_PROFILES.implement;
|
|
198
|
-
const maxSpecChars = SPEC_BUDGET;
|
|
199
|
-
const maxStandards = STANDARDS_BUDGET;
|
|
200
|
-
|
|
201
|
-
const result = {
|
|
202
|
-
spec: null,
|
|
203
|
-
standards: [],
|
|
204
|
-
task: null,
|
|
205
|
-
codeContext: [],
|
|
206
|
-
phaseArtifacts: null,
|
|
207
|
-
budget: { maxSpecChars, maxStandards },
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
// Standards digests
|
|
211
|
-
if (profile.includeStandards) {
|
|
212
|
-
result.standards = buildAgentDigests(agentId).slice(0, maxStandards);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Spec content (scoped by task FRs when possible)
|
|
216
|
-
if (profile.includeSpec) {
|
|
217
|
-
try {
|
|
218
|
-
const specPath = getAbsoluteOutputPath(projectPath, feature, 'spec');
|
|
219
|
-
if (existsSync(specPath)) {
|
|
220
|
-
let specContent = readFileSync(specPath, 'utf8');
|
|
221
|
-
|
|
222
|
-
// If task-scoped, extract mandate section for this task
|
|
223
|
-
if (taskId) {
|
|
224
|
-
const mandatePath = getAbsoluteOutputPath(projectPath, feature, 'mandate');
|
|
225
|
-
if (existsSync(mandatePath)) {
|
|
226
|
-
try {
|
|
227
|
-
const { extractTaskSection } = await import('../../lib/generators/mandate-generator.js');
|
|
228
|
-
const section = extractTaskSection(readFileSync(mandatePath, 'utf8'), taskId);
|
|
229
|
-
if (section) {
|
|
230
|
-
result.spec = section.slice(0, maxSpecChars);
|
|
231
|
-
result.task = section;
|
|
232
|
-
}
|
|
233
|
-
} catch { /* fall through to raw spec */ }
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (!result.spec) {
|
|
238
|
-
result.spec = specContent.slice(0, maxSpecChars);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
} catch { /* spec not available */ }
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Task mandate section
|
|
245
|
-
if (profile.includeTask && taskId && !result.task) {
|
|
246
|
-
try {
|
|
247
|
-
const mandatePath = getAbsoluteOutputPath(projectPath, feature, 'mandate');
|
|
248
|
-
if (existsSync(mandatePath)) {
|
|
249
|
-
const { extractTaskSection } = await import('../../lib/generators/mandate-generator.js');
|
|
250
|
-
const section = extractTaskSection(readFileSync(mandatePath, 'utf8'), taskId);
|
|
251
|
-
if (section) result.task = section;
|
|
252
|
-
}
|
|
253
|
-
} catch { /* mandate not available */ }
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Code paths from task
|
|
257
|
-
if (profile.includeCodePaths && taskId) {
|
|
258
|
-
try {
|
|
259
|
-
const planPath = getAbsoluteOutputPath(projectPath, feature, 'plan');
|
|
260
|
-
if (existsSync(planPath)) {
|
|
261
|
-
const planContent = readFileSync(planPath, 'utf8');
|
|
262
|
-
const backtickPaths = planContent.match(/`([^`]+\.[a-z]{1,4})`/g);
|
|
263
|
-
if (backtickPaths) {
|
|
264
|
-
result.codeContext = [...new Set(backtickPaths.map(p => p.replace(/`/g, '')))].slice(0, 20);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
} catch { /* plan not available */ }
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Phase artifacts (decisions, previous outputs)
|
|
271
|
-
if (profile.includePhaseArtifacts) {
|
|
272
|
-
try {
|
|
273
|
-
const decisionsPath = getAbsoluteOutputPath(projectPath, feature, 'decisions');
|
|
274
|
-
if (existsSync(decisionsPath)) {
|
|
275
|
-
result.phaseArtifacts = readFileSync(decisionsPath, 'utf8').slice(0, 3000);
|
|
276
|
-
}
|
|
277
|
-
} catch { /* decisions not available */ }
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
return result;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Invalidate module-level caches. Used in tests.
|
|
285
|
-
*/
|
|
286
|
-
export function clearCache() {
|
|
287
|
-
_agentsCache = null;
|
|
288
|
-
_standardsCache = null;
|
|
289
|
-
}
|