@baldurpan/create-ai-workflow 0.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/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/agents-block.js +70 -0
- package/dist/bin.js +14 -0
- package/dist/check/markdown.js +47 -0
- package/dist/check/parse.js +113 -0
- package/dist/check/rules.js +245 -0
- package/dist/cli.js +109 -0
- package/dist/commands/check.js +41 -0
- package/dist/commands/install.js +76 -0
- package/dist/commands/standards-add.js +145 -0
- package/dist/commands/update.js +214 -0
- package/dist/layout.js +115 -0
- package/dist/log.js +20 -0
- package/dist/manifest.js +53 -0
- package/dist/paths.js +41 -0
- package/package.json +37 -0
- package/templates/blocks/agents-block.md +30 -0
- package/templates/claude/agents/planner.agent.md +60 -0
- package/templates/claude/agents/reviewer.agent.md +77 -0
- package/templates/context/README.md +81 -0
- package/templates/context/plan-template.md +69 -0
- package/templates/context/plan-template.notes.md +55 -0
- package/templates/context/roles/coder.md +76 -0
- package/templates/context/workflow.md +143 -0
- package/templates/skills/feature-close/SKILL.md +102 -0
- package/templates/skills/feature-implement/SKILL.md +142 -0
- package/templates/skills/feature-plan/SKILL.md +138 -0
- package/templates/skills/feature-status/SKILL.md +86 -0
- package/templates/skills/onboard/SKILL.md +122 -0
- package/templates/skills/orchestrate/SKILL.md +92 -0
- package/templates/skills/roadmap/SKILL.md +97 -0
- package/templates/standards/.source +8 -0
- package/templates/standards/README.md +104 -0
- package/templates/standards/architecture/api-design.md +212 -0
- package/templates/standards/architecture/dependency-boundaries.md +104 -0
- package/templates/standards/architecture/feature-driven.md +102 -0
- package/templates/standards/architecture/folder-structure.md +108 -0
- package/templates/standards/architecture/monorepos.md +107 -0
- package/templates/standards/architecture/refactoring.md +97 -0
- package/templates/standards/architecture/shared-code.md +81 -0
- package/templates/standards/docs/PHP-SPEC.md +1195 -0
- package/templates/standards/docs/SPEC.md +540 -0
- package/templates/standards/examples/bad/data-fetching.tsx +52 -0
- package/templates/standards/examples/bad/derived-state.tsx +60 -0
- package/templates/standards/examples/bad/feature-component.tsx +80 -0
- package/templates/standards/examples/bad/prop-drilling.tsx +70 -0
- package/templates/standards/examples/good/data-fetching.tsx +53 -0
- package/templates/standards/examples/good/derived-state.tsx +52 -0
- package/templates/standards/examples/good/feature-component.tsx +141 -0
- package/templates/standards/examples/good/invoice.schema.ts +20 -0
- package/templates/standards/examples/good/prop-drilling.tsx +83 -0
- package/templates/standards/philosophy/ai-agent-behavior.md +93 -0
- package/templates/standards/philosophy/core-principles.md +49 -0
- package/templates/standards/philosophy/incremental-abstraction.md +56 -0
- package/templates/standards/philosophy/maintainability.md +66 -0
- package/templates/standards/philosophy/readability.md +87 -0
- package/templates/standards/php/README.md +62 -0
- package/templates/standards/php/anti-patterns.md +259 -0
- package/templates/standards/php/architecture.md +241 -0
- package/templates/standards/php/frameworks.md +197 -0
- package/templates/standards/php/rules.md +205 -0
- package/templates/standards/php/security.md +190 -0
- package/templates/standards/php/testing.md +139 -0
- package/templates/standards/php/tooling.md +245 -0
- package/templates/standards/react/accessibility.md +130 -0
- package/templates/standards/react/anti-patterns.md +187 -0
- package/templates/standards/react/component-design.md +139 -0
- package/templates/standards/react/error-boundaries.md +195 -0
- package/templates/standards/react/forms.md +147 -0
- package/templates/standards/react/hooks.md +88 -0
- package/templates/standards/react/memoization.md +87 -0
- package/templates/standards/react/state-management.md +123 -0
- package/templates/standards/react/testing.md +118 -0
- package/templates/standards/react/use-effect.md +110 -0
- package/templates/standards/security/api-security.md +136 -0
- package/templates/standards/security/auth.md +112 -0
- package/templates/standards/security/secrets.md +105 -0
- package/templates/standards/security/validation.md +89 -0
- package/templates/standards/templates/.editorconfig +16 -0
- package/templates/standards/templates/.nvmrc +1 -0
- package/templates/standards/templates/AGENTS.md +46 -0
- package/templates/standards/templates/CLAUDE.md +3 -0
- package/templates/standards/templates/_dot_gitignore +50 -0
- package/templates/standards/templates/biome.json +53 -0
- package/templates/standards/templates/eslint.config.js +61 -0
- package/templates/standards/templates/project-readme.md +76 -0
- package/templates/standards/templates/pull-request-template.md +46 -0
- package/templates/standards/templates/tsconfig.json +35 -0
- package/templates/standards/tooling/biome.md +92 -0
- package/templates/standards/tooling/ci.md +128 -0
- package/templates/standards/tooling/dates.md +194 -0
- package/templates/standards/tooling/dependencies.md +90 -0
- package/templates/standards/tooling/nx.md +113 -0
- package/templates/standards/tooling/observability.md +183 -0
- package/templates/standards/tooling/prisma.md +152 -0
- package/templates/standards/tooling/shadcn.md +115 -0
- package/templates/standards/tooling/tailwind.md +135 -0
- package/templates/standards/tooling/tanstack-query.md +214 -0
- package/templates/standards/tooling/tanstack-router.md +365 -0
- package/templates/standards/tooling/tanstack.md +31 -0
- package/templates/standards/tooling/vite.md +135 -0
- package/templates/standards/typescript/anti-patterns.md +107 -0
- package/templates/standards/typescript/error-handling.md +216 -0
- package/templates/standards/typescript/naming.md +78 -0
- package/templates/standards/typescript/rules.md +159 -0
- package/templates/standards/typescript/tsconfig/base.json +32 -0
- package/templates/standards/typescript/tsconfig/nextjs.json +12 -0
- package/templates/standards/typescript/tsconfig/react.json +9 -0
- package/templates/standards/typescript/validation.md +104 -0
- package/templates/stubs/executors.md +49 -0
- package/templates/stubs/findings.md +40 -0
- package/templates/stubs/history.md +13 -0
- package/templates/stubs/roadmap.md +29 -0
- package/templates/stubs/stack.md +35 -0
- package/templates/stubs/verify.md +48 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { check } from "./commands/check.js";
|
|
3
|
+
import { install } from "./commands/install.js";
|
|
4
|
+
import { standardsAdd } from "./commands/standards-add.js";
|
|
5
|
+
import { update } from "./commands/update.js";
|
|
6
|
+
import { bold, cyan, dim, info, UserError } from "./log.js";
|
|
7
|
+
import { packageVersion } from "./paths.js";
|
|
8
|
+
const USAGE = `${bold('create-ai-workflow')} — overlay a tiered planning workflow onto an existing repository
|
|
9
|
+
|
|
10
|
+
${bold('Usage')}
|
|
11
|
+
npx @baldurpan/create-ai-workflow install into this repository
|
|
12
|
+
npm create @baldurpan/ai-workflow the same thing, shorter
|
|
13
|
+
npx @baldurpan/create-ai-workflow update replace the tool-owned files with this version
|
|
14
|
+
npx @baldurpan/create-ai-workflow standards add <url> swap context/standards/ for a git repository
|
|
15
|
+
npx @baldurpan/create-ai-workflow check report structural breakage; never writes
|
|
16
|
+
|
|
17
|
+
${bold('Options')}
|
|
18
|
+
--dir <path> act on this directory instead of the working directory
|
|
19
|
+
--dry-run ${dim('update:')} print the plan and change nothing
|
|
20
|
+
--force ${dim('update:')} back up edited files (.bak) and take ours
|
|
21
|
+
--generate-index ${dim('standards add:')} build a conditional-loading table without asking
|
|
22
|
+
--version, --help
|
|
23
|
+
|
|
24
|
+
${bold('What it installs')}
|
|
25
|
+
context/ the workflow's documents. Tool-owned files are replaced on update; your
|
|
26
|
+
roadmap, plans, findings, stack, verify and executors never are.
|
|
27
|
+
.claude/ seven skills and two subagent definitions, for Claude Code.
|
|
28
|
+
.agents/ the same seven skills, for Codex and anything else reading that tree.
|
|
29
|
+
AGENTS.md a delimited block, merged into whatever is already there.
|
|
30
|
+
CLAUDE.md a single ${cyan('@AGENTS.md')} line, and only if the file does not exist.
|
|
31
|
+
|
|
32
|
+
Nothing is committed. Review the diff, then run ${cyan('/onboard')} in your agent.
|
|
33
|
+
`;
|
|
34
|
+
export function parseArgs(argv) {
|
|
35
|
+
const args = {
|
|
36
|
+
command: '',
|
|
37
|
+
rest: [],
|
|
38
|
+
dir: process.cwd(),
|
|
39
|
+
dryRun: false,
|
|
40
|
+
force: false,
|
|
41
|
+
generateIndex: false,
|
|
42
|
+
help: false,
|
|
43
|
+
version: false,
|
|
44
|
+
};
|
|
45
|
+
const positional = [];
|
|
46
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
47
|
+
const arg = argv[i];
|
|
48
|
+
switch (arg) {
|
|
49
|
+
case '--dir':
|
|
50
|
+
i += 1;
|
|
51
|
+
args.dir = path.resolve(argv[i] ?? '.');
|
|
52
|
+
break;
|
|
53
|
+
case '--dry-run':
|
|
54
|
+
args.dryRun = true;
|
|
55
|
+
break;
|
|
56
|
+
case '--force':
|
|
57
|
+
args.force = true;
|
|
58
|
+
break;
|
|
59
|
+
case '--generate-index':
|
|
60
|
+
args.generateIndex = true;
|
|
61
|
+
break;
|
|
62
|
+
case '--help':
|
|
63
|
+
case '-h':
|
|
64
|
+
args.help = true;
|
|
65
|
+
break;
|
|
66
|
+
case '--version':
|
|
67
|
+
case '-v':
|
|
68
|
+
args.version = true;
|
|
69
|
+
break;
|
|
70
|
+
default:
|
|
71
|
+
if (arg.startsWith('-'))
|
|
72
|
+
throw new UserError(`unknown option ${arg}`);
|
|
73
|
+
positional.push(arg);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
args.command = positional[0] ?? 'install';
|
|
77
|
+
args.rest = positional.slice(1);
|
|
78
|
+
return args;
|
|
79
|
+
}
|
|
80
|
+
export async function main(argv) {
|
|
81
|
+
const args = parseArgs(argv);
|
|
82
|
+
if (args.help) {
|
|
83
|
+
info(USAGE);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if (args.version) {
|
|
87
|
+
info(packageVersion());
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
switch (args.command) {
|
|
91
|
+
case 'install':
|
|
92
|
+
return install(args.dir);
|
|
93
|
+
case 'update':
|
|
94
|
+
return update(args.dir, { dryRun: args.dryRun, force: args.force });
|
|
95
|
+
case 'check':
|
|
96
|
+
return check(args.dir);
|
|
97
|
+
case 'standards': {
|
|
98
|
+
const [sub, url] = args.rest;
|
|
99
|
+
if (sub !== 'add') {
|
|
100
|
+
throw new UserError('the only standards subcommand is `standards add <git-url>`');
|
|
101
|
+
}
|
|
102
|
+
if (!url)
|
|
103
|
+
throw new UserError('`standards add` needs a git URL');
|
|
104
|
+
return standardsAdd(args.dir, url, { generateIndex: args.generateIndex });
|
|
105
|
+
}
|
|
106
|
+
default:
|
|
107
|
+
throw new UserError(`unknown command \`${args.command}\`. Run with --help for the four it knows.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { runChecks } from "../check/rules.js";
|
|
3
|
+
import { CONTEXT_DIR } from "../layout.js";
|
|
4
|
+
import { bold, dim, green, info, red, UserError, yellow } from "../log.js";
|
|
5
|
+
import { exists } from "../paths.js";
|
|
6
|
+
/**
|
|
7
|
+
* Reports structural breakage and nothing else. It never writes — there is no `--fix`, because the moment
|
|
8
|
+
* it can repair a ledger, a program's edit competes with a hand edit. Nothing depends on it: delete it and
|
|
9
|
+
* every workflow answer is unchanged.
|
|
10
|
+
*/
|
|
11
|
+
export function check(root) {
|
|
12
|
+
if (!exists(path.join(root, CONTEXT_DIR))) {
|
|
13
|
+
throw new UserError(`no ${CONTEXT_DIR}/ in ${root} — nothing to check.\n` +
|
|
14
|
+
' Run `npx @baldurpan/create-ai-workflow` to install the overlay.');
|
|
15
|
+
}
|
|
16
|
+
const problems = runChecks(root);
|
|
17
|
+
const errors = problems.filter((p) => p.level === 'error');
|
|
18
|
+
const notes = problems.filter((p) => p.level === 'note');
|
|
19
|
+
if (problems.length === 0) {
|
|
20
|
+
info(`${green('ok')} roadmap, plans, history and findings are structurally sound`);
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
for (const problem of problems) {
|
|
24
|
+
const marker = problem.level === 'error' ? red('error') : yellow('note ');
|
|
25
|
+
const where = problem.line ? `${problem.file}:${problem.line}` : problem.file;
|
|
26
|
+
info(`${marker} ${bold(where)}`);
|
|
27
|
+
info(` ${problem.message}`);
|
|
28
|
+
info(` ${dim(problem.rule)}`);
|
|
29
|
+
info();
|
|
30
|
+
}
|
|
31
|
+
const parts = [];
|
|
32
|
+
if (errors.length)
|
|
33
|
+
parts.push(`${errors.length} error${errors.length === 1 ? '' : 's'}`);
|
|
34
|
+
if (notes.length)
|
|
35
|
+
parts.push(`${notes.length} note${notes.length === 1 ? '' : 's'}`);
|
|
36
|
+
info(parts.join(', '));
|
|
37
|
+
if (errors.length === 0) {
|
|
38
|
+
info(dim('Notes do not fail the check — nothing here is structurally broken.'));
|
|
39
|
+
}
|
|
40
|
+
return errors.length > 0 ? 1 : 0;
|
|
41
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { apply as applyBlock, BlockConflictError } from "../agents-block.js";
|
|
4
|
+
import { ADAPTER_SKILL_DIRS, AGENTS_BLOCK_KEY, CONTEXT_DIR, DEFAULT_ADAPTERS, STUB_DIRS, STUBS, agentsBlockBody, managedFiles, readTemplate, renderManaged, } from "../layout.js";
|
|
5
|
+
import { bold, cyan, dim, green, info, UserError, warn } from "../log.js";
|
|
6
|
+
import { SCHEMA_VERSION, writeManifest } from "../manifest.js";
|
|
7
|
+
import { exists, hash, packageVersion } from "../paths.js";
|
|
8
|
+
const CLAUDE_MD_IMPORT = '@AGENTS.md';
|
|
9
|
+
function write(root, dest, content) {
|
|
10
|
+
const full = path.join(root, dest);
|
|
11
|
+
mkdirSync(path.dirname(full), { recursive: true });
|
|
12
|
+
writeFileSync(full, content, 'utf8');
|
|
13
|
+
}
|
|
14
|
+
export function install(root) {
|
|
15
|
+
if (exists(path.join(root, CONTEXT_DIR))) {
|
|
16
|
+
throw new UserError(`${CONTEXT_DIR}/ already exists in ${root}.\n` +
|
|
17
|
+
' This tool overlays onto a repository that does not have one; it does not merge into an\n' +
|
|
18
|
+
' existing directory. If this is an earlier install, run `update` instead. If it is yours,\n' +
|
|
19
|
+
' move it aside first.');
|
|
20
|
+
}
|
|
21
|
+
const adapters = DEFAULT_ADAPTERS;
|
|
22
|
+
const manifest = {
|
|
23
|
+
schemaVersion: SCHEMA_VERSION,
|
|
24
|
+
version: packageVersion(),
|
|
25
|
+
adapters: [...adapters],
|
|
26
|
+
managedFiles: {},
|
|
27
|
+
};
|
|
28
|
+
info(bold('Installing the ai-workflow overlay'));
|
|
29
|
+
info();
|
|
30
|
+
const managed = managedFiles(adapters);
|
|
31
|
+
for (const file of managed) {
|
|
32
|
+
const content = renderManaged(file);
|
|
33
|
+
write(root, file.dest, content);
|
|
34
|
+
manifest.managedFiles[file.dest] = hash(content);
|
|
35
|
+
}
|
|
36
|
+
info(`${green('+')} ${managed.length} tool-owned files ${dim('(replaced on update)')}`);
|
|
37
|
+
info(dim(` the seven skills go to ${adapters.map((a) => `${ADAPTER_SKILL_DIRS[a]}/`).join(' and ')} — ` +
|
|
38
|
+
'one body, one directory per host, neither hand-edited'));
|
|
39
|
+
for (const stub of STUBS) {
|
|
40
|
+
write(root, stub.dest, readTemplate(stub.source));
|
|
41
|
+
}
|
|
42
|
+
for (const dir of STUB_DIRS) {
|
|
43
|
+
write(root, `${dir}/.gitkeep`, '');
|
|
44
|
+
}
|
|
45
|
+
info(`${green('+')} ${STUBS.length} project-owned stubs and ${STUB_DIRS.length} directories ` +
|
|
46
|
+
`${dim('(yours — the updater cannot reach them)')}`);
|
|
47
|
+
const agentsPath = path.join(root, 'AGENTS.md');
|
|
48
|
+
const body = agentsBlockBody();
|
|
49
|
+
const before = exists(agentsPath) ? readFileSync(agentsPath, 'utf8') : null;
|
|
50
|
+
try {
|
|
51
|
+
writeFileSync(agentsPath, applyBlock(before, body), 'utf8');
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof BlockConflictError)
|
|
55
|
+
throw new UserError(error.message);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
manifest.managedFiles[AGENTS_BLOCK_KEY] = hash(body);
|
|
59
|
+
info(`${green(before === null ? '+' : '~')} AGENTS.md ${dim('— the delimited block only')}`);
|
|
60
|
+
const claudeMdPath = path.join(root, 'CLAUDE.md');
|
|
61
|
+
if (!exists(claudeMdPath)) {
|
|
62
|
+
writeFileSync(claudeMdPath, `# CLAUDE.md\n\n${CLAUDE_MD_IMPORT}\n\nThe import above is this project's agent-neutral instruction set, expanded into context at launch.\n**Everything that is not Claude-Code-specific belongs there, not here.**\n`, 'utf8');
|
|
63
|
+
info(`${green('+')} CLAUDE.md ${dim(`— one ${CLAUDE_MD_IMPORT} line. Yours from here on.`)}`);
|
|
64
|
+
}
|
|
65
|
+
else if (!readFileSync(claudeMdPath, 'utf8').includes(CLAUDE_MD_IMPORT)) {
|
|
66
|
+
warn(`CLAUDE.md exists without an ${CLAUDE_MD_IMPORT} import — left untouched.\n` +
|
|
67
|
+
` Add the single line ${cyan(CLAUDE_MD_IMPORT)} to it so the block above reaches Claude Code.`);
|
|
68
|
+
}
|
|
69
|
+
writeManifest(root, manifest);
|
|
70
|
+
info();
|
|
71
|
+
info(bold('Next'));
|
|
72
|
+
info(` 1. Review the diff. ${dim('Nothing was committed — that is deliberate.')}`);
|
|
73
|
+
info(` 2. Run ${cyan('/onboard')} in your agent to fill in verify.md, executors.md and stack.md.`);
|
|
74
|
+
info(` 3. ${cyan('/roadmap "some idea"')} starts the loop.`);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, cpSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { parseTables } from "../check/markdown.js";
|
|
7
|
+
import { STANDARDS_PREFIX } from "../layout.js";
|
|
8
|
+
import { bold, cyan, dim, green, info, UserError, warn } from "../log.js";
|
|
9
|
+
import { readManifest, writeManifest } from "../manifest.js";
|
|
10
|
+
import { exists, walk } from "../paths.js";
|
|
11
|
+
const INDEX_HEADER = ['If the task involves…', 'Load…'];
|
|
12
|
+
function git(args, cwd) {
|
|
13
|
+
try {
|
|
14
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const message = error.stderr ?? error.message;
|
|
18
|
+
throw new UserError(`git ${args[0]} failed:\n ${message.trim()}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The conditional-loading table is the interface, not the files behind it — agents read that table and pull
|
|
23
|
+
* exactly what it names. A tree whose README has no usable table is a tree the skills cannot navigate.
|
|
24
|
+
*/
|
|
25
|
+
export function findIndexTable(readme, root) {
|
|
26
|
+
for (const table of parseTables(readme)) {
|
|
27
|
+
if (table.header.length < 2 || table.rows.length < 2)
|
|
28
|
+
continue;
|
|
29
|
+
const targets = table.rows.flatMap((row) => [...(row.cells[1] ?? '').matchAll(/\]\(([^)\s#]+)/g)].map((m) => m[1]));
|
|
30
|
+
if (targets.length < 2)
|
|
31
|
+
continue;
|
|
32
|
+
const missing = targets.filter((t) => !/^[a-z]+:/i.test(t) && !existsSync(path.join(root, t)));
|
|
33
|
+
return { rows: table.rows.length, missing: [...new Set(missing)] };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
export function generateIndexTable(root) {
|
|
38
|
+
const files = walk(root).filter((rel) => rel.endsWith('.md') && path.dirname(rel) !== '.');
|
|
39
|
+
const byDir = new Map();
|
|
40
|
+
for (const rel of files) {
|
|
41
|
+
const dir = rel.split(path.sep)[0];
|
|
42
|
+
byDir.set(dir, [...(byDir.get(dir) ?? []), rel.split(path.sep).join('/')]);
|
|
43
|
+
}
|
|
44
|
+
const lines = [
|
|
45
|
+
'## Conditional loading',
|
|
46
|
+
'',
|
|
47
|
+
'**Generated from this tree\'s directory structure at install time.** It is a starting point, not a',
|
|
48
|
+
'considered index — rewrite the left column to say what each group is actually *for*, because that is',
|
|
49
|
+
'the column agents match against.',
|
|
50
|
+
'',
|
|
51
|
+
`| ${INDEX_HEADER.join(' | ')} |`,
|
|
52
|
+
'|---|---|',
|
|
53
|
+
];
|
|
54
|
+
for (const [dir, paths] of [...byDir].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
55
|
+
const links = paths.map((p) => `[\`${p}\`](${p})`).join(', ');
|
|
56
|
+
lines.push(`| ${dir} | ${links} |`);
|
|
57
|
+
}
|
|
58
|
+
return `${lines.join('\n')}\n`;
|
|
59
|
+
}
|
|
60
|
+
async function confirm(question) {
|
|
61
|
+
if (!process.stdin.isTTY)
|
|
62
|
+
return false;
|
|
63
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
64
|
+
try {
|
|
65
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
66
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
rl.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export async function standardsAdd(root, url, options) {
|
|
73
|
+
const manifest = readManifest(root);
|
|
74
|
+
const destination = path.join(root, STANDARDS_PREFIX.replace(/\/$/, ''));
|
|
75
|
+
const scratch = mkdtempSync(path.join(tmpdir(), 'ai-workflow-standards-'));
|
|
76
|
+
try {
|
|
77
|
+
info(`${dim('cloning')} ${url}`);
|
|
78
|
+
git(['clone', '--depth', '1', '--quiet', url, scratch]);
|
|
79
|
+
const ref = git(['rev-parse', 'HEAD'], scratch).trim();
|
|
80
|
+
const readmePath = path.join(scratch, 'README.md');
|
|
81
|
+
if (!exists(readmePath)) {
|
|
82
|
+
throw new UserError('that repository has no README.md.\n' +
|
|
83
|
+
` The skills say "consult the conditional loading table in ${STANDARDS_PREFIX}README.md";\n` +
|
|
84
|
+
' without that file there is nothing for an agent to traverse. Refusing to install it.');
|
|
85
|
+
}
|
|
86
|
+
const readme = readFileSync(readmePath, 'utf8');
|
|
87
|
+
const table = findIndexTable(readme, scratch);
|
|
88
|
+
let generated = null;
|
|
89
|
+
if (table === null) {
|
|
90
|
+
info();
|
|
91
|
+
warn('that README has no conditional-loading table.');
|
|
92
|
+
info(dim(' Agents read that table and load exactly what it names. Without one, the standards are'));
|
|
93
|
+
info(dim(' present but unreachable, and every task loads nothing or everything.'));
|
|
94
|
+
info();
|
|
95
|
+
const ok = options.generateIndex || (await confirm('Generate one from the directory structure?'));
|
|
96
|
+
if (!ok) {
|
|
97
|
+
throw new UserError('refusing to install a standards tree the skills cannot navigate.\n' +
|
|
98
|
+
' Add a conditional-loading table to that repo\'s README.md, or re-run with --generate-index.');
|
|
99
|
+
}
|
|
100
|
+
generated = generateIndexTable(scratch);
|
|
101
|
+
}
|
|
102
|
+
else if (table.missing.length > 0) {
|
|
103
|
+
warn(`the conditional-loading table names ${table.missing.length} file${table.missing.length === 1 ? '' : 's'} that ` +
|
|
104
|
+
'do not exist in that repository:');
|
|
105
|
+
for (const missing of table.missing.slice(0, 10))
|
|
106
|
+
info(` ${missing}`);
|
|
107
|
+
if (table.missing.length > 10)
|
|
108
|
+
info(dim(` …and ${table.missing.length - 10} more`));
|
|
109
|
+
info(dim(' Installed anyway — those rows will simply never resolve.'));
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
info(`${green('ok')} conditional-loading table found — ${table.rows} rows, every target resolves`);
|
|
113
|
+
}
|
|
114
|
+
rmSync(path.join(scratch, '.git'), { recursive: true, force: true });
|
|
115
|
+
if (generated) {
|
|
116
|
+
writeFileSync(readmePath, `${readme.replace(/\s+$/, '')}\n\n${generated}`, 'utf8');
|
|
117
|
+
}
|
|
118
|
+
writeFileSync(path.join(scratch, '.source'), `# Origin of this standards tree. Project-owned: \`update\` does not reach it.\n` +
|
|
119
|
+
`origin=${url}\nref=${ref}\nvendored=${new Date().toISOString()}\n`, 'utf8');
|
|
120
|
+
rmSync(destination, { recursive: true, force: true });
|
|
121
|
+
cpSync(scratch, destination, { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
// Whatever lands is project-owned from this point — it drops out of the manifest, so `update` can never
|
|
127
|
+
// clobber it.
|
|
128
|
+
let dropped = 0;
|
|
129
|
+
for (const key of Object.keys(manifest.managedFiles)) {
|
|
130
|
+
if (key.startsWith(STANDARDS_PREFIX)) {
|
|
131
|
+
delete manifest.managedFiles[key];
|
|
132
|
+
dropped += 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
writeManifest(root, manifest);
|
|
136
|
+
info();
|
|
137
|
+
info(`${green('done')} ${STANDARDS_PREFIX} replaced from ${bold(url)}`);
|
|
138
|
+
if (dropped > 0) {
|
|
139
|
+
info(dim(` ${dropped} entries dropped from the manifest — this tree is yours now, and updates`));
|
|
140
|
+
info(dim(' will never overwrite it.'));
|
|
141
|
+
}
|
|
142
|
+
info(` Review the diff, then check that ${cyan(`${STANDARDS_PREFIX}README.md`)} reads as an index an agent`);
|
|
143
|
+
info(' would actually follow. Nothing was committed.');
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { apply as applyBlock, BlockConflictError, inspect } from "../agents-block.js";
|
|
4
|
+
import { AGENTS_BLOCK_KEY, DEFAULT_ADAPTERS, STANDARDS_PREFIX, agentsBlockBody, managedFiles, renderManaged, } from "../layout.js";
|
|
5
|
+
import { bold, cyan, dim, green, info, red, UserError, warn, yellow } from "../log.js";
|
|
6
|
+
import { readManifest, writeManifest } from "../manifest.js";
|
|
7
|
+
import { exists, hash, packageVersion } from "../paths.js";
|
|
8
|
+
const LABEL = {
|
|
9
|
+
replace: green('update '),
|
|
10
|
+
restore: green('restore'),
|
|
11
|
+
add: green('add '),
|
|
12
|
+
unchanged: dim('same '),
|
|
13
|
+
conflict: red('CONFLICT'),
|
|
14
|
+
remove: yellow('remove '),
|
|
15
|
+
adopt: yellow('adopt '),
|
|
16
|
+
};
|
|
17
|
+
function readIfExists(file) {
|
|
18
|
+
return exists(file) ? readFileSync(file, 'utf8') : null;
|
|
19
|
+
}
|
|
20
|
+
function sourceRef(text) {
|
|
21
|
+
return text ? (/^ref=(.+)$/m.exec(text)?.[1]?.trim() ?? null) : null;
|
|
22
|
+
}
|
|
23
|
+
export function update(root, options) {
|
|
24
|
+
const manifest = readManifest(root);
|
|
25
|
+
// Adapters reconcile to what this version ships rather than to what the install recorded. An install
|
|
26
|
+
// made before a tree existed is the only way that tree ever arrives, and a tree this version has
|
|
27
|
+
// dropped falls through to the no-longer-shipped branch below and is removed. Both are reported.
|
|
28
|
+
const adapters = DEFAULT_ADAPTERS;
|
|
29
|
+
const gained = adapters.filter((a) => !manifest.adapters.includes(a));
|
|
30
|
+
const dropped = manifest.adapters.filter((a) => !adapters.includes(a));
|
|
31
|
+
const files = managedFiles(adapters);
|
|
32
|
+
const byDest = new Map(files.map((f) => [f.dest, f]));
|
|
33
|
+
// The standards tree is tool-owned only while it is ours and unmodified. One edited file makes the whole
|
|
34
|
+
// tree the project's — half-managed is worse than either, because the README's conditional table and the
|
|
35
|
+
// files it names have to agree.
|
|
36
|
+
const standardsInManifest = Object.keys(manifest.managedFiles).filter((p) => p.startsWith(STANDARDS_PREFIX));
|
|
37
|
+
const standardsEdited = standardsInManifest.some((dest) => {
|
|
38
|
+
const onDisk = readIfExists(path.join(root, dest));
|
|
39
|
+
return onDisk === null ? false : hash(onDisk) !== manifest.managedFiles[dest];
|
|
40
|
+
});
|
|
41
|
+
const standardsAdopted = standardsInManifest.length === 0 || standardsEdited;
|
|
42
|
+
const steps = [];
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
if (file.dest.startsWith(STANDARDS_PREFIX) && standardsAdopted)
|
|
45
|
+
continue;
|
|
46
|
+
const recorded = manifest.managedFiles[file.dest];
|
|
47
|
+
const target = renderManaged(file);
|
|
48
|
+
const onDisk = readIfExists(path.join(root, file.dest));
|
|
49
|
+
if (onDisk === null) {
|
|
50
|
+
steps.push(recorded === undefined
|
|
51
|
+
? { dest: file.dest, action: 'add', note: 'new in this version' }
|
|
52
|
+
: { dest: file.dest, action: 'restore' });
|
|
53
|
+
}
|
|
54
|
+
else if (recorded === undefined) {
|
|
55
|
+
steps.push({
|
|
56
|
+
dest: file.dest,
|
|
57
|
+
action: 'conflict',
|
|
58
|
+
note: 'exists but is not in the manifest — this tool did not write it',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else if (hash(onDisk) === recorded) {
|
|
62
|
+
steps.push({ dest: file.dest, action: hash(target) === recorded ? 'unchanged' : 'replace' });
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
steps.push({ dest: file.dest, action: 'conflict', note: 'edited since it was installed' });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (standardsAdopted && standardsInManifest.length > 0) {
|
|
69
|
+
steps.push({
|
|
70
|
+
dest: `${STANDARDS_PREFIX}*`,
|
|
71
|
+
action: 'adopt',
|
|
72
|
+
note: 'edited — the whole standards tree becomes project-owned and drops out of the manifest',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// Anything the manifest still lists that this version no longer ships.
|
|
76
|
+
for (const dest of Object.keys(manifest.managedFiles)) {
|
|
77
|
+
if (dest === AGENTS_BLOCK_KEY || byDest.has(dest))
|
|
78
|
+
continue;
|
|
79
|
+
if (dest.startsWith(STANDARDS_PREFIX) && standardsAdopted)
|
|
80
|
+
continue;
|
|
81
|
+
const onDisk = readIfExists(path.join(root, dest));
|
|
82
|
+
if (onDisk === null)
|
|
83
|
+
continue;
|
|
84
|
+
steps.push(hash(onDisk) === manifest.managedFiles[dest]
|
|
85
|
+
? { dest, action: 'remove', note: 'no longer shipped' }
|
|
86
|
+
: { dest, action: 'conflict', note: 'no longer shipped, and edited — left in place' });
|
|
87
|
+
}
|
|
88
|
+
// The AGENTS.md block.
|
|
89
|
+
const agentsPath = path.join(root, 'AGENTS.md');
|
|
90
|
+
const agentsText = readIfExists(agentsPath);
|
|
91
|
+
const body = agentsBlockBody();
|
|
92
|
+
const state = inspect(agentsText);
|
|
93
|
+
const recordedBlock = manifest.managedFiles[AGENTS_BLOCK_KEY];
|
|
94
|
+
let blockStep;
|
|
95
|
+
if (state.kind === 'duplicate' || state.kind === 'malformed') {
|
|
96
|
+
blockStep = { dest: AGENTS_BLOCK_KEY, action: 'conflict', note: describeBad(state) };
|
|
97
|
+
}
|
|
98
|
+
else if (state.kind === 'no-file' || state.kind === 'no-markers') {
|
|
99
|
+
blockStep = { dest: AGENTS_BLOCK_KEY, action: 'restore' };
|
|
100
|
+
}
|
|
101
|
+
else if (hash(state.body) === recordedBlock) {
|
|
102
|
+
blockStep = {
|
|
103
|
+
dest: AGENTS_BLOCK_KEY,
|
|
104
|
+
action: hash(body) === recordedBlock ? 'unchanged' : 'replace',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
blockStep = { dest: AGENTS_BLOCK_KEY, action: 'conflict', note: 'edited since it was installed' };
|
|
109
|
+
}
|
|
110
|
+
steps.push(blockStep);
|
|
111
|
+
// Report.
|
|
112
|
+
info(bold(`ai-workflow ${manifest.version} → ${packageVersion()}`));
|
|
113
|
+
info(dim(gained.length || dropped.length
|
|
114
|
+
? `adapters: ${manifest.adapters.join(', ')} → ${adapters.join(', ')}`
|
|
115
|
+
: `adapters: ${adapters.join(', ')}`));
|
|
116
|
+
info();
|
|
117
|
+
const conflicts = steps.filter((s) => s.action === 'conflict');
|
|
118
|
+
const changing = steps.filter((s) => s.action !== 'unchanged');
|
|
119
|
+
if (changing.length === 0) {
|
|
120
|
+
info(green('Everything tool-owned is already up to date.'));
|
|
121
|
+
}
|
|
122
|
+
for (const step of changing) {
|
|
123
|
+
info(` ${LABEL[step.action]} ${step.dest}${step.note ? dim(` — ${step.note}`) : ''}`);
|
|
124
|
+
}
|
|
125
|
+
const same = steps.length - changing.length;
|
|
126
|
+
if (same > 0)
|
|
127
|
+
info(dim(` ${same} file${same === 1 ? '' : 's'} already current`));
|
|
128
|
+
info();
|
|
129
|
+
info(bold('Ownership'));
|
|
130
|
+
info(` ${green('replaced by update')} ${dim('the tool-owned files above — every one is in the manifest')}`);
|
|
131
|
+
info(` ${cyan('yours, untouched')} ${dim('context/stack.md, verify.md, executors.md, roadmap.md,')}`);
|
|
132
|
+
info(` ${dim('history.md, findings.md, drafts/, plans/, archive/, CLAUDE.md,')}`);
|
|
133
|
+
info(` ${dim('and anything else you have added under context/')}`);
|
|
134
|
+
info(dim(' A project-owned file is not in the manifest, so no code path here reaches it.'));
|
|
135
|
+
const installedRef = sourceRef(readIfExists(path.join(root, `${STANDARDS_PREFIX}.source`)));
|
|
136
|
+
const bundledRef = sourceRef(renderManagedSourceMarker(files));
|
|
137
|
+
if (installedRef && bundledRef && installedRef !== bundledRef && !standardsAdopted) {
|
|
138
|
+
info();
|
|
139
|
+
info(`${yellow('!')} the bundled standards moved: ${dim(installedRef.slice(0, 8))} → ${dim(bundledRef.slice(0, 8))}`);
|
|
140
|
+
}
|
|
141
|
+
if (options.dryRun) {
|
|
142
|
+
info();
|
|
143
|
+
info(dim('--dry-run: nothing was written.'));
|
|
144
|
+
return conflicts.length > 0 && !options.force ? 1 : 0;
|
|
145
|
+
}
|
|
146
|
+
if (conflicts.length > 0 && !options.force) {
|
|
147
|
+
info();
|
|
148
|
+
throw new UserError(`${conflicts.length} conflict${conflicts.length === 1 ? '' : 's'} — nothing was written.\n` +
|
|
149
|
+
' Each file above was edited after this tool wrote it. Re-run with --force to back up the\n' +
|
|
150
|
+
' edited copy alongside it (.bak) and take ours, or move your version aside first.');
|
|
151
|
+
}
|
|
152
|
+
// Apply.
|
|
153
|
+
let written = 0;
|
|
154
|
+
for (const step of steps) {
|
|
155
|
+
if (step.dest === AGENTS_BLOCK_KEY)
|
|
156
|
+
continue;
|
|
157
|
+
if (step.action === 'unchanged' || step.action === 'adopt')
|
|
158
|
+
continue;
|
|
159
|
+
const full = path.join(root, step.dest);
|
|
160
|
+
if (step.action === 'remove') {
|
|
161
|
+
rmSync(full, { force: true });
|
|
162
|
+
delete manifest.managedFiles[step.dest];
|
|
163
|
+
written += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (step.action === 'conflict') {
|
|
167
|
+
if (!byDest.has(step.dest))
|
|
168
|
+
continue; // no longer shipped and edited — leave it alone
|
|
169
|
+
copyFileSync(full, `${full}.bak`);
|
|
170
|
+
}
|
|
171
|
+
const file = byDest.get(step.dest);
|
|
172
|
+
if (!file)
|
|
173
|
+
continue;
|
|
174
|
+
const content = renderManaged(file);
|
|
175
|
+
mkdirSync(path.dirname(full), { recursive: true });
|
|
176
|
+
writeFileSync(full, content, 'utf8');
|
|
177
|
+
manifest.managedFiles[step.dest] = hash(content);
|
|
178
|
+
written += 1;
|
|
179
|
+
}
|
|
180
|
+
if (standardsAdopted) {
|
|
181
|
+
for (const dest of standardsInManifest)
|
|
182
|
+
delete manifest.managedFiles[dest];
|
|
183
|
+
}
|
|
184
|
+
if (blockStep.action !== 'unchanged') {
|
|
185
|
+
if (blockStep.action === 'conflict' && agentsText !== null) {
|
|
186
|
+
copyFileSync(agentsPath, `${agentsPath}.bak`);
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
writeFileSync(agentsPath, applyBlock(agentsText, body), 'utf8');
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error instanceof BlockConflictError)
|
|
193
|
+
throw new UserError(error.message);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
manifest.managedFiles[AGENTS_BLOCK_KEY] = hash(body);
|
|
197
|
+
written += 1;
|
|
198
|
+
}
|
|
199
|
+
manifest.version = packageVersion();
|
|
200
|
+
manifest.adapters = [...adapters];
|
|
201
|
+
writeManifest(root, manifest);
|
|
202
|
+
info();
|
|
203
|
+
info(`${green('done')} ${written} file${written === 1 ? '' : 's'} written. Review the diff — nothing was committed.`);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
function describeBad(state) {
|
|
207
|
+
return state.kind === 'duplicate'
|
|
208
|
+
? `${state.count} ai-workflow blocks in AGENTS.md — delete the extra one`
|
|
209
|
+
: `${state.reason} — fix the markers by hand`;
|
|
210
|
+
}
|
|
211
|
+
function renderManagedSourceMarker(files) {
|
|
212
|
+
const marker = files.find((f) => f.dest === `${STANDARDS_PREFIX}.source`);
|
|
213
|
+
return marker ? renderManaged(marker) : null;
|
|
214
|
+
}
|