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,329 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
/**
|
|
4
|
+
* Rule T1 (canon/typed-data.md): no untyped array, map, `mixed`, or `any` in a
|
|
5
|
+
* signature that crosses a boundary. Diff-based: only added lines are judged,
|
|
6
|
+
* so enabling the rule never fails untouched history.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node .ai-agents/scripts/check-typed-boundaries.mjs --base origin/main # CI
|
|
10
|
+
* node .ai-agents/scripts/check-typed-boundaries.mjs --staged # pre-commit
|
|
11
|
+
*
|
|
12
|
+
* Paths come from .ai-agents/rules.config.json → typedBoundaries.
|
|
13
|
+
* Suppress one line with a trailing comment: typed-boundary-ok: <reason>
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
19
|
+
|
|
20
|
+
import { matchesAny } from './lib/glob.mjs';
|
|
21
|
+
import { loadRulesConfig } from './lib/config.mjs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} Violation
|
|
25
|
+
* @property {string} file
|
|
26
|
+
* @property {number} line
|
|
27
|
+
* @property {'T1'} rule
|
|
28
|
+
* @property {string} found
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** @typedef {'php' | 'ts' | 'py'} Language */
|
|
32
|
+
|
|
33
|
+
const SUPPRESSION = /typed-boundary-ok:\s*\S/;
|
|
34
|
+
const HINT = 'use a named Data class or typed collection (see .ai-agents/canon/typed-data.md)';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} path
|
|
38
|
+
* @returns {Language | null}
|
|
39
|
+
*/
|
|
40
|
+
export function languageOf(path) {
|
|
41
|
+
if (path.endsWith('.php')) return 'php';
|
|
42
|
+
if (/\.(ts|tsx|mts|cts)$/.test(path) && !path.endsWith('.d.ts')) return 'ts';
|
|
43
|
+
if (path.endsWith('.py')) return 'py';
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} line
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
function isCommentLine(line) {
|
|
52
|
+
return /^\s*(\/\/|\*|\/\*|#)/.test(line);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------- PHP
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Line ranges (1-based, inclusive) of named function/method signatures.
|
|
59
|
+
* @param {ReadonlyArray<string>} lines
|
|
60
|
+
* @returns {ReadonlyArray<{ start: number, end: number }>}
|
|
61
|
+
*/
|
|
62
|
+
function phpSignatures(lines) {
|
|
63
|
+
/** @type {Array<{ start: number, end: number }>} */
|
|
64
|
+
const ranges = [];
|
|
65
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
66
|
+
const text = lines[i] ?? '';
|
|
67
|
+
const head = /\bfunction\s+&?[A-Za-z_]\w*\s*\(/.exec(text);
|
|
68
|
+
if (!head || isCommentLine(text)) continue;
|
|
69
|
+
let depth = 0;
|
|
70
|
+
let closed = false;
|
|
71
|
+
let end = i;
|
|
72
|
+
scan: for (let j = i; j < lines.length; j += 1) {
|
|
73
|
+
const segment = j === i ? text.slice(head.index) : (lines[j] ?? '');
|
|
74
|
+
for (const char of segment) {
|
|
75
|
+
if (char === '(') depth += 1;
|
|
76
|
+
else if (char === ')') {
|
|
77
|
+
depth -= 1;
|
|
78
|
+
if (depth === 0) closed = true;
|
|
79
|
+
} else if (closed && (char === '{' || char === ';')) {
|
|
80
|
+
end = j;
|
|
81
|
+
break scan;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
end = j;
|
|
85
|
+
}
|
|
86
|
+
ranges.push({ start: i + 1, end: end + 1 });
|
|
87
|
+
i = end;
|
|
88
|
+
}
|
|
89
|
+
return ranges;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Nearest docblock text above a 1-based line, skipping blank and attribute lines.
|
|
94
|
+
* @param {ReadonlyArray<string>} lines
|
|
95
|
+
* @param {number} lineNumber
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
function docblockAbove(lines, lineNumber) {
|
|
99
|
+
let index = lineNumber - 2;
|
|
100
|
+
while (index >= 0 && /^\s*(#\[.*\])?\s*$/.test(lines[index] ?? '')) index -= 1;
|
|
101
|
+
if (index < 0 || !/\*\/\s*$/.test(lines[index] ?? '')) return '';
|
|
102
|
+
/** @type {Array<string>} */
|
|
103
|
+
const block = [];
|
|
104
|
+
while (index >= 0) {
|
|
105
|
+
const current = lines[index] ?? '';
|
|
106
|
+
block.unshift(current);
|
|
107
|
+
if (current.includes('/**')) break;
|
|
108
|
+
index -= 1;
|
|
109
|
+
}
|
|
110
|
+
return block.join('\n');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {string} typeText
|
|
115
|
+
* @returns {string | null}
|
|
116
|
+
*/
|
|
117
|
+
function untypedPhpType(typeText) {
|
|
118
|
+
const parts = typeText.replace(/^\?/, '').split(/[|&]/).map((part) => part.replace(/^\?/, '').toLowerCase());
|
|
119
|
+
return parts.find((part) => part === 'array' || part === 'mixed' || part === 'iterable') ?? null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} file
|
|
124
|
+
* @param {ReadonlyArray<string>} lines
|
|
125
|
+
* @param {ReadonlySet<number>} added
|
|
126
|
+
* @returns {Array<Violation>}
|
|
127
|
+
*/
|
|
128
|
+
function phpViolations(file, lines, added) {
|
|
129
|
+
/** @type {Array<Violation>} */
|
|
130
|
+
const found = [];
|
|
131
|
+
for (const signature of phpSignatures(lines)) {
|
|
132
|
+
const docblock = docblockAbove(lines, signature.start);
|
|
133
|
+
for (let n = signature.start; n <= signature.end; n += 1) {
|
|
134
|
+
const text = lines[n - 1] ?? '';
|
|
135
|
+
if (!added.has(n) || SUPPRESSION.test(text) || isCommentLine(text)) continue;
|
|
136
|
+
const promoted = /\b(public|protected|private)\b/.test(text) && !/\bfunction\b/.test(text);
|
|
137
|
+
|
|
138
|
+
for (const match of text.matchAll(/([\w\\|?&]+)\s+(?:&\s*)?(?:\.\.\.)?\$(\w+)/g)) {
|
|
139
|
+
const typeText = match[1] ?? '';
|
|
140
|
+
const name = match[2] ?? '';
|
|
141
|
+
const bad = untypedPhpType(typeText);
|
|
142
|
+
if (!bad) continue;
|
|
143
|
+
const genericParam = new RegExp(`@param\\s+\\S*<[^\\n]*\\$${name}\\b`).test(docblock);
|
|
144
|
+
if (promoted && bad === 'array' && genericParam) continue;
|
|
145
|
+
found.push({ file, line: n, rule: 'T1', found: `${typeText} $${name}` });
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
if (found.at(-1)?.line === n) continue;
|
|
149
|
+
|
|
150
|
+
const returns = /\)\s*:\s*([\w\\|?]+)/.exec(text);
|
|
151
|
+
const badReturn = returns ? untypedPhpType(returns[1] ?? '') : null;
|
|
152
|
+
if (returns && badReturn) {
|
|
153
|
+
found.push({ file, line: n, rule: 'T1', found: `returns ${returns[1]}` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return found;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------- TypeScript
|
|
161
|
+
|
|
162
|
+
/** @type {ReadonlyArray<RegExp>} */
|
|
163
|
+
const TS_PATTERNS = [
|
|
164
|
+
/Record<\s*string\s*,\s*(unknown|any)\s*>/,
|
|
165
|
+
/Map<\s*string\s*,\s*(unknown|any)\s*>/,
|
|
166
|
+
/:\s*\{\s*\[\s*\w+\s*:\s*string\s*\]\s*:\s*(unknown|any)\b/,
|
|
167
|
+
/:\s*any\b/,
|
|
168
|
+
/\bany\[\]/,
|
|
169
|
+
/<\s*any\s*>/,
|
|
170
|
+
/\bas\s+any\b/,
|
|
171
|
+
/:\s*object\b/,
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {string} file
|
|
176
|
+
* @param {ReadonlyArray<string>} lines
|
|
177
|
+
* @param {ReadonlySet<number>} added
|
|
178
|
+
* @returns {Array<Violation>}
|
|
179
|
+
*/
|
|
180
|
+
function tsViolations(file, lines, added) {
|
|
181
|
+
/** @type {Array<Violation>} */
|
|
182
|
+
const found = [];
|
|
183
|
+
lines.forEach((raw, index) => {
|
|
184
|
+
const n = index + 1;
|
|
185
|
+
if (!added.has(n) || isCommentLine(raw) || SUPPRESSION.test(raw)) return;
|
|
186
|
+
const text = raw.replace(/\s\/\/.*$/, '');
|
|
187
|
+
for (const pattern of TS_PATTERNS) {
|
|
188
|
+
const match = pattern.exec(text);
|
|
189
|
+
if (match) {
|
|
190
|
+
found.push({ file, line: n, rule: 'T1', found: match[0].trim() });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
return found;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------- Python
|
|
199
|
+
|
|
200
|
+
/** @type {ReadonlyArray<RegExp>} */
|
|
201
|
+
const PY_PATTERNS = [
|
|
202
|
+
/\b(dict|Dict|Mapping|MutableMapping)\[\s*str\s*,\s*(Any|object)\s*\]/,
|
|
203
|
+
/->\s*(dict|list|Any|object)\s*:/,
|
|
204
|
+
/:\s*(dict|list)\s*[,)=]/,
|
|
205
|
+
/:\s*Any\b/,
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {string} file
|
|
210
|
+
* @param {ReadonlyArray<string>} lines
|
|
211
|
+
* @param {ReadonlySet<number>} added
|
|
212
|
+
* @returns {Array<Violation>}
|
|
213
|
+
*/
|
|
214
|
+
function pyViolations(file, lines, added) {
|
|
215
|
+
/** @type {Array<Violation>} */
|
|
216
|
+
const found = [];
|
|
217
|
+
lines.forEach((raw, index) => {
|
|
218
|
+
const n = index + 1;
|
|
219
|
+
if (!added.has(n) || /^\s*#/.test(raw) || SUPPRESSION.test(raw)) return;
|
|
220
|
+
const text = raw.replace(/\s#.*$/, '');
|
|
221
|
+
for (const pattern of PY_PATTERNS) {
|
|
222
|
+
const match = pattern.exec(text);
|
|
223
|
+
if (match) {
|
|
224
|
+
found.push({ file, line: n, rule: 'T1', found: match[0].trim() });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
return found;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------- API
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {string} file repository-relative path
|
|
236
|
+
* @param {string} content full file content
|
|
237
|
+
* @param {ReadonlySet<number>} added 1-based line numbers added in the diff
|
|
238
|
+
* @returns {Array<Violation>}
|
|
239
|
+
*/
|
|
240
|
+
export function findViolations(file, content, added) {
|
|
241
|
+
const language = languageOf(file);
|
|
242
|
+
if (language === null) return [];
|
|
243
|
+
const lines = content.split('\n');
|
|
244
|
+
switch (language) {
|
|
245
|
+
case 'php':
|
|
246
|
+
return phpViolations(file, lines, added);
|
|
247
|
+
case 'ts':
|
|
248
|
+
return tsViolations(file, lines, added);
|
|
249
|
+
case 'py':
|
|
250
|
+
return pyViolations(file, lines, added);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Added line numbers per file, from `git diff -U0` output.
|
|
256
|
+
* @param {string} diff
|
|
257
|
+
* @returns {Map<string, Set<number>>}
|
|
258
|
+
*/
|
|
259
|
+
export function parseAddedLines(diff) {
|
|
260
|
+
/** @type {Map<string, Set<number>>} */
|
|
261
|
+
const result = new Map();
|
|
262
|
+
/** @type {string | null} */
|
|
263
|
+
let file = null;
|
|
264
|
+
let next = 0;
|
|
265
|
+
for (const line of diff.split('\n')) {
|
|
266
|
+
if (line.startsWith('+++ ')) {
|
|
267
|
+
const target = line.slice(4).trim();
|
|
268
|
+
file = target === '/dev/null' ? null : target.replace(/^b\//, '');
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (line.startsWith('--- ') || line.startsWith('diff --git')) continue;
|
|
272
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
273
|
+
if (hunk) {
|
|
274
|
+
next = Number(hunk[1]);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (file === null) continue;
|
|
278
|
+
if (line.startsWith('+')) {
|
|
279
|
+
const lines = result.get(file) ?? new Set();
|
|
280
|
+
lines.add(next);
|
|
281
|
+
result.set(file, lines);
|
|
282
|
+
next += 1;
|
|
283
|
+
} else if (line.startsWith(' ')) {
|
|
284
|
+
next += 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* @param {ReadonlyArray<string>} argv
|
|
292
|
+
* @returns {number} exit code
|
|
293
|
+
*/
|
|
294
|
+
export function main(argv) {
|
|
295
|
+
const root = process.cwd();
|
|
296
|
+
const config = loadRulesConfig(root).typedBoundaries;
|
|
297
|
+
if (config.paths.length === 0) {
|
|
298
|
+
console.log('T1 typed-boundaries: not configured (typedBoundaries.paths is empty) — skipping.');
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
301
|
+
const baseIndex = argv.indexOf('--base');
|
|
302
|
+
const range = argv.includes('--staged')
|
|
303
|
+
? ['--cached']
|
|
304
|
+
: [`${baseIndex >= 0 ? argv[baseIndex + 1] ?? 'origin/main' : 'origin/main'}...HEAD`];
|
|
305
|
+
const diff = execFileSync('git', ['diff', '-U0', '--no-color', '--diff-filter=AMR', ...range], {
|
|
306
|
+
encoding: 'utf8',
|
|
307
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
/** @type {Array<Violation>} */
|
|
311
|
+
const violations = [];
|
|
312
|
+
for (const [file, added] of parseAddedLines(diff)) {
|
|
313
|
+
if (!matchesAny(file, config.paths) || matchesAny(file, config.allow)) continue;
|
|
314
|
+
const full = join(root, file);
|
|
315
|
+
if (!existsSync(full)) continue;
|
|
316
|
+
violations.push(...findViolations(file, readFileSync(full, 'utf8'), added));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for (const v of violations) {
|
|
320
|
+
console.log(`${v.rule} ${v.file}:${v.line} — ${v.found} — ${HINT}`);
|
|
321
|
+
}
|
|
322
|
+
console.log(`T1 typed-boundaries: ${violations.length} violation(s).`);
|
|
323
|
+
return violations.length === 0 ? 0 : 1;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const invoked = process.argv[1];
|
|
327
|
+
if (invoked !== undefined && import.meta.url === pathToFileURL(invoked).href) {
|
|
328
|
+
process.exit(main(process.argv.slice(2)));
|
|
329
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} TypedBoundariesConfig
|
|
7
|
+
* @property {ReadonlyArray<string>} paths Globs the T1 check applies to
|
|
8
|
+
* @property {ReadonlyArray<string>} allow Globs exempt from the check
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} TestWithChangeConfig
|
|
13
|
+
* @property {ReadonlyArray<string>} sourcePaths
|
|
14
|
+
* @property {ReadonlyArray<string>} testPaths
|
|
15
|
+
* @property {string} exemptionTag
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} RulesConfig
|
|
20
|
+
* @property {TypedBoundariesConfig} typedBoundaries
|
|
21
|
+
* @property {TestWithChangeConfig} testWithChange
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {unknown} value
|
|
26
|
+
* @param {string} field
|
|
27
|
+
* @returns {ReadonlyArray<string>}
|
|
28
|
+
*/
|
|
29
|
+
function stringList(value, field) {
|
|
30
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
31
|
+
throw new Error(`rules.config.json: ${field} must be a list of strings`);
|
|
32
|
+
}
|
|
33
|
+
return /** @type {ReadonlyArray<string>} */ (value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse edge for rules.config.json (typed-data T5): read, validate, return a named shape.
|
|
38
|
+
* @param {string} repoRoot
|
|
39
|
+
* @returns {RulesConfig}
|
|
40
|
+
*/
|
|
41
|
+
export function loadRulesConfig(repoRoot) {
|
|
42
|
+
const path = join(repoRoot, '.ai-agents', 'rules.config.json');
|
|
43
|
+
if (!existsSync(path)) {
|
|
44
|
+
throw new Error(`Missing ${path}. Run the installer, then /bootstrap to configure it.`);
|
|
45
|
+
}
|
|
46
|
+
/** @type {{ typedBoundaries?: { paths?: unknown, allow?: unknown }, testWithChange?: { sourcePaths?: unknown, testPaths?: unknown, exemptionTag?: unknown } }} */
|
|
47
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
48
|
+
const tag = raw.testWithChange?.exemptionTag ?? 'no-test';
|
|
49
|
+
if (typeof tag !== 'string') {
|
|
50
|
+
throw new Error('rules.config.json: testWithChange.exemptionTag must be a string');
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
typedBoundaries: {
|
|
54
|
+
paths: stringList(raw.typedBoundaries?.paths ?? [], 'typedBoundaries.paths'),
|
|
55
|
+
allow: stringList(raw.typedBoundaries?.allow ?? [], 'typedBoundaries.allow'),
|
|
56
|
+
},
|
|
57
|
+
testWithChange: {
|
|
58
|
+
sourcePaths: stringList(raw.testWithChange?.sourcePaths ?? [], 'testWithChange.sourcePaths'),
|
|
59
|
+
testPaths: stringList(raw.testWithChange?.testPaths ?? [], 'testWithChange.testPaths'),
|
|
60
|
+
exemptionTag: tag,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Minimal glob matching for rule configuration. Supports `**` (any depth,
|
|
4
|
+
* including zero directories), `*` (within one path segment), and `?`.
|
|
5
|
+
* Paths are always forward-slash, repository-relative.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} glob
|
|
10
|
+
* @returns {RegExp}
|
|
11
|
+
*/
|
|
12
|
+
export function globToRegExp(glob) {
|
|
13
|
+
let pattern = '';
|
|
14
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
15
|
+
const char = glob.charAt(i);
|
|
16
|
+
if (char === '*') {
|
|
17
|
+
if (glob.charAt(i + 1) === '*') {
|
|
18
|
+
const followedBySlash = glob.charAt(i + 2) === '/';
|
|
19
|
+
pattern += followedBySlash ? '(?:.*/)?' : '.*';
|
|
20
|
+
i += followedBySlash ? 2 : 1;
|
|
21
|
+
} else {
|
|
22
|
+
pattern += '[^/]*';
|
|
23
|
+
}
|
|
24
|
+
} else if (char === '?') {
|
|
25
|
+
pattern += '[^/]';
|
|
26
|
+
} else {
|
|
27
|
+
pattern += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return new RegExp(`^${pattern}$`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} path
|
|
35
|
+
* @param {ReadonlyArray<string>} globs
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function matchesAny(path, globs) {
|
|
39
|
+
return globs.some((glob) => globToRegExp(glob).test(path));
|
|
40
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# State
|
|
2
|
+
|
|
3
|
+
Files here are written by agents and read by other agents. They hold the
|
|
4
|
+
**current** feature only. Before the branch merges, agent 09a archives them to
|
|
5
|
+
`.ai-agents/archive/{slug}/` and resets this directory, so the reset travels
|
|
6
|
+
with the feature instead of waiting for someone to do it afterwards.
|
|
7
|
+
|
|
8
|
+
This directory is why the pipeline survives losing its conversation. The state is
|
|
9
|
+
on disk, not in anyone's context window — you can close the session mid-feature,
|
|
10
|
+
come back next week, run `/resume`, and pick up exactly where you stopped.
|
|
11
|
+
|
|
12
|
+
## Files
|
|
13
|
+
|
|
14
|
+
| File | Written by | Read by |
|
|
15
|
+
|------|-----------|---------|
|
|
16
|
+
| `current-feature.md` | 01 intake | 01b, 02a, 02b, 02c, 02d, 03a, 03b, 06, 07, 08, 09a |
|
|
17
|
+
| `impact.md` | 01b impact (predict, then verify) | 02a, 02b, 02c, 03a, 04a, 04b, 06, 07, 08, 09a |
|
|
18
|
+
| `tdd-log.md` | 00a, 00b, 04a, 04b | Gate 2 reviewer, 06, 09a |
|
|
19
|
+
| `current-plan.md` | 02a (deps), 03a (backend tasks), 03b (UI tasks) | 04a, 04b |
|
|
20
|
+
| `contract-backend.md` | 02b | 02d, 03a, 04a, 06, 07, 08, 09a |
|
|
21
|
+
| `contract-ui.md` | 02c | 02d, 03b, 04b, 06, 08, 09a |
|
|
22
|
+
| `contract-seam.md` | 02d | 03a, 03b, **04a, 04b**, 06, 07 |
|
|
23
|
+
| `current-stage.md` | every agent | every agent |
|
|
24
|
+
| `decisions-log.md` | 04a, 04b | 09a |
|
|
25
|
+
| `scaffold-decisions.md` | 00a scaffold | 00 bootstrap |
|
|
26
|
+
|
|
27
|
+
Outside `state/`:
|
|
28
|
+
|
|
29
|
+
| Path | Written by | Read by |
|
|
30
|
+
|------|-----------|---------|
|
|
31
|
+
| `contracts/{slug}/` | 02d (`export` mode), `/seam import` | 02d, 03b, 04b, CI job 10 — survives release |
|
|
32
|
+
| `.ai-agents/changes/{slug}.md` | 09a `/finalize` | 09b `/release` — the feature's own release note |
|
|
33
|
+
| `.ai-agents/observations/` | task-observer | 09 changelog (retrospective) |
|
|
34
|
+
| `.ai-agents/pipeline.config.json` | installer | every agent (topology, runtimes, tools) |
|
|
35
|
+
|
|
36
|
+
`scaffold-decisions.md` only exists on repositories that started empty. It
|
|
37
|
+
records which stack decisions were actively chosen and which were merely
|
|
38
|
+
defaulted, so bootstrap can carry the defaulted ones into the context marked as
|
|
39
|
+
weakly held rather than enshrining an unexamined default as convention.
|
|
40
|
+
|
|
41
|
+
`contract-seam.md` is the frozen seam. Both implementers build against it, and it
|
|
42
|
+
outranks either contract where they differ. When it needs to change, it changes
|
|
43
|
+
here and both tracks are told — never on one side only.
|
|
44
|
+
|
|
45
|
+
When idle, only `current-stage.md` exists, reading `Stage: idle`.
|
|
46
|
+
|
|
47
|
+
## Stages
|
|
48
|
+
|
|
49
|
+
| Stage | Meaning | Next |
|
|
50
|
+
|-------|---------|------|
|
|
51
|
+
| *(empty repo)* | Nothing to document yet | `/scaffold` |
|
|
52
|
+
| `scaffolded` | Vertical slice built, Gate A open | Review the slice, then `/bootstrap` |
|
|
53
|
+
| *(no architecture context)* | Repo has code but is not set up | `/bootstrap` |
|
|
54
|
+
| `bootstrapped` | Context written, Gate B open | Review it, then `/intake` |
|
|
55
|
+
| `idle` | Nothing in progress | `/intake` |
|
|
56
|
+
| `intake-complete` | Gate 0 open | `approve` / `edit` / `split` |
|
|
57
|
+
| `gate-0-approved` | Ready to measure impact | `/impact` |
|
|
58
|
+
| `impact-assessed` | Risk class set | `/contract` |
|
|
59
|
+
| `contracts-complete` | 02a, 02b, 02c, 02d all done — Gate 1 open | Review seam first, then `approve` |
|
|
60
|
+
| `gate-1-approved` | Ready to sequence | `/sequence` |
|
|
61
|
+
| `sequence-complete` | Both tracks sequenced | `/implement` |
|
|
62
|
+
| `backend-task-{n}-committed` | Gate 2 open | Review diff, push |
|
|
63
|
+
| `ui-task-{n}-committed` | Gate 2 open | Review diff, push |
|
|
64
|
+
| `{track}-task-{n}-ci-failed` | CI failed | Fix, or `/implement --retry-task {track} {n}` |
|
|
65
|
+
| `{track}-task-{n}-merged` | Task merged | `/implement` for the next available |
|
|
66
|
+
| *(all tasks committed)* | Implementation done | `/impact --verify` |
|
|
67
|
+
| `impact-verified` | Actual impact measured | `/qa` and `/security` (and `/perf`, mandatory at L2+) |
|
|
68
|
+
| `review-complete` | Gate 3 open | `approve-merge` / `block` |
|
|
69
|
+
| `gate-3-approved` | Reviews passed | `/finalize` — note, archive, state reset |
|
|
70
|
+
| `finalized` | Branch is finished | Push and merge; then `/intake`. Nothing runs against merged code |
|
|
71
|
+
| `idle` | Reset by `/finalize` | `/intake` |
|
|
72
|
+
|
|
73
|
+
Individual agent completions are appended as lines rather than replacing the
|
|
74
|
+
stage, so parallel agents do not overwrite each other:
|
|
75
|
+
|
|
76
|
+
```markdown
|
|
77
|
+
**02b backend-contract**: complete at 2026-08-18T09:14:22Z
|
|
78
|
+
**02c ui-contract**: complete at 2026-08-18T09:19:08Z
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The overall stage advances only when every agent in that step has finished.
|
|
82
|
+
|
|
83
|
+
## Flags
|
|
84
|
+
|
|
85
|
+
Appended by agents and surfaced by `/status` and `/resume`:
|
|
86
|
+
|
|
87
|
+
```markdown
|
|
88
|
+
**Critical security findings**: 2 — MERGE BLOCKED
|
|
89
|
+
**Critical performance findings**: 1 — degrades at 500k rows
|
|
90
|
+
**Escalated decisions**: 1 — unresolved in contract-seam.md
|
|
91
|
+
**Risk class**: L2
|
|
92
|
+
**Required**: /perf mandatory; characterisation tests first
|
|
93
|
+
**Escapes**: 2 — must be covered or accepted before merge
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Editing by hand
|
|
97
|
+
|
|
98
|
+
You can edit these files between agent runs — to correct a wrong assumption in a
|
|
99
|
+
contract, or to reorder tasks. The next agent picks up your edits.
|
|
100
|
+
|
|
101
|
+
Two things to know:
|
|
102
|
+
|
|
103
|
+
- Editing `contract-backend.md` or `contract-ui.md` after the seam is frozen
|
|
104
|
+
makes them disagree with `contract-seam.md`. If the change touches the API,
|
|
105
|
+
re-run `/contract` so 02d re-freezes it.
|
|
106
|
+
- Do not edit application source to match a state file edit. Re-run the
|
|
107
|
+
appropriate agent instead — the point of the pipeline is that the code follows
|
|
108
|
+
from the documents.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Current Stage
|
|
2
|
+
|
|
3
|
+
**Stage**: idle
|
|
4
|
+
**Next action**: Depends on what is in this repository.
|
|
5
|
+
|
|
6
|
+
- **Empty, or only framework default output** → `/scaffold`. There is nothing for
|
|
7
|
+
`/bootstrap` to read yet, and a context written from intentions rather than
|
|
8
|
+
code produces exemplars nobody should imitate.
|
|
9
|
+
- **Has real code, no `architecture-context.md`** → `/bootstrap`.
|
|
10
|
+
- **Context exists and is filled in** → `/intake` to start a feature.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Templates
|
|
2
|
+
|
|
3
|
+
Starter files agent 00 (`/bootstrap`) adapts to the project, and reference
|
|
4
|
+
material the implementers read for pattern rather than copy verbatim.
|
|
5
|
+
|
|
6
|
+
Bootstrap writes the CI configuration directly to the path recorded in
|
|
7
|
+
architecture context §3, wiring up whichever of the jobs in
|
|
8
|
+
`agents/05-ci-validator.md` apply to the project's tooling.
|
|
9
|
+
|
|
10
|
+
Add templates here as the team identifies patterns worth standardising — a
|
|
11
|
+
module skeleton, a screen skeleton, a test harness. Anything here should be
|
|
12
|
+
something a human would otherwise copy-paste and then edit.
|
|
13
|
+
|
|
14
|
+
## CI templates
|
|
15
|
+
|
|
16
|
+
- `ci/gitlab-ci.rules.yml` — include from `.gitlab-ci.yml`
|
|
17
|
+
- `ci/github-actions.rules.yml` — copy into `.github/workflows/`
|
|
18
|
+
|
|
19
|
+
Both run the T1 typed-boundaries and TDD-1 test-with-change checks on merge
|
|
20
|
+
requests, plus the impeccable design detector as a non-blocking job. `/bootstrap`
|
|
21
|
+
wires the one that matches §3 and adds the repo's own lint, type-check, test,
|
|
22
|
+
and build jobs.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Pipeline rule jobs for GitHub Actions — copy to .github/workflows/pipeline-rules.yml.
|
|
2
|
+
# /bootstrap adjusts it to the repository (see agents/05-ci-validator.md).
|
|
3
|
+
name: pipeline-rules
|
|
4
|
+
on:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
rules:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
with:
|
|
13
|
+
fetch-depth: 0
|
|
14
|
+
- uses: actions/setup-node@v4
|
|
15
|
+
with:
|
|
16
|
+
node-version: 22
|
|
17
|
+
- name: T1 typed boundaries
|
|
18
|
+
run: node .ai-agents/scripts/check-typed-boundaries.mjs --base "origin/${{ github.base_ref }}"
|
|
19
|
+
- name: TDD-1 test with change
|
|
20
|
+
run: node .ai-agents/scripts/check-test-with-change.mjs --base "origin/${{ github.base_ref }}"
|
|
21
|
+
|
|
22
|
+
design:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
continue-on-error: true # non-blocking until promoted (05-ci-validator §9)
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
with:
|
|
28
|
+
fetch-depth: 0
|
|
29
|
+
- uses: actions/setup-node@v4
|
|
30
|
+
with:
|
|
31
|
+
node-version: 22
|
|
32
|
+
- name: impeccable detect on changed UI files
|
|
33
|
+
run: |
|
|
34
|
+
CHANGED=$(git diff --name-only "origin/${{ github.base_ref }}"...HEAD -- '*.vue' '*.tsx' '*.jsx' '*.svelte' '*.blade.php' '*.css' | tr '\n' ' ')
|
|
35
|
+
if [ -n "$CHANGED" ]; then npx --yes impeccable detect $CHANGED; else echo "No UI files changed."; fi
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Pipeline rule jobs for GitLab CI — include from .gitlab-ci.yml:
|
|
2
|
+
# include:
|
|
3
|
+
# - local: .ai-agents/templates/ci/gitlab-ci.rules.yml
|
|
4
|
+
# /bootstrap adjusts stages, images, and which jobs apply (see agents/05-ci-validator.md).
|
|
5
|
+
|
|
6
|
+
.pipeline-rules-base:
|
|
7
|
+
stage: test
|
|
8
|
+
image: node:22-alpine
|
|
9
|
+
before_script:
|
|
10
|
+
- apk add --no-cache git
|
|
11
|
+
- git fetch --no-tags origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
12
|
+
rules:
|
|
13
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
14
|
+
|
|
15
|
+
rules:typed-boundaries:
|
|
16
|
+
extends: .pipeline-rules-base
|
|
17
|
+
script:
|
|
18
|
+
- node .ai-agents/scripts/check-typed-boundaries.mjs --base "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
19
|
+
|
|
20
|
+
rules:test-with-change:
|
|
21
|
+
extends: .pipeline-rules-base
|
|
22
|
+
script:
|
|
23
|
+
- node .ai-agents/scripts/check-test-with-change.mjs --base "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
24
|
+
|
|
25
|
+
design:impeccable-detect:
|
|
26
|
+
extends: .pipeline-rules-base
|
|
27
|
+
allow_failure: true # non-blocking until promoted (05-ci-validator §9)
|
|
28
|
+
script:
|
|
29
|
+
- CHANGED=$(git diff --name-only "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"...HEAD -- '*.vue' '*.tsx' '*.jsx' '*.svelte' '*.blade.php' '*.css' | tr '\n' ' ')
|
|
30
|
+
- if [ -n "$CHANGED" ]; then npx --yes impeccable detect $CHANGED; else echo "No UI files changed."; fi
|