fullstack-agentic-flow 2.0.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/LICENSE +21 -0
- package/README.md +348 -0
- package/dist/src/cli/args.js +74 -0
- package/dist/src/cli/commands/doctor.js +133 -0
- package/dist/src/cli/commands/init.js +103 -0
- package/dist/src/cli/commands/shared.js +20 -0
- package/dist/src/cli/commands/tools.js +21 -0
- package/dist/src/cli/commands/update.js +21 -0
- package/dist/src/cli/main.js +6 -0
- package/dist/src/cli/output.js +11 -0
- package/dist/src/cli/prompter.js +13 -0
- package/dist/src/cli/report.js +13 -0
- package/dist/src/cli/run.js +48 -0
- package/dist/src/domain/canon-mapping.js +26 -0
- package/dist/src/domain/claude-md-mode.js +17 -0
- package/dist/src/domain/enum-parse.js +9 -0
- package/dist/src/domain/errors.js +13 -0
- package/dist/src/domain/pipeline-config.js +58 -0
- package/dist/src/domain/runtime.js +19 -0
- package/dist/src/domain/tool.js +129 -0
- package/dist/src/domain/topology.js +39 -0
- package/dist/src/install/adapters.js +39 -0
- package/dist/src/install/applier.js +150 -0
- package/dist/src/install/detect.js +70 -0
- package/dist/src/install/managed-section.js +35 -0
- package/dist/src/install/manifest.js +63 -0
- package/dist/src/install/planned-file.js +77 -0
- package/dist/src/install/planner.js +139 -0
- package/dist/src/install/rules-preset.js +37 -0
- package/dist/src/install/setup-guide.js +60 -0
- package/dist/src/io/fs.js +39 -0
- package/dist/src/io/paths.js +6 -0
- package/dist/src/io/shell.js +25 -0
- package/dist/src/registry/commands.js +52 -0
- package/dist/src/registry/subagents.js +52 -0
- package/dist/src/registry/tools.js +99 -0
- package/package.json +53 -0
- package/payload/ai-agents/agents/00-bootstrap.md +473 -0
- package/payload/ai-agents/agents/00a-scaffold.md +295 -0
- package/payload/ai-agents/agents/00b-module-scaffold.md +108 -0
- package/payload/ai-agents/agents/01-intake.md +308 -0
- package/payload/ai-agents/agents/01b-impact-analysis.md +274 -0
- package/payload/ai-agents/agents/02a-dependency-map.md +247 -0
- package/payload/ai-agents/agents/02b-backend-contract.md +434 -0
- package/payload/ai-agents/agents/02c-ui-contract.md +367 -0
- package/payload/ai-agents/agents/02d-interface-seam.md +373 -0
- package/payload/ai-agents/agents/03a-backend-sequence.md +233 -0
- package/payload/ai-agents/agents/03b-ui-sequence.md +230 -0
- package/payload/ai-agents/agents/04a-backend-implementer.md +290 -0
- package/payload/ai-agents/agents/04b-ui-implementer.md +302 -0
- package/payload/ai-agents/agents/05-ci-validator.md +125 -0
- package/payload/ai-agents/agents/06-qa-spec.md +249 -0
- package/payload/ai-agents/agents/07-security.md +228 -0
- package/payload/ai-agents/agents/08-performance.md +208 -0
- package/payload/ai-agents/agents/09a-feature-notes.md +156 -0
- package/payload/ai-agents/agents/09b-release.md +111 -0
- package/payload/ai-agents/architecture-context.template.md +554 -0
- package/payload/ai-agents/archive/README.md +22 -0
- package/payload/ai-agents/canon/backend-canon.md +295 -0
- package/payload/ai-agents/canon/frontend-canon.md +47 -0
- package/payload/ai-agents/canon/typed-data.md +158 -0
- package/payload/ai-agents/observations/README.md +11 -0
- package/payload/ai-agents/scripts/check-test-with-change.mjs +103 -0
- package/payload/ai-agents/scripts/check-typed-boundaries.mjs +329 -0
- package/payload/ai-agents/scripts/lib/config.mjs +63 -0
- package/payload/ai-agents/scripts/lib/glob.mjs +40 -0
- package/payload/ai-agents/state/README.md +108 -0
- package/payload/ai-agents/state/current-stage.md +10 -0
- package/payload/ai-agents/templates/README.md +22 -0
- package/payload/ai-agents/templates/ci/github-actions.rules.yml +35 -0
- package/payload/ai-agents/templates/ci/gitlab-ci.rules.yml +30 -0
- package/payload/ai-agents/toolchain.md +214 -0
- package/payload/commands/bootstrap.md +17 -0
- package/payload/commands/contract.md +64 -0
- package/payload/commands/finalize.md +26 -0
- package/payload/commands/impact.md +25 -0
- package/payload/commands/implement.md +58 -0
- package/payload/commands/intake.md +16 -0
- package/payload/commands/perf.md +20 -0
- package/payload/commands/qa.md +15 -0
- package/payload/commands/release.md +22 -0
- package/payload/commands/resume.md +52 -0
- package/payload/commands/scaffold-module.md +20 -0
- package/payload/commands/scaffold.md +35 -0
- package/payload/commands/seam.md +28 -0
- package/payload/commands/security.md +16 -0
- package/payload/commands/sequence.md +22 -0
- package/payload/commands/status.md +37 -0
- package/payload/legacy/v1-checksums.json +197 -0
- package/payload/root/contracts-README.md +19 -0
- package/payload/root/pipeline-section.md +33 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { pathExists, readTextOrNull, sha256, writeText } from '../io/fs.js';
|
|
3
|
+
import { upsertManagedSection } from './managed-section.js';
|
|
4
|
+
import { Ownership } from './planned-file.js';
|
|
5
|
+
export var OutcomeKind;
|
|
6
|
+
(function (OutcomeKind) {
|
|
7
|
+
OutcomeKind["Created"] = "created";
|
|
8
|
+
OutcomeKind["Updated"] = "updated";
|
|
9
|
+
OutcomeKind["Unchanged"] = "unchanged";
|
|
10
|
+
/** Team-edited or pre-existing file; new version written beside it as `.incoming`. */
|
|
11
|
+
OutcomeKind["Conflict"] = "conflict";
|
|
12
|
+
/** Seed file already exists; left alone. */
|
|
13
|
+
OutcomeKind["SeedKept"] = "seed-kept";
|
|
14
|
+
/** Would have been written, but this is a dry run. */
|
|
15
|
+
OutcomeKind["Planned"] = "planned";
|
|
16
|
+
})(OutcomeKind || (OutcomeKind = {}));
|
|
17
|
+
export class FileOutcome {
|
|
18
|
+
relativePath;
|
|
19
|
+
kind;
|
|
20
|
+
note;
|
|
21
|
+
constructor(relativePath, kind, note) {
|
|
22
|
+
this.relativePath = relativePath;
|
|
23
|
+
this.kind = kind;
|
|
24
|
+
this.note = note;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class ApplyReport {
|
|
28
|
+
outcomes;
|
|
29
|
+
obsoletePresent;
|
|
30
|
+
constructor(outcomes, obsoletePresent) {
|
|
31
|
+
this.outcomes = outcomes;
|
|
32
|
+
this.obsoletePresent = obsoletePresent;
|
|
33
|
+
}
|
|
34
|
+
count(kind) {
|
|
35
|
+
return this.outcomes.filter((outcome) => outcome.kind === kind).length;
|
|
36
|
+
}
|
|
37
|
+
ofKind(kind) {
|
|
38
|
+
return this.outcomes.filter((outcome) => outcome.kind === kind);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class ApplyOptions {
|
|
42
|
+
force;
|
|
43
|
+
dryRun;
|
|
44
|
+
constructor(force, dryRun) {
|
|
45
|
+
this.force = force;
|
|
46
|
+
this.dryRun = dryRun;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Merge our keys into an existing settings.json. The file is foreign JSON: this is its parse edge. */
|
|
50
|
+
export function mergeClaudeSettings(existing, patch) {
|
|
51
|
+
const parsed = existing === null || existing.trim() === '' ? {} : JSON.parse(existing);
|
|
52
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
53
|
+
throw new Error('.claude/settings.json is not a JSON object; refusing to modify it.');
|
|
54
|
+
}
|
|
55
|
+
const settings = { ...parsed };
|
|
56
|
+
const objectAt = (key) => {
|
|
57
|
+
const value = settings[key];
|
|
58
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
59
|
+
? { ...value }
|
|
60
|
+
: {};
|
|
61
|
+
};
|
|
62
|
+
if (patch.marketplaces.length > 0) {
|
|
63
|
+
const marketplaces = objectAt('extraKnownMarketplaces');
|
|
64
|
+
for (const source of patch.marketplaces) {
|
|
65
|
+
marketplaces[source.name] = { source: { source: 'github', repo: source.githubRepo } };
|
|
66
|
+
}
|
|
67
|
+
settings['extraKnownMarketplaces'] = marketplaces;
|
|
68
|
+
}
|
|
69
|
+
if (patch.enabledPlugins.length > 0) {
|
|
70
|
+
const plugins = objectAt('enabledPlugins');
|
|
71
|
+
for (const plugin of patch.enabledPlugins) {
|
|
72
|
+
plugins[plugin] = true;
|
|
73
|
+
}
|
|
74
|
+
settings['enabledPlugins'] = plugins;
|
|
75
|
+
}
|
|
76
|
+
return `${JSON.stringify(settings, null, 2)}\n`;
|
|
77
|
+
}
|
|
78
|
+
async function renderFinal(file, current) {
|
|
79
|
+
switch (file.ownership) {
|
|
80
|
+
case Ownership.ManagedSection:
|
|
81
|
+
if (file.sectionId === null || file.commentStyle === null) {
|
|
82
|
+
throw new Error(`Managed section for ${file.relativePath} is missing its id or style`);
|
|
83
|
+
}
|
|
84
|
+
return upsertManagedSection(current, file.sectionId, file.content, file.commentStyle);
|
|
85
|
+
case Ownership.JsonMerge:
|
|
86
|
+
if (file.jsonPatch === null) {
|
|
87
|
+
throw new Error(`Settings merge for ${file.relativePath} has no patch`);
|
|
88
|
+
}
|
|
89
|
+
return mergeClaudeSettings(current, file.jsonPatch);
|
|
90
|
+
case Ownership.Pipeline:
|
|
91
|
+
case Ownership.Seed:
|
|
92
|
+
case Ownership.Generated:
|
|
93
|
+
return file.content;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function applyPlan(targetDir, plan, manifest, options) {
|
|
97
|
+
const outcomes = [];
|
|
98
|
+
let nextManifest = manifest;
|
|
99
|
+
const write = async (relativePath, content) => {
|
|
100
|
+
if (!options.dryRun) {
|
|
101
|
+
await writeText(join(targetDir, relativePath), content);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
for (const file of plan.files) {
|
|
105
|
+
const absolute = join(targetDir, file.relativePath);
|
|
106
|
+
const current = await readTextOrNull(absolute);
|
|
107
|
+
const desired = await renderFinal(file, current);
|
|
108
|
+
const record = (kind, note = '') => {
|
|
109
|
+
outcomes.push(new FileOutcome(file.relativePath, options.dryRun && kind !== OutcomeKind.Unchanged && kind !== OutcomeKind.SeedKept ? OutcomeKind.Planned : kind, note));
|
|
110
|
+
};
|
|
111
|
+
if (current === null) {
|
|
112
|
+
await write(file.relativePath, desired);
|
|
113
|
+
if (file.ownership === Ownership.Pipeline)
|
|
114
|
+
nextManifest = nextManifest.with(file.relativePath, sha256(desired));
|
|
115
|
+
record(OutcomeKind.Created);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (file.ownership === Ownership.Seed) {
|
|
119
|
+
record(OutcomeKind.SeedKept);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (current === desired) {
|
|
123
|
+
if (file.ownership === Ownership.Pipeline)
|
|
124
|
+
nextManifest = nextManifest.with(file.relativePath, sha256(desired));
|
|
125
|
+
record(OutcomeKind.Unchanged);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (file.ownership !== Ownership.Pipeline) {
|
|
129
|
+
await write(file.relativePath, desired);
|
|
130
|
+
record(OutcomeKind.Updated);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const installedSha = manifest.shaFor(file.relativePath);
|
|
134
|
+
const untouchedSinceInstall = installedSha !== null && installedSha === sha256(current);
|
|
135
|
+
if (untouchedSinceInstall || options.force) {
|
|
136
|
+
await write(file.relativePath, desired);
|
|
137
|
+
nextManifest = nextManifest.with(file.relativePath, sha256(desired));
|
|
138
|
+
record(OutcomeKind.Updated, options.force && !untouchedSinceInstall ? 'overwritten (--force)' : '');
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
await write(`${file.relativePath}.incoming`, desired);
|
|
142
|
+
record(OutcomeKind.Conflict, installedSha === null ? 'existed before install' : 'edited since install');
|
|
143
|
+
}
|
|
144
|
+
const obsoletePresent = [];
|
|
145
|
+
for (const path of plan.obsoletePaths) {
|
|
146
|
+
if (await pathExists(join(targetDir, path)))
|
|
147
|
+
obsoletePresent.push(path);
|
|
148
|
+
}
|
|
149
|
+
return { report: new ApplyReport(outcomes, obsoletePresent), manifest: nextManifest };
|
|
150
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { CanonMapping } from '../domain/canon-mapping.js';
|
|
3
|
+
import { pathExists, readTextOrNull } from '../io/fs.js';
|
|
4
|
+
export class DetectedStack {
|
|
5
|
+
mapping;
|
|
6
|
+
isGitRepository;
|
|
7
|
+
evidence;
|
|
8
|
+
constructor(mapping, isGitRepository,
|
|
9
|
+
/** Human-readable evidence, e.g. "composer.json requires laravel/framework". */
|
|
10
|
+
evidence) {
|
|
11
|
+
this.mapping = mapping;
|
|
12
|
+
this.isGitRepository = isGitRepository;
|
|
13
|
+
this.evidence = evidence;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Manifest dependency names only — parse edge for package manifests. */
|
|
17
|
+
async function dependencyNames(path, sections) {
|
|
18
|
+
const text = await readTextOrNull(path);
|
|
19
|
+
if (text === null)
|
|
20
|
+
return [];
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(text);
|
|
23
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
24
|
+
return [];
|
|
25
|
+
const names = [];
|
|
26
|
+
for (const section of sections) {
|
|
27
|
+
const block = parsed[section];
|
|
28
|
+
if (typeof block === 'object' && block !== null) {
|
|
29
|
+
names.push(...Object.keys(block));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return names;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function detectStack(targetDir) {
|
|
39
|
+
const isGit = await pathExists(join(targetDir, '.git'));
|
|
40
|
+
const composer = await dependencyNames(join(targetDir, 'composer.json'), ['require', 'require-dev']);
|
|
41
|
+
if (composer.includes('laravel/framework')) {
|
|
42
|
+
return new DetectedStack(CanonMapping.Laravel, isGit, 'composer.json requires laravel/framework');
|
|
43
|
+
}
|
|
44
|
+
const npm = await dependencyNames(join(targetDir, 'package.json'), ['dependencies', 'devDependencies']);
|
|
45
|
+
if (npm.includes('@nestjs/core')) {
|
|
46
|
+
return new DetectedStack(CanonMapping.NestJs, isGit, 'package.json depends on @nestjs/core');
|
|
47
|
+
}
|
|
48
|
+
if (['express', 'fastify', 'hono'].some((name) => npm.includes(name))) {
|
|
49
|
+
return new DetectedStack(CanonMapping.Express, isGit, 'package.json depends on express/fastify/hono');
|
|
50
|
+
}
|
|
51
|
+
const python = [
|
|
52
|
+
(await readTextOrNull(join(targetDir, 'pyproject.toml'))) ?? '',
|
|
53
|
+
(await readTextOrNull(join(targetDir, 'requirements.txt'))) ?? '',
|
|
54
|
+
].join('\n');
|
|
55
|
+
if (/\bdjango\b/i.test(python)) {
|
|
56
|
+
return new DetectedStack(CanonMapping.Django, isGit, 'Python dependencies include django');
|
|
57
|
+
}
|
|
58
|
+
if (/\bfastapi\b/i.test(python)) {
|
|
59
|
+
return new DetectedStack(CanonMapping.FastApi, isGit, 'Python dependencies include fastapi');
|
|
60
|
+
}
|
|
61
|
+
if (await pathExists(join(targetDir, 'go.mod'))) {
|
|
62
|
+
return new DetectedStack(CanonMapping.Go, isGit, 'go.mod present');
|
|
63
|
+
}
|
|
64
|
+
const gradle = (await readTextOrNull(join(targetDir, 'build.gradle.kts'))) ?? (await readTextOrNull(join(targetDir, 'build.gradle'))) ?? '';
|
|
65
|
+
const pom = (await readTextOrNull(join(targetDir, 'pom.xml'))) ?? '';
|
|
66
|
+
if (/spring-boot/.test(`${gradle}${pom}`)) {
|
|
67
|
+
return new DetectedStack(CanonMapping.Spring, isGit, 'Spring Boot build file');
|
|
68
|
+
}
|
|
69
|
+
return new DetectedStack(null, isGit, 'no recognised backend framework manifest');
|
|
70
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export var CommentStyle;
|
|
2
|
+
(function (CommentStyle) {
|
|
3
|
+
/** Markdown files: <!-- … --> */
|
|
4
|
+
CommentStyle["Html"] = "html";
|
|
5
|
+
/** .gitignore and shell-style files: # … */
|
|
6
|
+
CommentStyle["Hash"] = "hash";
|
|
7
|
+
})(CommentStyle || (CommentStyle = {}));
|
|
8
|
+
const MARK = 'fullstack-agentic-flow';
|
|
9
|
+
function markers(sectionId, style) {
|
|
10
|
+
switch (style) {
|
|
11
|
+
case CommentStyle.Html:
|
|
12
|
+
return { start: `<!-- ${MARK}:${sectionId}:start -->`, end: `<!-- ${MARK}:${sectionId}:end -->` };
|
|
13
|
+
case CommentStyle.Hash:
|
|
14
|
+
return { start: `# ${MARK}:${sectionId}:start`, end: `# ${MARK}:${sectionId}:end` };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Insert or replace one delimited section in a file the user owns, leaving
|
|
19
|
+
* everything outside the markers untouched. Idempotent.
|
|
20
|
+
*/
|
|
21
|
+
export function upsertManagedSection(existing, sectionId, body, style) {
|
|
22
|
+
const { start, end } = markers(sectionId, style);
|
|
23
|
+
const block = `${start}\n${body.replace(/\n+$/, '')}\n${end}\n`;
|
|
24
|
+
if (existing === null || existing.length === 0) {
|
|
25
|
+
return block;
|
|
26
|
+
}
|
|
27
|
+
const startAt = existing.indexOf(start);
|
|
28
|
+
const endAt = existing.indexOf(end);
|
|
29
|
+
if (startAt >= 0 && endAt > startAt) {
|
|
30
|
+
const after = existing.slice(endAt + end.length).replace(/^\n/, '');
|
|
31
|
+
return `${existing.slice(0, startAt)}${block}${after}`;
|
|
32
|
+
}
|
|
33
|
+
const separator = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
34
|
+
return `${existing}${separator}${block}`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readTextOrNull, writeText } from '../io/fs.js';
|
|
3
|
+
export class ManifestEntry {
|
|
4
|
+
path;
|
|
5
|
+
sha256;
|
|
6
|
+
constructor(path, sha256) {
|
|
7
|
+
this.path = path;
|
|
8
|
+
this.sha256 = sha256;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** `.ai-agents/.install-manifest.json` — the checksum each pipeline file had when we last wrote it. */
|
|
12
|
+
export class InstallManifest {
|
|
13
|
+
pipelineVersion;
|
|
14
|
+
entries;
|
|
15
|
+
static RELATIVE_PATH = '.ai-agents/.install-manifest.json';
|
|
16
|
+
constructor(pipelineVersion, entries) {
|
|
17
|
+
this.pipelineVersion = pipelineVersion;
|
|
18
|
+
this.entries = entries;
|
|
19
|
+
}
|
|
20
|
+
shaFor(path) {
|
|
21
|
+
return this.entries.find((entry) => entry.path === path)?.sha256 ?? null;
|
|
22
|
+
}
|
|
23
|
+
with(path, sha) {
|
|
24
|
+
const others = this.entries.filter((entry) => entry.path !== path);
|
|
25
|
+
return new InstallManifest(this.pipelineVersion, [...others, new ManifestEntry(path, sha)]);
|
|
26
|
+
}
|
|
27
|
+
withVersion(pipelineVersion) {
|
|
28
|
+
return new InstallManifest(pipelineVersion, this.entries);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* @param fallback used when the repository has no manifest yet — e.g. the
|
|
32
|
+
* published checksums of pipeline v1, so pristine v1 files upgrade in place.
|
|
33
|
+
*/
|
|
34
|
+
static async load(targetDir, fallback) {
|
|
35
|
+
const text = await readTextOrNull(join(targetDir, InstallManifest.RELATIVE_PATH));
|
|
36
|
+
return text === null ? fallback : InstallManifest.parse(text);
|
|
37
|
+
}
|
|
38
|
+
static async loadFile(path) {
|
|
39
|
+
const text = await readTextOrNull(path);
|
|
40
|
+
return text === null ? new InstallManifest('none', []) : InstallManifest.parse(text);
|
|
41
|
+
}
|
|
42
|
+
/** Parse edge for manifest JSON (typed-data T5). */
|
|
43
|
+
static parse(text) {
|
|
44
|
+
const raw = JSON.parse(text);
|
|
45
|
+
if (typeof raw !== 'object' || raw === null)
|
|
46
|
+
return new InstallManifest('none', []);
|
|
47
|
+
const record = raw;
|
|
48
|
+
const files = Array.isArray(record.files) ? record.files : [];
|
|
49
|
+
const entries = files.flatMap((item) => {
|
|
50
|
+
if (typeof item !== 'object' || item === null)
|
|
51
|
+
return [];
|
|
52
|
+
const entry = item;
|
|
53
|
+
return typeof entry.path === 'string' && typeof entry.sha256 === 'string'
|
|
54
|
+
? [new ManifestEntry(entry.path, entry.sha256)]
|
|
55
|
+
: [];
|
|
56
|
+
});
|
|
57
|
+
return new InstallManifest(typeof record.pipelineVersion === 'string' ? record.pipelineVersion : 'none', entries);
|
|
58
|
+
}
|
|
59
|
+
async save(targetDir) {
|
|
60
|
+
const sorted = [...this.entries].sort((a, b) => a.path.localeCompare(b.path));
|
|
61
|
+
await writeText(join(targetDir, InstallManifest.RELATIVE_PATH), `${JSON.stringify({ pipelineVersion: this.pipelineVersion, files: sorted }, null, 2)}\n`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Who owns a file after installation, which decides how update treats it. */
|
|
2
|
+
export var Ownership;
|
|
3
|
+
(function (Ownership) {
|
|
4
|
+
/** Shipped by the pipeline; refreshed on update unless the team edited it. */
|
|
5
|
+
Ownership["Pipeline"] = "pipeline";
|
|
6
|
+
/** Written once as a starting point; the team or an agent owns it afterwards. */
|
|
7
|
+
Ownership["Seed"] = "seed";
|
|
8
|
+
/** Regenerated from the installer's choices on every run. */
|
|
9
|
+
Ownership["Generated"] = "generated";
|
|
10
|
+
/** A delimited section inside a file the team owns (CLAUDE.md, AGENTS.md, .gitignore). */
|
|
11
|
+
Ownership["ManagedSection"] = "managed-section";
|
|
12
|
+
/** Keys merged into a JSON settings file the team owns. */
|
|
13
|
+
Ownership["JsonMerge"] = "json-merge";
|
|
14
|
+
})(Ownership || (Ownership = {}));
|
|
15
|
+
export class PlannedFile {
|
|
16
|
+
relativePath;
|
|
17
|
+
ownership;
|
|
18
|
+
content;
|
|
19
|
+
sectionId;
|
|
20
|
+
commentStyle;
|
|
21
|
+
jsonPatch;
|
|
22
|
+
constructor(relativePath, ownership,
|
|
23
|
+
/** Full content for Pipeline/Seed/Generated; section body for ManagedSection. */
|
|
24
|
+
content, sectionId, commentStyle, jsonPatch) {
|
|
25
|
+
this.relativePath = relativePath;
|
|
26
|
+
this.ownership = ownership;
|
|
27
|
+
this.content = content;
|
|
28
|
+
this.sectionId = sectionId;
|
|
29
|
+
this.commentStyle = commentStyle;
|
|
30
|
+
this.jsonPatch = jsonPatch;
|
|
31
|
+
}
|
|
32
|
+
static pipeline(relativePath, content) {
|
|
33
|
+
return new PlannedFile(relativePath, Ownership.Pipeline, content, null, null, null);
|
|
34
|
+
}
|
|
35
|
+
static seed(relativePath, content) {
|
|
36
|
+
return new PlannedFile(relativePath, Ownership.Seed, content, null, null, null);
|
|
37
|
+
}
|
|
38
|
+
static generated(relativePath, content) {
|
|
39
|
+
return new PlannedFile(relativePath, Ownership.Generated, content, null, null, null);
|
|
40
|
+
}
|
|
41
|
+
static section(relativePath, sectionId, body, style) {
|
|
42
|
+
return new PlannedFile(relativePath, Ownership.ManagedSection, body, sectionId, style, null);
|
|
43
|
+
}
|
|
44
|
+
static settings(relativePath, patch) {
|
|
45
|
+
return new PlannedFile(relativePath, Ownership.JsonMerge, '', null, null, patch);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export class MarketplaceSource {
|
|
49
|
+
name;
|
|
50
|
+
githubRepo;
|
|
51
|
+
constructor(name, githubRepo) {
|
|
52
|
+
this.name = name;
|
|
53
|
+
this.githubRepo = githubRepo;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** The keys this installer adds to `.claude/settings.json`; everything else in the file is preserved. */
|
|
57
|
+
export class ClaudeSettingsPatch {
|
|
58
|
+
marketplaces;
|
|
59
|
+
enabledPlugins;
|
|
60
|
+
constructor(marketplaces, enabledPlugins) {
|
|
61
|
+
this.marketplaces = marketplaces;
|
|
62
|
+
this.enabledPlugins = enabledPlugins;
|
|
63
|
+
}
|
|
64
|
+
isEmpty() {
|
|
65
|
+
return this.marketplaces.length === 0 && this.enabledPlugins.length === 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export class FilePlan {
|
|
69
|
+
files;
|
|
70
|
+
obsoletePaths;
|
|
71
|
+
constructor(files,
|
|
72
|
+
/** Paths from older pipeline versions that should be removed by hand. */
|
|
73
|
+
obsoletePaths) {
|
|
74
|
+
this.files = files;
|
|
75
|
+
this.obsoletePaths = obsoletePaths;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { ClaudeMdMode } from '../domain/claude-md-mode.js';
|
|
4
|
+
import { Runtime } from '../domain/runtime.js';
|
|
5
|
+
import { isSplit } from '../domain/topology.js';
|
|
6
|
+
import { ToolId } from '../domain/tool.js';
|
|
7
|
+
import { COMMAND_CATALOG } from '../registry/commands.js';
|
|
8
|
+
import { SUBAGENT_CATALOG } from '../registry/subagents.js';
|
|
9
|
+
import { TOOL_CATALOG } from '../registry/tools.js';
|
|
10
|
+
import { listFilesRecursive, pathExists } from '../io/fs.js';
|
|
11
|
+
import { renderClaudeCommand, renderClaudeSubagent, renderCodexSkill } from './adapters.js';
|
|
12
|
+
import { CommentStyle } from './managed-section.js';
|
|
13
|
+
import { ClaudeSettingsPatch, FilePlan, MarketplaceSource, PlannedFile } from './planned-file.js';
|
|
14
|
+
import { rulesPresetFor } from './rules-preset.js';
|
|
15
|
+
import { renderSetupGuide } from './setup-guide.js';
|
|
16
|
+
export class InstallChoices {
|
|
17
|
+
topology;
|
|
18
|
+
runtimes;
|
|
19
|
+
tools;
|
|
20
|
+
mapping;
|
|
21
|
+
claudeMd;
|
|
22
|
+
constructor(topology, runtimes, tools, mapping, claudeMd = ClaudeMdMode.Import) {
|
|
23
|
+
this.topology = topology;
|
|
24
|
+
this.runtimes = runtimes;
|
|
25
|
+
this.tools = tools;
|
|
26
|
+
this.mapping = mapping;
|
|
27
|
+
this.claudeMd = claudeMd;
|
|
28
|
+
}
|
|
29
|
+
has(tool) {
|
|
30
|
+
return this.tools.includes(tool);
|
|
31
|
+
}
|
|
32
|
+
targets(runtime) {
|
|
33
|
+
return this.runtimes.includes(runtime);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Files under payload/ai-agents that the team owns after the first install. */
|
|
37
|
+
const SEED_PATHS = ['state/current-stage.md'];
|
|
38
|
+
/** Paths shipped by pipeline v1 that v2 renamed or removed. */
|
|
39
|
+
const OBSOLETE_PATHS = [
|
|
40
|
+
'.ai-agents/agents/02d-api-seam.md',
|
|
41
|
+
'.claude/agents/api-seam.md',
|
|
42
|
+
'.ai-agents/state/contract-api.md',
|
|
43
|
+
];
|
|
44
|
+
function commandIndex(runtime) {
|
|
45
|
+
return COMMAND_CATALOG.all()
|
|
46
|
+
.map((command) => {
|
|
47
|
+
const invocation = runtime === Runtime.ClaudeCode ? `/${command.slug}` : `$flow-${command.slug}`;
|
|
48
|
+
return `- \`${invocation}\` — ${command.description}`;
|
|
49
|
+
})
|
|
50
|
+
.join('\n');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* One section, in AGENTS.md, covering every installed runtime — Codex reads it
|
|
54
|
+
* directly and so does Claude Code, which loads AGENTS.md when the repository
|
|
55
|
+
* has no CLAUDE.md (and through the `@AGENTS.md` import stub otherwise).
|
|
56
|
+
*/
|
|
57
|
+
async function pipelineSection(payloadDir, choices) {
|
|
58
|
+
const base = (await readFile(join(payloadDir, 'root', 'pipeline-section.md'), 'utf8')).replace(/\n+$/, '');
|
|
59
|
+
const parts = [base, ''];
|
|
60
|
+
for (const runtime of choices.runtimes) {
|
|
61
|
+
const label = runtime === Runtime.ClaudeCode ? 'In Claude Code' : 'In Codex';
|
|
62
|
+
parts.push(`${label}:`, '', commandIndex(runtime), '');
|
|
63
|
+
}
|
|
64
|
+
if (choices.targets(Runtime.Codex)) {
|
|
65
|
+
parts.push('Codex has no subagent files: when a command says to run agents in parallel,', 'run them as separate sessions or one after another, each reading its own file', 'in `.ai-agents/agents/`.');
|
|
66
|
+
}
|
|
67
|
+
if (choices.has(ToolId.TaskObserver)) {
|
|
68
|
+
parts.push('', '**task-observer**: load the task-observer skill before the first tool call of', 'every session. Write observation logs to `.ai-agents/observations/` and', 'proposed updates to `.ai-agents/observations/proposals/`. Never edit agent', 'files or the architecture context from an observation.');
|
|
69
|
+
}
|
|
70
|
+
return parts.join('\n');
|
|
71
|
+
}
|
|
72
|
+
function claudeSettingsPatch(choices) {
|
|
73
|
+
const marketplaces = [];
|
|
74
|
+
const plugins = [];
|
|
75
|
+
if (choices.has(ToolId.Superpowers)) {
|
|
76
|
+
plugins.push('superpowers@claude-plugins-official');
|
|
77
|
+
}
|
|
78
|
+
if (choices.has(ToolId.Ponytail)) {
|
|
79
|
+
marketplaces.push(new MarketplaceSource('ponytail', 'DietrichGebert/ponytail'));
|
|
80
|
+
plugins.push('ponytail@ponytail');
|
|
81
|
+
}
|
|
82
|
+
return new ClaudeSettingsPatch(marketplaces, plugins);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Claude Code loads AGENTS.md only when the repository has no CLAUDE.md, and an
|
|
86
|
+
* `@AGENTS.md` import is never loaded twice — so this stub is safe either way,
|
|
87
|
+
* and it is the only thing that works in sessions where direct AGENTS.md
|
|
88
|
+
* support is unavailable (Bedrock, telemetry off, pre-2.1.277).
|
|
89
|
+
*/
|
|
90
|
+
const CLAUDE_MD_IMPORT = [
|
|
91
|
+
'@AGENTS.md',
|
|
92
|
+
'',
|
|
93
|
+
'The delivery pipeline section lives in AGENTS.md so Claude Code and Codex read',
|
|
94
|
+
'the same file. Edit it there, not here.',
|
|
95
|
+
].join('\n');
|
|
96
|
+
export async function buildPlan(payloadDir, config, targetDir, claudeMd) {
|
|
97
|
+
const choices = new InstallChoices(config.topology, config.runtimes, config.tools, config.mapping, claudeMd);
|
|
98
|
+
const files = [];
|
|
99
|
+
const agentsRoot = join(payloadDir, 'ai-agents');
|
|
100
|
+
for (const relative of await listFilesRecursive(agentsRoot)) {
|
|
101
|
+
const content = await readFile(join(agentsRoot, relative), 'utf8');
|
|
102
|
+
const target = `.ai-agents/${relative}`;
|
|
103
|
+
files.push(SEED_PATHS.includes(relative) ? PlannedFile.seed(target, content) : PlannedFile.pipeline(target, content));
|
|
104
|
+
}
|
|
105
|
+
files.push(PlannedFile.generated('.ai-agents/pipeline.config.json', config.toJson()));
|
|
106
|
+
files.push(PlannedFile.seed('.ai-agents/rules.config.json', rulesPresetFor(choices.mapping).toJson()));
|
|
107
|
+
files.push(PlannedFile.generated('.ai-agents/SETUP.md', renderSetupGuide(choices, TOOL_CATALOG)));
|
|
108
|
+
if (isSplit(choices.topology)) {
|
|
109
|
+
files.push(PlannedFile.seed('contracts/README.md', await readFile(join(payloadDir, 'root', 'contracts-README.md'), 'utf8')));
|
|
110
|
+
}
|
|
111
|
+
for (const command of COMMAND_CATALOG.all()) {
|
|
112
|
+
const body = await readFile(join(payloadDir, 'commands', `${command.slug}.md`), 'utf8');
|
|
113
|
+
if (choices.targets(Runtime.ClaudeCode)) {
|
|
114
|
+
files.push(PlannedFile.pipeline(`.claude/commands/${command.slug}.md`, renderClaudeCommand(command, body)));
|
|
115
|
+
}
|
|
116
|
+
if (choices.targets(Runtime.Codex)) {
|
|
117
|
+
files.push(PlannedFile.pipeline(`.agents/skills/flow-${command.slug}/SKILL.md`, renderCodexSkill(command, body)));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
files.push(PlannedFile.section('AGENTS.md', 'pipeline', await pipelineSection(payloadDir, choices), CommentStyle.Html));
|
|
121
|
+
if (choices.targets(Runtime.ClaudeCode)) {
|
|
122
|
+
for (const agent of SUBAGENT_CATALOG.all()) {
|
|
123
|
+
files.push(PlannedFile.pipeline(`.claude/agents/${agent.name}.md`, renderClaudeSubagent(agent)));
|
|
124
|
+
}
|
|
125
|
+
const claudeMdExists = await pathExists(join(targetDir, 'CLAUDE.md'));
|
|
126
|
+
if (choices.claudeMd === ClaudeMdMode.Import || claudeMdExists) {
|
|
127
|
+
files.push(PlannedFile.section('CLAUDE.md', 'pipeline', CLAUDE_MD_IMPORT, CommentStyle.Html));
|
|
128
|
+
}
|
|
129
|
+
const patch = claudeSettingsPatch(choices);
|
|
130
|
+
if (!patch.isEmpty()) {
|
|
131
|
+
files.push(PlannedFile.settings('.claude/settings.json', patch));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const ignored = TOOL_CATALOG.forIds(choices.tools).flatMap((tool) => tool.ignorePaths);
|
|
135
|
+
if (ignored.length > 0) {
|
|
136
|
+
files.push(PlannedFile.section('.gitignore', 'tools', ignored.join('\n'), CommentStyle.Hash));
|
|
137
|
+
}
|
|
138
|
+
return new FilePlan(files, OBSOLETE_PATHS);
|
|
139
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { CanonMapping } from '../domain/canon-mapping.js';
|
|
2
|
+
/** Starting values for .ai-agents/rules.config.json; /bootstrap narrows them against real history. */
|
|
3
|
+
export class RulesPreset {
|
|
4
|
+
typedBoundaryPaths;
|
|
5
|
+
sourcePaths;
|
|
6
|
+
testPaths;
|
|
7
|
+
constructor(typedBoundaryPaths, sourcePaths, testPaths) {
|
|
8
|
+
this.typedBoundaryPaths = typedBoundaryPaths;
|
|
9
|
+
this.sourcePaths = sourcePaths;
|
|
10
|
+
this.testPaths = testPaths;
|
|
11
|
+
}
|
|
12
|
+
toJson() {
|
|
13
|
+
return `${JSON.stringify({
|
|
14
|
+
typedBoundaries: { paths: this.typedBoundaryPaths, allow: [] },
|
|
15
|
+
testWithChange: { sourcePaths: this.sourcePaths, testPaths: this.testPaths, exemptionTag: 'no-test' },
|
|
16
|
+
}, null, 2)}\n`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function rulesPresetFor(mapping) {
|
|
20
|
+
switch (mapping) {
|
|
21
|
+
case CanonMapping.Laravel:
|
|
22
|
+
return new RulesPreset(['app/Modules/*/Domain/**', 'app/Modules/*/Application/**'], ['app/Modules/**'], ['tests/**']);
|
|
23
|
+
case CanonMapping.NestJs:
|
|
24
|
+
case CanonMapping.Express:
|
|
25
|
+
return new RulesPreset(['src/modules/*/domain/**', 'src/modules/*/application/**'], ['src/modules/**'], ['**/*.spec.ts', '**/*.test.ts', 'test/**']);
|
|
26
|
+
case CanonMapping.Django:
|
|
27
|
+
case CanonMapping.FastApi:
|
|
28
|
+
return new RulesPreset(['**/modules/*/domain/**', '**/modules/*/application/**'], ['**/modules/**'], ['**/tests/**', '**/test_*.py']);
|
|
29
|
+
case CanonMapping.Spring:
|
|
30
|
+
case CanonMapping.Go:
|
|
31
|
+
case CanonMapping.Custom:
|
|
32
|
+
case null:
|
|
33
|
+
// The typed-boundaries checker covers PHP, TypeScript and Python; other
|
|
34
|
+
// stacks rely on their compiler plus review. /bootstrap fills these in.
|
|
35
|
+
return new RulesPreset([], [], []);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Runtime } from '../domain/runtime.js';
|
|
2
|
+
import { StepLocation } from '../domain/tool.js';
|
|
3
|
+
import { Topology, describeTopology } from '../domain/topology.js';
|
|
4
|
+
const runtimeLabel = (runtime) => (runtime === Runtime.ClaudeCode ? 'Claude Code' : 'Codex');
|
|
5
|
+
function stepLine(step) {
|
|
6
|
+
const where = step.location === StepLocation.Shell ? 'shell' : 'inside the agent';
|
|
7
|
+
const check = step.verified ? '' : ' _(unverified: closest documented form — confirm against the tool README)_';
|
|
8
|
+
return `- [ ] ${step.description} (${where}):\n \`${step.command}\`${check}`;
|
|
9
|
+
}
|
|
10
|
+
function toolBlock(tool, runtimes) {
|
|
11
|
+
const lines = [`### ${tool.name}`, '', `${tool.purpose}`, '', `Source: \`${tool.source}\` · Used by: ${tool.usedBy.join(', ')}`, ''];
|
|
12
|
+
for (const runtime of runtimes) {
|
|
13
|
+
const support = tool.supportFor(runtime);
|
|
14
|
+
if (support === null) {
|
|
15
|
+
lines.push(`**${runtimeLabel(runtime)}**: not supported — skip.`, '');
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
lines.push(`**${runtimeLabel(runtime)}**`, '', ...support.steps.map(stepLine));
|
|
19
|
+
if (support.caveat.length > 0)
|
|
20
|
+
lines.push('', `> ${support.caveat}`);
|
|
21
|
+
lines.push('');
|
|
22
|
+
}
|
|
23
|
+
if (tool.usage.length > 0) {
|
|
24
|
+
lines.push('Day to day:', '', ...tool.usage.map((line) => `- ${line}`), '');
|
|
25
|
+
}
|
|
26
|
+
return lines.join('\n');
|
|
27
|
+
}
|
|
28
|
+
function nextStep(topology) {
|
|
29
|
+
const seam = topology === Topology.SplitFrontend
|
|
30
|
+
? '\nThis is a `split-frontend` repo: before the first feature, import its contract with `/seam import {path-or-url}`.'
|
|
31
|
+
: '';
|
|
32
|
+
return [
|
|
33
|
+
'## Then',
|
|
34
|
+
'',
|
|
35
|
+
'- Empty repository, or only framework defaults → `/scaffold`, review the slice, then `/bootstrap`',
|
|
36
|
+
'- Existing code → `/bootstrap`',
|
|
37
|
+
'- After bootstrap: `/intake` → `/impact` → `/contract` → `/sequence` → `/implement` …',
|
|
38
|
+
seam,
|
|
39
|
+
].join('\n');
|
|
40
|
+
}
|
|
41
|
+
/** `.ai-agents/SETUP.md` — the per-repo checklist for everything the file copy cannot do. */
|
|
42
|
+
export function renderSetupGuide(choices, catalog) {
|
|
43
|
+
const tools = catalog.forIds(choices.tools);
|
|
44
|
+
const header = [
|
|
45
|
+
'# Pipeline setup',
|
|
46
|
+
'',
|
|
47
|
+
'_Generated by `fullstack-agentic-flow`. Regenerated on every `init`/`update`; edit `.ai-agents/toolchain.md` instead._',
|
|
48
|
+
'',
|
|
49
|
+
`**Topology**: ${describeTopology(choices.topology)}`,
|
|
50
|
+
`**Runtimes**: ${choices.runtimes.map(runtimeLabel).join(', ')}`,
|
|
51
|
+
`**Canon mapping**: ${choices.mapping ?? 'not detected — /scaffold or /bootstrap records it'}`,
|
|
52
|
+
'',
|
|
53
|
+
'Check with `npx fullstack-agentic-flow doctor`.',
|
|
54
|
+
'',
|
|
55
|
+
];
|
|
56
|
+
const body = tools.length === 0
|
|
57
|
+
? ['## Tools', '', 'None selected. Add later with `npx fullstack-agentic-flow init --tools …` (existing files are respected).', '']
|
|
58
|
+
: ['## Tools', '', 'Shell steps can be run by the installer with `--run-tools`; steps marked _inside the agent_ need you.', '', ...tools.map((tool) => toolBlock(tool, choices.runtimes))];
|
|
59
|
+
return `${[...header, ...body, nextStep(choices.topology)].join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '')}\n`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, access, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
4
|
+
export async function pathExists(path) {
|
|
5
|
+
try {
|
|
6
|
+
await access(path);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function readTextOrNull(path) {
|
|
14
|
+
return (await pathExists(path)) ? readFile(path, 'utf8') : null;
|
|
15
|
+
}
|
|
16
|
+
export async function writeText(path, content) {
|
|
17
|
+
await mkdir(dirname(path), { recursive: true });
|
|
18
|
+
await writeFile(path, content, 'utf8');
|
|
19
|
+
}
|
|
20
|
+
export function sha256(content) {
|
|
21
|
+
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
22
|
+
}
|
|
23
|
+
/** Every file under `root`, as forward-slash paths relative to `root`, sorted. */
|
|
24
|
+
export async function listFilesRecursive(root) {
|
|
25
|
+
const found = [];
|
|
26
|
+
const walk = async (dir) => {
|
|
27
|
+
for (const entry of await readdir(dir)) {
|
|
28
|
+
const full = join(dir, entry);
|
|
29
|
+
if ((await stat(full)).isDirectory()) {
|
|
30
|
+
await walk(full);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
found.push(relative(root, full).split(sep).join('/'));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
await walk(root);
|
|
38
|
+
return found.sort();
|
|
39
|
+
}
|