@jaimevalasek/aioson 1.21.7 → 1.22.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/CHANGELOG.md +39 -2
- package/docs/en/1-understand/ecosystem-map.md +1 -1
- package/docs/en/2-start/initial-decisions.md +1 -1
- package/docs/en/4-agents/README.md +8 -7
- package/docs/en/4-agents/discovery-design-doc.md +150 -0
- package/docs/en/5-reference/cli-reference.md +42 -16
- package/docs/en/README.md +2 -2
- package/docs/pt/4-agentes/README.md +8 -6
- package/docs/pt/4-agentes/briefing-refiner.md +122 -0
- package/docs/pt/4-agentes/discovery-design-doc.md +133 -74
- package/docs/pt/4-agentes/scope-check.md +65 -0
- package/docs/pt/5-referencia/README.md +1 -0
- package/docs/pt/5-referencia/comandos-cli.md +5 -4
- package/docs/pt/5-referencia/feature-archive.md +1 -0
- package/docs/pt/5-referencia/feature-export.md +155 -0
- package/docs/pt/README.md +2 -2
- package/docs/pt/agentes.md +3 -1
- package/package.json +1 -1
- package/src/agent-manifests.js +14 -3
- package/src/agents.js +21 -20
- package/src/cli.js +72 -52
- package/src/commands/briefing.js +28 -150
- package/src/commands/commit-prepare.js +5 -2
- package/src/commands/feature-archive.js +48 -12
- package/src/commands/feature-close.js +40 -0
- package/src/commands/feature-export.js +242 -0
- package/src/commands/gate-check.js +8 -3
- package/src/commands/git-guard.js +58 -0
- package/src/commands/harness-gate.js +120 -0
- package/src/commands/harness-status.js +157 -0
- package/src/commands/harness.js +18 -1
- package/src/commands/live.js +120 -115
- package/src/commands/parallel-doctor.js +2 -1
- package/src/commands/pulse-update.js +2 -2
- package/src/commands/scan-project.js +12 -2
- package/src/commands/self-implement-loop.js +305 -5
- package/src/commands/workflow-next.js +477 -425
- package/src/constants.js +21 -11
- package/src/context-search.js +3 -0
- package/src/doctor.js +24 -8
- package/src/dossier/schema.js +4 -3
- package/src/harness/active-contract.js +41 -0
- package/src/harness/attempt-artifacts.js +95 -0
- package/src/harness/budget-guard.js +127 -0
- package/src/harness/circuit-breaker.js +7 -0
- package/src/harness/contract-schema.js +324 -0
- package/src/harness/criteria-runner.js +136 -0
- package/src/harness/git-baseline.js +204 -0
- package/src/harness/glob-match.js +126 -0
- package/src/harness/guard-events.js +71 -0
- package/src/harness/human-gate.js +182 -0
- package/src/harness/scope-guard.js +115 -0
- package/src/i18n/messages/en.js +24 -21
- package/src/i18n/messages/es.js +11 -9
- package/src/i18n/messages/fr.js +11 -9
- package/src/i18n/messages/pt-BR.js +24 -21
- package/src/lib/briefing-refiner/apply-feedback.js +134 -0
- package/src/lib/briefing-refiner/briefing-paths.js +41 -0
- package/src/lib/briefing-refiner/briefing-registry.js +204 -0
- package/src/lib/briefing-refiner/briefing-sections.js +110 -0
- package/src/lib/briefing-refiner/feedback-schema.js +122 -0
- package/src/lib/briefing-refiner/refinement-report.js +39 -0
- package/src/lib/briefing-refiner/review-html.js +230 -0
- package/src/lib/dev-resume.js +94 -45
- package/src/parser.js +8 -5
- package/src/preflight-engine.js +88 -84
- package/src/runtime-store.js +2 -0
- package/src/sandbox.js +17 -3
- package/template/.aioson/agents/analyst.md +27 -23
- package/template/.aioson/agents/architect.md +7 -3
- package/template/.aioson/agents/briefing-refiner.md +121 -0
- package/template/.aioson/agents/briefing.md +83 -74
- package/template/.aioson/agents/committer.md +8 -0
- package/template/.aioson/agents/copywriter.md +19 -7
- package/template/.aioson/agents/design-hybrid-forge.md +16 -5
- package/template/.aioson/agents/dev.md +68 -66
- package/template/.aioson/agents/deyvin.md +97 -90
- package/template/.aioson/agents/discover.md +2 -2
- package/template/.aioson/agents/discovery-design-doc.md +34 -30
- package/template/.aioson/agents/genome.md +82 -71
- package/template/.aioson/agents/neo.md +11 -3
- package/template/.aioson/agents/orache.md +10 -0
- package/template/.aioson/agents/orchestrator.md +68 -68
- package/template/.aioson/agents/pentester.md +15 -6
- package/template/.aioson/agents/pm.md +30 -25
- package/template/.aioson/agents/product.md +108 -108
- package/template/.aioson/agents/profiler-enricher.md +10 -0
- package/template/.aioson/agents/profiler-forge.md +10 -0
- package/template/.aioson/agents/profiler-researcher.md +11 -0
- package/template/.aioson/agents/qa.md +28 -20
- package/template/.aioson/agents/scope-check.md +176 -164
- package/template/.aioson/agents/setup.md +11 -1
- package/template/.aioson/agents/sheldon.md +38 -38
- package/template/.aioson/agents/site-forge.md +15 -6
- package/template/.aioson/agents/squad.md +12 -0
- package/template/.aioson/agents/tester.md +209 -209
- package/template/.aioson/agents/ux-ui.md +2 -2
- package/template/.aioson/agents/validator.md +10 -2
- package/template/.aioson/config.md +31 -28
- package/template/.aioson/docs/autopilot-handoff.md +46 -0
- package/template/.aioson/docs/dossier/agent-templates.md +191 -0
- package/template/.aioson/docs/dossier/schema.md +218 -0
- package/template/.claude/commands/aioson/agent/briefing-refiner.md +17 -0
- package/template/AGENTS.md +50 -47
- package/template/CLAUDE.md +29 -27
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* aioson feature:export — copy every artefact of a feature into a clean output
|
|
5
|
+
* directory, leaving the source tree untouched.
|
|
6
|
+
*
|
|
7
|
+
* Sibling of feature:archive, but COPY (not move) to an arbitrary --out. Turns
|
|
8
|
+
* AIOSON's markdown output into a portable deliverable: read/analyse the specs
|
|
9
|
+
* outside the project, hand them to a client, or use AIOSON purely as a spec
|
|
10
|
+
* generator. Works for both active features (artefacts in context/ root + slug
|
|
11
|
+
* dirs) and already-archived ones (context/done/{slug}).
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* aioson feature:export . --feature=checkout
|
|
15
|
+
* aioson feature:export . --feature=checkout --out=../checkout-specs
|
|
16
|
+
* aioson feature:export . --feature=checkout --flatten
|
|
17
|
+
* aioson feature:export . --feature=checkout --no-index
|
|
18
|
+
* aioson feature:export . --feature=checkout --dry-run --json
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs/promises');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
const { contextDir } = require('../preflight-engine');
|
|
24
|
+
const { collectFeatureArtifacts } = require('./feature-archive');
|
|
25
|
+
|
|
26
|
+
async function dirExists(dirPath) {
|
|
27
|
+
try {
|
|
28
|
+
return (await fs.stat(dirPath)).isDirectory();
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Recursively list every file under `dir`, returning paths relative to `base`.
|
|
36
|
+
*/
|
|
37
|
+
async function walkFiles(dir, base = dir) {
|
|
38
|
+
const out = [];
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
42
|
+
} catch {
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const abs = path.join(dir, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
// eslint-disable-next-line no-await-in-loop
|
|
49
|
+
out.push(...await walkFiles(abs, base));
|
|
50
|
+
} else if (entry.isFile()) {
|
|
51
|
+
out.push(path.relative(base, abs));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toPosix(p) {
|
|
58
|
+
return p.split(path.sep).join('/');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the flat list of {srcAbs, relDest, group} to copy. relDest is the path
|
|
63
|
+
* relative to the output dir (mirrored structure). Caller may flatten later.
|
|
64
|
+
*/
|
|
65
|
+
async function buildEntries({ ctxDir, rootFiles, dirs, doneDir }) {
|
|
66
|
+
const entries = [];
|
|
67
|
+
|
|
68
|
+
for (const name of rootFiles) {
|
|
69
|
+
entries.push({ srcAbs: path.join(ctxDir, name), relDest: name, group: 'context' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const d of dirs) {
|
|
73
|
+
// eslint-disable-next-line no-await-in-loop
|
|
74
|
+
const files = await walkFiles(d.sourceDir);
|
|
75
|
+
for (const rel of files) {
|
|
76
|
+
entries.push({
|
|
77
|
+
srcAbs: path.join(d.sourceDir, rel),
|
|
78
|
+
relDest: path.join(d.label, rel),
|
|
79
|
+
group: d.label
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (doneDir) {
|
|
85
|
+
const files = await walkFiles(doneDir);
|
|
86
|
+
for (const rel of files) {
|
|
87
|
+
entries.push({
|
|
88
|
+
srcAbs: path.join(doneDir, rel),
|
|
89
|
+
relDest: path.join('done', rel),
|
|
90
|
+
group: 'done'
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return entries;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function applyFlatten(entries) {
|
|
99
|
+
// Collapse subdir structure into a single level. Root files (no separator)
|
|
100
|
+
// keep their name; nested files become `label-...-file.ext`, which is
|
|
101
|
+
// collision-free by construction since it encodes the full source path.
|
|
102
|
+
for (const e of entries) {
|
|
103
|
+
e.relDest = e.relDest.split(/[/\\]/).join('-');
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderIndex({ slug, entries, targetDir, exportedAt }) {
|
|
109
|
+
const lines = [
|
|
110
|
+
`# Feature Export — ${slug}`,
|
|
111
|
+
'',
|
|
112
|
+
`> ${entries.length} file(s) copied from AIOSON on ${exportedAt}.`,
|
|
113
|
+
'> Non-destructive snapshot — the original artefacts were left untouched.',
|
|
114
|
+
'',
|
|
115
|
+
'| group | file | source |',
|
|
116
|
+
'|-------|------|--------|'
|
|
117
|
+
];
|
|
118
|
+
const sorted = [...entries].sort((a, b) => {
|
|
119
|
+
if (a.group !== b.group) return a.group.localeCompare(b.group);
|
|
120
|
+
return a.relDest.localeCompare(b.relDest);
|
|
121
|
+
});
|
|
122
|
+
for (const e of sorted) {
|
|
123
|
+
const source = toPosix(path.relative(targetDir, e.srcAbs));
|
|
124
|
+
lines.push(`| ${e.group} | ${toPosix(e.relDest)} | ${source} |`);
|
|
125
|
+
}
|
|
126
|
+
lines.push('');
|
|
127
|
+
return lines.join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function runFeatureExport({ args = [], options = {}, logger }) {
|
|
131
|
+
const targetDir = path.resolve(process.cwd(), args[0] || '.');
|
|
132
|
+
const slug = options.feature ? String(options.feature) : null;
|
|
133
|
+
const flatten = Boolean(options.flatten);
|
|
134
|
+
const noIndex = Boolean(options['no-index'] || options.noIndex);
|
|
135
|
+
const dryRun = Boolean(options['dry-run'] || options.dryRun);
|
|
136
|
+
const jsonOut = Boolean(options.json);
|
|
137
|
+
|
|
138
|
+
const log = (msg) => { if (logger && !jsonOut) logger.log(msg); };
|
|
139
|
+
|
|
140
|
+
if (!slug) {
|
|
141
|
+
if (jsonOut) return { ok: false, reason: 'missing_feature' };
|
|
142
|
+
log('--feature=<slug> is required.');
|
|
143
|
+
return { ok: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!/^[a-z][a-z0-9-]*$/i.test(slug)) {
|
|
147
|
+
if (jsonOut) return { ok: false, reason: 'invalid_slug' };
|
|
148
|
+
log(`Invalid slug "${slug}" — use lowercase letters, digits and hyphens only.`);
|
|
149
|
+
return { ok: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const ctxDir = contextDir(targetDir);
|
|
153
|
+
if (!(await dirExists(ctxDir))) {
|
|
154
|
+
if (jsonOut) return { ok: false, reason: 'no_context_dir' };
|
|
155
|
+
log(`.aioson/context/ not found at ${targetDir}. Run aioson setup first.`);
|
|
156
|
+
return { ok: false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const outDir = options.out
|
|
160
|
+
? path.resolve(process.cwd(), String(options.out))
|
|
161
|
+
: path.join(targetDir, `${slug}-export`);
|
|
162
|
+
|
|
163
|
+
const { rootFiles, dirs, doneDir } = await collectFeatureArtifacts({
|
|
164
|
+
ctxDir, targetDir, slug, includeDone: true
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
let entries = await buildEntries({ ctxDir, rootFiles, dirs, doneDir });
|
|
168
|
+
if (flatten) entries = applyFlatten(entries);
|
|
169
|
+
|
|
170
|
+
if (entries.length === 0) {
|
|
171
|
+
if (jsonOut) return { ok: true, slug, exported: [], noop: true };
|
|
172
|
+
log(`No artefacts matched "*-${slug}.{md,yaml,yml,json}" in .aioson/context/, no slug directories (features/plans/briefings), and nothing under context/done/${slug}/ — nothing to export.`);
|
|
173
|
+
return { ok: true, slug, noop: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const relOut = toPosix(path.relative(targetDir, outDir)) || outDir;
|
|
177
|
+
|
|
178
|
+
if (dryRun) {
|
|
179
|
+
const result = {
|
|
180
|
+
ok: true,
|
|
181
|
+
dryRun: true,
|
|
182
|
+
slug,
|
|
183
|
+
outDir: relOut,
|
|
184
|
+
flatten,
|
|
185
|
+
index: !noIndex,
|
|
186
|
+
count: entries.length,
|
|
187
|
+
files: entries.map((e) => toPosix(e.relDest))
|
|
188
|
+
};
|
|
189
|
+
if (jsonOut) return result;
|
|
190
|
+
log(`[dry-run] feature:export — ${slug}:`);
|
|
191
|
+
log(` out: ${relOut}/ (${flatten ? 'flattened' : 'mirrored'})`);
|
|
192
|
+
log(` would copy: ${entries.length} file(s)`);
|
|
193
|
+
for (const e of [...entries].sort((a, b) => a.relDest.localeCompare(b.relDest))) {
|
|
194
|
+
log(` • ${toPosix(e.relDest)}`);
|
|
195
|
+
}
|
|
196
|
+
if (!noIndex) log(' would write: INDEX.md');
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
201
|
+
const copied = [];
|
|
202
|
+
for (const e of entries) {
|
|
203
|
+
const dest = path.join(outDir, e.relDest);
|
|
204
|
+
// eslint-disable-next-line no-await-in-loop
|
|
205
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
206
|
+
// eslint-disable-next-line no-await-in-loop
|
|
207
|
+
await fs.copyFile(e.srcAbs, dest);
|
|
208
|
+
copied.push(toPosix(e.relDest));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let indexWritten = false;
|
|
212
|
+
if (!noIndex) {
|
|
213
|
+
const exportedAt = new Date().toISOString().slice(0, 10);
|
|
214
|
+
await fs.writeFile(
|
|
215
|
+
path.join(outDir, 'INDEX.md'),
|
|
216
|
+
renderIndex({ slug, entries, targetDir, exportedAt }),
|
|
217
|
+
'utf8'
|
|
218
|
+
);
|
|
219
|
+
indexWritten = true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const result = {
|
|
223
|
+
ok: true,
|
|
224
|
+
slug,
|
|
225
|
+
outDir: relOut,
|
|
226
|
+
flatten,
|
|
227
|
+
count: copied.length,
|
|
228
|
+
copied,
|
|
229
|
+
index: indexWritten
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (jsonOut) return result;
|
|
233
|
+
log(`feature:export — ${slug}:`);
|
|
234
|
+
log(` out: ${relOut}/ (${flatten ? 'flattened' : 'mirrored'})`);
|
|
235
|
+
log(` copied: ${copied.length} file(s)`);
|
|
236
|
+
for (const f of [...copied].sort()) log(` • ${f}`);
|
|
237
|
+
if (indexWritten) log(` index: ${relOut}/INDEX.md`);
|
|
238
|
+
log(' source tree untouched (non-destructive copy).');
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { runFeatureExport };
|
|
@@ -36,7 +36,10 @@ const GATE_PREREQUISITES = {
|
|
|
36
36
|
const GATE_REQUIRED_ARTIFACTS = {
|
|
37
37
|
A: (slug) => [`requirements-${slug}.md`],
|
|
38
38
|
B: (slug) => ['architecture.md'],
|
|
39
|
-
|
|
39
|
+
// implementation-plan-{slug}.md is a MEDIUM-only artifact (@pm owns it,
|
|
40
|
+
// AC-SDLC-15/16). SMALL/MICRO sequences never route through @pm, so
|
|
41
|
+
// requiring it unconditionally dead-ends Gate C for those features.
|
|
42
|
+
C: (slug, classification) => (classification === 'MEDIUM' ? [`implementation-plan-${slug}.md`] : []),
|
|
40
43
|
D: (slug) => [] // Gate D validated by QA sign-off in spec
|
|
41
44
|
};
|
|
42
45
|
|
|
@@ -75,7 +78,7 @@ async function checkGate(targetDir, slug, gateLetter) {
|
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
// Check required artifacts
|
|
78
|
-
const requiredFiles = GATE_REQUIRED_ARTIFACTS[gateLetter](slug);
|
|
81
|
+
const requiredFiles = GATE_REQUIRED_ARTIFACTS[gateLetter](slug, classification);
|
|
79
82
|
for (const fileName of requiredFiles) {
|
|
80
83
|
const filePath = path.join(dir, fileName);
|
|
81
84
|
const exists = await fileExists(filePath);
|
|
@@ -150,7 +153,9 @@ async function checkGate(targetDir, slug, gateLetter) {
|
|
|
150
153
|
const fixAgents = {
|
|
151
154
|
A: `activate @analyst to produce requirements-${slug}.md, then run: aioson gate:approve . --feature=${slug} --gate=A`,
|
|
152
155
|
B: `activate @architect to produce architecture.md, then run: aioson gate:approve . --feature=${slug} --gate=B`,
|
|
153
|
-
C:
|
|
156
|
+
C: classification === 'MEDIUM'
|
|
157
|
+
? `activate @pm to produce and approve implementation-plan-${slug}.md, then run: aioson gate:approve . --feature=${slug} --gate=C`
|
|
158
|
+
: `approve Gates A and B first, then run: aioson gate:approve . --feature=${slug} --gate=C`,
|
|
154
159
|
D: `activate @qa for final verification; if QA passes, run: aioson gate:approve . --feature=${slug} --gate=D`
|
|
155
160
|
};
|
|
156
161
|
recommendation = `BLOCKED — ${fixAgents[gateLetter] || 'resolve missing items'}`;
|
|
@@ -12,12 +12,64 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const path = require('node:path');
|
|
15
|
+
const fs = require('node:fs');
|
|
15
16
|
const {
|
|
16
17
|
inspectStagedChanges,
|
|
17
18
|
installPreCommitHook,
|
|
18
19
|
uninstallPreCommitHook
|
|
19
20
|
} = require('../lib/git-commit-guard');
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* REQ-20 (loop-guardrails, should-have): mescla `forbidden_files` do contrato
|
|
24
|
+
* ATIVO (progress `in_progress`/`human_gate` mais recente) na verificação do
|
|
25
|
+
* guard em tempo de execução — camada 2 do scope guard no pre-commit.
|
|
26
|
+
* Best-effort: nunca quebra o guard; contrato inválido é ignorado (o preflight
|
|
27
|
+
* do self:loop já bloqueia o loop nesse caso). Paths `.aioson/**` ficam fora
|
|
28
|
+
* (estado do framework precisa ser commitável).
|
|
29
|
+
*
|
|
30
|
+
* C-03 (QA 2026-06-09): nesta camada aplicam-se apenas os globs DECLARADOS no
|
|
31
|
+
* contrato. Os defaults não-removíveis (lockfiles, node_modules, .env*, ...)
|
|
32
|
+
* existem para conter o LOOP do agente; no pre-commit pegariam mudanças
|
|
33
|
+
* humanas legítimas (ex.: package-lock.json após `npm install`). Segredos
|
|
34
|
+
* (.env*, *.pem, *.key, secrets/**) continuam bloqueados pela policy baseline
|
|
35
|
+
* do próprio git-guard.
|
|
36
|
+
*/
|
|
37
|
+
const { findActiveContract } = require('../harness/active-contract');
|
|
38
|
+
|
|
39
|
+
function applyActiveContractPolicy(targetDir, result) {
|
|
40
|
+
const active = findActiveContract(targetDir);
|
|
41
|
+
if (!active) return null;
|
|
42
|
+
const { validateContract } = require('../harness/contract-schema');
|
|
43
|
+
const { matchGlob, matchAny } = require('../harness/glob-match');
|
|
44
|
+
const contract = JSON.parse(fs.readFileSync(active.contractPath, 'utf8'));
|
|
45
|
+
if (!validateContract(contract).ok) return null;
|
|
46
|
+
const declaredForbidden = Array.isArray(contract.forbidden_files) ? contract.forbidden_files : [];
|
|
47
|
+
if (!declaredForbidden.length) return { slug: active.slug, findings: 0 };
|
|
48
|
+
|
|
49
|
+
let added = 0;
|
|
50
|
+
for (const file of result.files) {
|
|
51
|
+
if (matchGlob('.aioson/**', file.path)) continue;
|
|
52
|
+
const matched = matchAny(declaredForbidden, file.path);
|
|
53
|
+
if (!matched) continue;
|
|
54
|
+
const finding = {
|
|
55
|
+
type: 'path',
|
|
56
|
+
severity: 'error',
|
|
57
|
+
id: 'contract_forbidden_file',
|
|
58
|
+
path: file.path,
|
|
59
|
+
reason: `matches forbidden glob "${matched}" from active harness contract "${active.slug}"`,
|
|
60
|
+
line: null
|
|
61
|
+
};
|
|
62
|
+
file.findings.push(finding);
|
|
63
|
+
result.errors.push(finding);
|
|
64
|
+
added += 1;
|
|
65
|
+
}
|
|
66
|
+
if (added > 0) {
|
|
67
|
+
result.ok = false;
|
|
68
|
+
result.summary.errorCount = result.errors.length;
|
|
69
|
+
}
|
|
70
|
+
return { slug: active.slug, findings: added };
|
|
71
|
+
}
|
|
72
|
+
|
|
21
73
|
function formatFinding(prefix, finding) {
|
|
22
74
|
const line = finding.line ? `:${finding.line}` : '';
|
|
23
75
|
return `${prefix} ${finding.path}${line} — ${finding.reason} [${finding.id}]`;
|
|
@@ -111,9 +163,15 @@ async function runGitGuard({ args, options = {}, logger }) {
|
|
|
111
163
|
return failure;
|
|
112
164
|
}
|
|
113
165
|
|
|
166
|
+
let contractPolicy = null;
|
|
167
|
+
try {
|
|
168
|
+
contractPolicy = applyActiveContractPolicy(targetDir, result);
|
|
169
|
+
} catch { /* best-effort: contrato ilegível nunca quebra o guard */ }
|
|
170
|
+
|
|
114
171
|
const output = {
|
|
115
172
|
ok: result.ok,
|
|
116
173
|
projectDir: targetDir,
|
|
174
|
+
contractPolicy,
|
|
117
175
|
gitRoot: result.gitRoot,
|
|
118
176
|
strict: result.strict,
|
|
119
177
|
policy: result.policy,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* aioson harness:approve / harness:reject — decisão humana de gates do
|
|
5
|
+
* self:loop (loop-guardrails REQ-14/15).
|
|
6
|
+
*
|
|
7
|
+
* - Exigem --slug e --gate; reject exige --reason.
|
|
8
|
+
* - Decisão persiste em `.aioson/plans/{slug}/gates/{id}.json`
|
|
9
|
+
* (decided_at/decided_by/reason) e emite `human_gate_decision`.
|
|
10
|
+
* - Gate já decidido = no-op idempotente com aviso (REQ-14).
|
|
11
|
+
* - Gate inexistente = erro explícito sem efeito colateral (EC-8).
|
|
12
|
+
* - Sem pendências restantes → progress.status volta a `in_progress`;
|
|
13
|
+
* re-executar `self:loop` retoma do ponto persistido (REQ-15).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const { execFileSync } = require('node:child_process');
|
|
19
|
+
|
|
20
|
+
const { decideGate, resolveGateState, pendingGates } = require('../harness/human-gate');
|
|
21
|
+
const { emitGuardEvent } = require('../harness/guard-events');
|
|
22
|
+
|
|
23
|
+
function gitUserName(targetDir) {
|
|
24
|
+
try {
|
|
25
|
+
return execFileSync('git', ['config', 'user.name'], {
|
|
26
|
+
cwd: targetDir,
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
29
|
+
}).trim() || null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function runGateDecision(decision, { args, options = {}, logger }) {
|
|
36
|
+
const targetDir = path.resolve(process.cwd(), args?.[0] || '.');
|
|
37
|
+
const slug = String(options.slug || '').trim();
|
|
38
|
+
const gateId = String(options.gate || '').trim();
|
|
39
|
+
const reason = options.reason ? String(options.reason).trim() : null;
|
|
40
|
+
const decidedBy = options.by ? String(options.by).trim() : gitUserName(targetDir);
|
|
41
|
+
|
|
42
|
+
if (!slug) {
|
|
43
|
+
logger.error('Error: --slug is required');
|
|
44
|
+
return { ok: false, error: 'missing_slug' };
|
|
45
|
+
}
|
|
46
|
+
if (!gateId) {
|
|
47
|
+
logger.error('Error: --gate is required (gate id, e.g. payment_logic_change-1)');
|
|
48
|
+
return { ok: false, error: 'missing_gate' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const planDir = path.join(targetDir, '.aioson', 'plans', slug);
|
|
52
|
+
if (!fs.existsSync(path.join(planDir, 'harness-contract.json'))) {
|
|
53
|
+
logger.error(`Contract not found for slug: ${slug}`);
|
|
54
|
+
return { ok: false, error: 'contract_not_found' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = decideGate(planDir, gateId, { decision, by: decidedBy, reason });
|
|
58
|
+
|
|
59
|
+
if (!result.ok) {
|
|
60
|
+
const messages = {
|
|
61
|
+
gate_not_found: `Gate not found: ${gateId} — nothing to ${decision === 'approved' ? 'approve' : 'reject'} (no side effects)`,
|
|
62
|
+
reason_required_on_reject: 'Error: --reason is required when rejecting a gate',
|
|
63
|
+
gate_corrupted: `Gate file is corrupted: ${gateId}`,
|
|
64
|
+
invalid_decision: `Invalid decision: ${decision}`
|
|
65
|
+
};
|
|
66
|
+
logger.error(messages[result.error] || `Error: ${result.error}`);
|
|
67
|
+
return { ok: false, error: result.error, gateId };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (result.idempotent) {
|
|
71
|
+
logger.log(`• Gate ${gateId} already decided (${result.gate.status} at ${result.gate.decided_at}) — no-op`);
|
|
72
|
+
return { ok: true, idempotent: true, gate: result.gate };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// reconcilia progress.json: remove decidido de pending_gates; sem pendências
|
|
76
|
+
// restantes → status volta a in_progress (retomada idempotente / auditoria)
|
|
77
|
+
const progressPath = path.join(planDir, 'progress.json');
|
|
78
|
+
let remaining = [];
|
|
79
|
+
try {
|
|
80
|
+
if (fs.existsSync(progressPath)) {
|
|
81
|
+
const progress = JSON.parse(fs.readFileSync(progressPath, 'utf8'));
|
|
82
|
+
resolveGateState(progress, planDir);
|
|
83
|
+
fs.writeFileSync(progressPath, JSON.stringify(progress, null, 2), 'utf8');
|
|
84
|
+
remaining = progress.pending_gates || [];
|
|
85
|
+
} else {
|
|
86
|
+
remaining = pendingGates(planDir).map((g) => g.id);
|
|
87
|
+
}
|
|
88
|
+
} catch { /* progress corrompido — decisão do gate já persistiu */ }
|
|
89
|
+
|
|
90
|
+
await emitGuardEvent(targetDir, {
|
|
91
|
+
eventType: 'human_gate_decision',
|
|
92
|
+
message: `gate ${gateId} ${decision} by ${decidedBy || 'unknown'}`,
|
|
93
|
+
payload: { slug, gate_id: gateId, theme: result.gate.theme, decision, decided_by: decidedBy, reason }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const mark = decision === 'approved' ? '✓' : '✗';
|
|
97
|
+
logger.log(`${mark} Gate ${gateId} ${decision}${decidedBy ? ` by ${decidedBy}` : ''}${reason ? ` — ${reason}` : ''}`);
|
|
98
|
+
if (remaining.length > 0) {
|
|
99
|
+
logger.log(` Pending gates remaining: ${remaining.join(', ')}`);
|
|
100
|
+
} else if (decision === 'approved') {
|
|
101
|
+
logger.log(` No pending gates — re-run self:loop to resume from the persisted state.`);
|
|
102
|
+
} else {
|
|
103
|
+
logger.log(` Run ended. A new self:loop starts a fresh run (rejected gate is kept as audit).`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { ok: true, gate: result.gate, pending_gates: remaining };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function runHarnessApprove(ctx) {
|
|
110
|
+
return runGateDecision('approved', ctx);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function runHarnessReject(ctx) {
|
|
114
|
+
return runGateDecision('rejected', ctx);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
runHarnessApprove,
|
|
119
|
+
runHarnessReject
|
|
120
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* aioson harness:status . --slug=X [--json] — visibilidade do estado do loop
|
|
5
|
+
* (loop-guardrails REQ-18).
|
|
6
|
+
*
|
|
7
|
+
* Agrega: circuito, iteração N/M, budget, checks da última tentativa,
|
|
8
|
+
* última falha, gates pendentes e a próxima ação. Escopo distinto de
|
|
9
|
+
* `spec:status` (planos+learnings) — referenciado no rodapé.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
|
|
15
|
+
const { resolveContract, validateContract } = require('../harness/contract-schema');
|
|
16
|
+
const { pendingGates } = require('../harness/human-gate');
|
|
17
|
+
|
|
18
|
+
function readJson(file) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Última tentativa = maior diretório numérico em attempts/. */
|
|
27
|
+
function latestAttempt(planDir) {
|
|
28
|
+
const dir = path.join(planDir, 'attempts');
|
|
29
|
+
if (!fs.existsSync(dir)) return null;
|
|
30
|
+
const numbers = fs.readdirSync(dir)
|
|
31
|
+
.map((name) => Number(name))
|
|
32
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
33
|
+
return numbers.length ? Math.max(...numbers) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Conta checks pass/fail da tentativa pelos logs `# exit_code:`. */
|
|
37
|
+
function readChecks(planDir, attempt) {
|
|
38
|
+
const checksDir = path.join(planDir, 'attempts', String(attempt), 'checks');
|
|
39
|
+
if (!fs.existsSync(checksDir)) return { total: 0, passed: 0, failed: 0, failed_ids: [] };
|
|
40
|
+
const summary = { total: 0, passed: 0, failed: 0, failed_ids: [] };
|
|
41
|
+
for (const file of fs.readdirSync(checksDir)) {
|
|
42
|
+
if (!file.endsWith('.log')) continue;
|
|
43
|
+
summary.total += 1;
|
|
44
|
+
try {
|
|
45
|
+
const content = fs.readFileSync(path.join(checksDir, file), 'utf8');
|
|
46
|
+
const match = content.match(/^# exit_code: (.+)$/m);
|
|
47
|
+
if (match && match[1].trim() === '0') {
|
|
48
|
+
summary.passed += 1;
|
|
49
|
+
} else {
|
|
50
|
+
summary.failed += 1;
|
|
51
|
+
summary.failed_ids.push(file.replace(/\.log$/, ''));
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
summary.failed += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return summary;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nextAction(progress, pending, slug) {
|
|
61
|
+
if (pending.length > 0) {
|
|
62
|
+
return `aioson harness:approve . --slug=${slug} --gate=${pending[0].id} (ou harness:reject --reason="...")`;
|
|
63
|
+
}
|
|
64
|
+
const status = progress?.status || 'unknown';
|
|
65
|
+
if (status === 'circuit_open' || progress?.circuit_state === 'OPEN') {
|
|
66
|
+
return 'circuito aberto — revisar last_error e corrigir antes de novo run';
|
|
67
|
+
}
|
|
68
|
+
if (status === 'waiting_validation') {
|
|
69
|
+
return `aioson harness:validate . --slug=${slug}`;
|
|
70
|
+
}
|
|
71
|
+
if (progress?.ready_for_done_gate) {
|
|
72
|
+
return `pronto para o done gate — aioson feature:close . --feature=${slug}`;
|
|
73
|
+
}
|
|
74
|
+
return `re-executar self:loop para continuar (iteração persiste em progress.json)`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function runHarnessStatus({ args, options = {}, logger }) {
|
|
78
|
+
const targetDir = path.resolve(process.cwd(), args?.[0] || '.');
|
|
79
|
+
const slug = String(options.slug || '').trim();
|
|
80
|
+
|
|
81
|
+
if (!slug) {
|
|
82
|
+
logger.error('Error: --slug is required');
|
|
83
|
+
return { ok: false, error: 'missing_slug' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const planDir = path.join(targetDir, '.aioson', 'plans', slug);
|
|
87
|
+
const contract = readJson(path.join(planDir, 'harness-contract.json'));
|
|
88
|
+
if (!contract) {
|
|
89
|
+
logger.error(`Contract not found for slug: ${slug}`);
|
|
90
|
+
return { ok: false, error: 'contract_not_found' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const progress = readJson(path.join(planDir, 'progress.json'));
|
|
94
|
+
const schema = validateContract(contract);
|
|
95
|
+
const resolved = schema.ok ? resolveContract(contract) : null;
|
|
96
|
+
const pending = pendingGates(planDir);
|
|
97
|
+
const attempt = latestAttempt(planDir);
|
|
98
|
+
const checks = attempt ? readChecks(planDir, attempt) : { total: 0, passed: 0, failed: 0, failed_ids: [] };
|
|
99
|
+
|
|
100
|
+
const maxSteps = resolved ? resolved.governor.max_steps : (contract.governor && contract.governor.max_steps) || null;
|
|
101
|
+
const ceiling = resolved ? (resolved.governor.cost_ceiling_tokens ?? null) : null;
|
|
102
|
+
const budget = progress?.budget || null;
|
|
103
|
+
|
|
104
|
+
const report = {
|
|
105
|
+
ok: true,
|
|
106
|
+
slug,
|
|
107
|
+
contract_mode: contract.contract_mode || 'BALANCED',
|
|
108
|
+
contract_schema_ok: schema.ok,
|
|
109
|
+
circuit_state: progress?.circuit_state || 'CLOSED',
|
|
110
|
+
status: progress?.status || 'unknown',
|
|
111
|
+
iterations: progress?.iterations ?? 0,
|
|
112
|
+
max_steps: maxSteps,
|
|
113
|
+
budget: budget ? {
|
|
114
|
+
tokens_estimated: budget.tokens_estimated || 0,
|
|
115
|
+
cost_ceiling_tokens: ceiling,
|
|
116
|
+
run_id: budget.run_id || null,
|
|
117
|
+
run_started_at: budget.run_started_at || null
|
|
118
|
+
} : null,
|
|
119
|
+
last_attempt: attempt,
|
|
120
|
+
checks,
|
|
121
|
+
last_error: progress?.last_error || null,
|
|
122
|
+
pending_gates: pending.map((g) => ({ id: g.id, theme: g.theme, attempt: g.attempt, requested_at: g.requested_at })),
|
|
123
|
+
next_action: nextAction(progress, pending, slug)
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (options.json) {
|
|
127
|
+
logger.log(JSON.stringify(report, null, 2));
|
|
128
|
+
return report;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
logger.log(`Harness status — ${slug}`);
|
|
132
|
+
logger.log(` Mode: ${report.contract_mode}${schema.ok ? '' : ' (⚠ contract schema invalid)'}`);
|
|
133
|
+
logger.log(` Circuit: ${report.circuit_state} | status: ${report.status}`);
|
|
134
|
+
logger.log(` Iteration: ${report.iterations}${maxSteps ? `/${maxSteps}` : ''}`);
|
|
135
|
+
if (budget) {
|
|
136
|
+
logger.log(` Budget: ${report.budget.tokens_estimated} tokens estimados${ceiling ? ` / ${ceiling} (${Math.round((report.budget.tokens_estimated / ceiling) * 100)}%)` : ' (sem teto)'}`);
|
|
137
|
+
}
|
|
138
|
+
if (attempt) {
|
|
139
|
+
logger.log(` Last attempt: ${attempt} — checks ${checks.passed}/${checks.total} pass${checks.failed ? ` (failed: ${checks.failed_ids.join(', ')})` : ''}`);
|
|
140
|
+
}
|
|
141
|
+
if (report.last_error) logger.log(` Last error: ${report.last_error}`);
|
|
142
|
+
if (pending.length > 0) {
|
|
143
|
+
logger.log(` ⛔ Pending gates (${pending.length}):`);
|
|
144
|
+
for (const gate of pending) {
|
|
145
|
+
logger.log(` - ${gate.id} [${gate.theme}] attempt ${gate.attempt}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
logger.log(` Next: ${report.next_action}`);
|
|
149
|
+
logger.log('');
|
|
150
|
+
logger.log(' Planos e learnings: aioson spec:status');
|
|
151
|
+
|
|
152
|
+
return report;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
runHarnessStatus
|
|
157
|
+
};
|
package/src/commands/harness.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { createCircuitBreaker } = require('../harness/circuit-breaker');
|
|
6
|
+
const { validateContract } = require('../harness/contract-schema');
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* aioson harness:init — Inicializa o contrato e progresso da feature.
|
|
@@ -36,8 +37,17 @@ async function runHarnessInit({ args, options = {}, logger, t }) {
|
|
|
36
37
|
governor: {
|
|
37
38
|
max_steps: 50,
|
|
38
39
|
error_streak_limit: 5,
|
|
39
|
-
cost_ceiling_tokens: null
|
|
40
|
+
cost_ceiling_tokens: null,
|
|
41
|
+
max_runtime_minutes: null,
|
|
42
|
+
max_changed_files: null,
|
|
43
|
+
max_diff_lines: null
|
|
40
44
|
},
|
|
45
|
+
// Scope guard (loop-guardrails): allowed_files ausente = sem allowlist;
|
|
46
|
+
// forbidden_files é SEMPRE mesclado com os defaults embutidos (.env*, *.pem,
|
|
47
|
+
// *.key, secrets/**, .git/**, node_modules/**, lockfiles) — não-removíveis.
|
|
48
|
+
forbidden_files: [],
|
|
49
|
+
// human_gate ausente = nenhum gate (retrocompat). Exemplo:
|
|
50
|
+
// "human_gate": { "required_for": ["payment_logic_change", "publish"] }
|
|
41
51
|
criteria: [
|
|
42
52
|
{
|
|
43
53
|
id: "C1",
|
|
@@ -48,6 +58,13 @@ async function runHarnessInit({ args, options = {}, logger, t }) {
|
|
|
48
58
|
]
|
|
49
59
|
};
|
|
50
60
|
|
|
61
|
+
const schemaResult = validateContract(contract);
|
|
62
|
+
if (!schemaResult.ok) {
|
|
63
|
+
const first = schemaResult.errors[0];
|
|
64
|
+
logger.error(`Contract schema invalid: ${first.field} — ${first.reason}`);
|
|
65
|
+
return { ok: false, error: 'contract_schema_invalid', errors: schemaResult.errors };
|
|
66
|
+
}
|
|
67
|
+
|
|
51
68
|
const cb = createCircuitBreaker(contractPath, progressPath);
|
|
52
69
|
fs.writeFileSync(contractPath, JSON.stringify(contract, null, 2), 'utf8');
|
|
53
70
|
await cb.load();
|