@jaimevalasek/aioson 1.21.8 → 1.23.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 +950 -923
- package/package.json +1 -1
- package/src/agents.js +21 -20
- package/src/cli.js +31 -0
- package/src/commands/feature-close.js +40 -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-preview.js +74 -0
- package/src/commands/harness-retro.js +221 -0
- package/src/commands/harness-status.js +157 -0
- package/src/commands/harness.js +18 -1
- package/src/commands/self-implement-loop.js +315 -5
- package/src/commands/workflow-next.js +45 -2
- package/src/doctor.js +24 -8
- 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/preview-artifact.js +85 -0
- package/src/harness/scope-guard.js +115 -0
- package/src/i18n/messages/en.js +23 -0
- package/src/i18n/messages/es.js +32 -9
- package/src/i18n/messages/fr.js +32 -9
- package/src/i18n/messages/pt-BR.js +23 -0
- package/src/lib/dev-resume.js +94 -45
- package/src/lib/retro/retro-aggregate.js +192 -0
- package/src/lib/retro/retro-render.js +185 -0
- package/src/lib/retro/retro-sources.js +624 -0
- package/src/preflight-engine.js +88 -84
- package/template/.aioson/agents/analyst.md +4 -0
- package/template/.aioson/agents/architect.md +4 -0
- package/template/.aioson/agents/dev.md +14 -1
- package/template/.aioson/agents/discovery-design-doc.md +4 -0
- package/template/.aioson/agents/pentester.md +8 -0
- package/template/.aioson/agents/pm.md +10 -5
- package/template/.aioson/agents/qa.md +46 -14
- package/template/.aioson/agents/scope-check.md +176 -172
- package/template/.aioson/agents/sheldon.md +13 -0
- package/template/.aioson/agents/tester.md +17 -0
- package/template/.aioson/agents/validator.md +8 -0
- package/template/.aioson/config.md +31 -28
- package/template/.aioson/docs/autopilot-handoff.md +83 -0
- package/template/.aioson/rules/aioson-context-boundary.md +10 -8
- package/template/AGENTS.md +57 -57
- package/template/CLAUDE.md +33 -33
package/package.json
CHANGED
package/src/agents.js
CHANGED
|
@@ -29,15 +29,16 @@ function buildAgentPrompt(agent, tool, options = {}) {
|
|
|
29
29
|
const safeTool = String(tool || 'codex').toLowerCase();
|
|
30
30
|
const instructionPath = options.instructionPath || agent.path;
|
|
31
31
|
const targetDir = options.targetDir ? String(options.targetDir) : '.';
|
|
32
|
-
const interactionLanguage = String(options.interactionLanguage || 'en');
|
|
33
|
-
const autonomyMode = String(options.autonomyMode || '').trim();
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
const interactionLanguage = String(options.interactionLanguage || 'en');
|
|
33
|
+
const autonomyMode = String(options.autonomyMode || '').trim();
|
|
34
|
+
const autoHandoff = options.autoHandoff === true;
|
|
35
|
+
const capabilitySummary = String(options.capabilitySummary || '').trim();
|
|
36
|
+
const activationContext = String(options.activationContext || '').trim();
|
|
37
|
+
const dependsOn = Array.isArray(options.dependsOn) ? options.dependsOn : agent.dependsOn;
|
|
38
|
+
const dependencyText =
|
|
39
|
+
dependsOn.length > 0
|
|
40
|
+
? `Check required context files first: ${dependsOn.join(', ')}.`
|
|
41
|
+
: 'No prerequisite context files are required.';
|
|
41
42
|
const activationBlock = activationContext
|
|
42
43
|
? [
|
|
43
44
|
'',
|
|
@@ -66,19 +67,19 @@ function buildAgentPrompt(agent, tool, options = {}) {
|
|
|
66
67
|
'',
|
|
67
68
|
`**Language boundary:** Agent instructions are canonical in English. All user-facing communication must be in ${interactionLanguage}.`,
|
|
68
69
|
'',
|
|
69
|
-
`**Scope boundary:** You operate exclusively as ${agent.command}. Do not perform work that belongs to another agent. When your work is complete, output only the handoff — which agent is next and why. Do not continue into that agent\'s territory
|
|
70
|
+
`**Scope boundary:** You operate exclusively as ${agent.command}. Do not perform work that belongs to another agent. When your work is complete, output only the handoff — which agent is next and why. Do not continue into that agent\'s territory.${autoHandoff ? ' Exception: autopilot handoff is active for this stage — follow `.aioson/docs/autopilot-handoff.md` and auto-invoke the next agent\'s skill when no stop condition applies. The chain stops before the first `@dev` activation (human clears context and starts implementation) and continues through the post-dev review cycle (`@dev` → `@qa` → `@tester`/`@pentester` when their triggers fire → `@validator`); it NEVER auto-runs `feature:close`/publish — those require explicit human approval.' : ''}`,
|
|
70
71
|
].join('\n');
|
|
71
72
|
|
|
72
|
-
if (safeTool === 'claude') {
|
|
73
|
-
return `Read ${instructionPath} and execute ${agent.command}. ${dependencyText}${activationBlock}\n\nWrite output to ${agent.output}.${autonomyBlock}${lifecycleBlock}`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (safeTool === 'opencode') {
|
|
77
|
-
return `Use agent "${agent.id}" from ${instructionPath}. ${dependencyText}${activationBlock}\n\nSave output to ${agent.output}.${autonomyBlock}${lifecycleBlock}`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return `Read AGENTS.md and execute ${agent.command} using ${instructionPath}. ${dependencyText}${activationBlock}\n\nSave output to ${agent.output}.${autonomyBlock}${lifecycleBlock}`;
|
|
81
|
-
}
|
|
73
|
+
if (safeTool === 'claude') {
|
|
74
|
+
return `Read ${instructionPath} and execute ${agent.command}. ${dependencyText}${activationBlock}\n\nWrite output to ${agent.output}.${autonomyBlock}${lifecycleBlock}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (safeTool === 'opencode') {
|
|
78
|
+
return `Use agent "${agent.id}" from ${instructionPath}. ${dependencyText}${activationBlock}\n\nSave output to ${agent.output}.${autonomyBlock}${lifecycleBlock}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return `Read AGENTS.md and execute ${agent.command} using ${instructionPath}. ${dependencyText}${activationBlock}\n\nSave output to ${agent.output}.${autonomyBlock}${lifecycleBlock}`;
|
|
82
|
+
}
|
|
82
83
|
|
|
83
84
|
module.exports = {
|
|
84
85
|
normalizeAgentName,
|
package/src/cli.js
CHANGED
|
@@ -268,6 +268,10 @@ const JSON_SUPPORTED_COMMANDS = new Set([
|
|
|
268
268
|
'workflow-next',
|
|
269
269
|
'workflow:status',
|
|
270
270
|
'workflow-status',
|
|
271
|
+
'harness:retro',
|
|
272
|
+
'harness-retro',
|
|
273
|
+
'harness:preview',
|
|
274
|
+
'harness-preview',
|
|
271
275
|
'agent:next',
|
|
272
276
|
'agent-next',
|
|
273
277
|
'parallel:init',
|
|
@@ -399,6 +403,16 @@ const JSON_SUPPORTED_COMMANDS = new Set([
|
|
|
399
403
|
'harness-validate',
|
|
400
404
|
'harness:apply-validation',
|
|
401
405
|
'harness-apply-validation',
|
|
406
|
+
'harness:approve',
|
|
407
|
+
'harness-approve',
|
|
408
|
+
'harness:reject',
|
|
409
|
+
'harness-reject',
|
|
410
|
+
'harness:status',
|
|
411
|
+
'harness-status',
|
|
412
|
+
'harness:retro',
|
|
413
|
+
'harness-retro',
|
|
414
|
+
'harness:preview',
|
|
415
|
+
'harness-preview',
|
|
402
416
|
'brief-gen',
|
|
403
417
|
'verify:gate',
|
|
404
418
|
'verify-gate',
|
|
@@ -811,6 +825,8 @@ function printHelp(t, logger) {
|
|
|
811
825
|
logHelpLine(t, logger, 'cli.help_qa_run');
|
|
812
826
|
logHelpLine(t, logger, 'cli.help_qa_scan');
|
|
813
827
|
logHelpLine(t, logger, 'cli.help_qa_report');
|
|
828
|
+
logHelpLine(t, logger, 'cli.help_harness_retro');
|
|
829
|
+
logHelpLine(t, logger, 'cli.help_harness_preview');
|
|
814
830
|
logHelpLine(t, logger, 'cli.help_web_map');
|
|
815
831
|
logHelpLine(t, logger, 'cli.help_web_scrape');
|
|
816
832
|
logHelpLine(t, logger, 'cli.help_scan_project');
|
|
@@ -1256,6 +1272,21 @@ async function main() {
|
|
|
1256
1272
|
result = await runHarnessValidate({ args, options, logger: commandLogger, t });
|
|
1257
1273
|
} else if (command === 'harness:apply-validation' || command === 'harness-apply-validation') {
|
|
1258
1274
|
result = await runHarnessApplyValidation({ args, options, logger: commandLogger, t });
|
|
1275
|
+
} else if (command === 'harness:approve' || command === 'harness-approve') {
|
|
1276
|
+
const { runHarnessApprove } = require('./commands/harness-gate');
|
|
1277
|
+
result = await runHarnessApprove({ args, options, logger: commandLogger, t });
|
|
1278
|
+
} else if (command === 'harness:reject' || command === 'harness-reject') {
|
|
1279
|
+
const { runHarnessReject } = require('./commands/harness-gate');
|
|
1280
|
+
result = await runHarnessReject({ args, options, logger: commandLogger, t });
|
|
1281
|
+
} else if (command === 'harness:status' || command === 'harness-status') {
|
|
1282
|
+
const { runHarnessStatus } = require('./commands/harness-status');
|
|
1283
|
+
result = await runHarnessStatus({ args, options, logger: commandLogger, t });
|
|
1284
|
+
} else if (command === 'harness:retro' || command === 'harness-retro') {
|
|
1285
|
+
const { runHarnessRetro } = require('./commands/harness-retro');
|
|
1286
|
+
result = await runHarnessRetro({ args, options, logger: commandLogger, t });
|
|
1287
|
+
} else if (command === 'harness:preview' || command === 'harness-preview') {
|
|
1288
|
+
const { runHarnessPreview } = require('./commands/harness-preview');
|
|
1289
|
+
result = await runHarnessPreview({ args, options, logger: commandLogger, t });
|
|
1259
1290
|
} else if (command === 'verify:gate' || command === 'verify-gate') {
|
|
1260
1291
|
result = await runVerifyGate({ args, options, logger: commandLogger, t });
|
|
1261
1292
|
|
|
@@ -333,6 +333,46 @@ async function runFeatureClose({ args, options = {}, logger }) {
|
|
|
333
333
|
const contractContent = await readFileSafe(contractPath);
|
|
334
334
|
const progressContent = await readFileSafe(progressPath);
|
|
335
335
|
|
|
336
|
+
// REQ-13 (loop-guardrails): tema `publish` é gate de COMANDO — intercepta
|
|
337
|
+
// o feature:close quando o contrato ativo o exige e não há gate publish
|
|
338
|
+
// aprovado. Nunca detectado por diff. `--force` NÃO bypassa: a aprovação
|
|
339
|
+
// humana é o propósito do gate (decisão registrada no spec da feature).
|
|
340
|
+
if (contractContent) {
|
|
341
|
+
try {
|
|
342
|
+
const contract = JSON.parse(contractContent);
|
|
343
|
+
const requiredFor = contract && contract.human_gate && Array.isArray(contract.human_gate.required_for)
|
|
344
|
+
? contract.human_gate.required_for
|
|
345
|
+
: [];
|
|
346
|
+
if (requiredFor.includes('publish')) {
|
|
347
|
+
const { hasApprovedPublishGate, pendingGates, createGate } = require('../harness/human-gate');
|
|
348
|
+
const { emitGuardEvent } = require('../harness/guard-events');
|
|
349
|
+
if (!hasApprovedPublishGate(planDir)) {
|
|
350
|
+
let gate = pendingGates(planDir).find((g) => g.theme === 'publish');
|
|
351
|
+
if (!gate) {
|
|
352
|
+
gate = createGate(planDir, {
|
|
353
|
+
theme: 'publish',
|
|
354
|
+
attempt: 0,
|
|
355
|
+
triggeredBy: [],
|
|
356
|
+
diffSummary: `feature:close ${slug}`,
|
|
357
|
+
runId: null
|
|
358
|
+
});
|
|
359
|
+
await emitGuardEvent(targetDir, {
|
|
360
|
+
eventType: 'human_gate_requested',
|
|
361
|
+
message: `publish gate ${gate.id} requested by feature:close`,
|
|
362
|
+
payload: { slug, gate_id: gate.id, theme: 'publish' }
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const errMsg = `[Publish Gate BLOCKED] Feature "${slug}" requires human approval before closing (human_gate.required_for includes "publish"). Approve with: aioson harness:approve . --slug=${slug} --gate=${gate.id}`;
|
|
366
|
+
if (options.json) {
|
|
367
|
+
return { ok: false, reason: 'publish_gate_pending', feature: slug, gate: gate.id, error: errMsg };
|
|
368
|
+
}
|
|
369
|
+
logger.log(errMsg);
|
|
370
|
+
return { ok: false };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} catch { /* contrato ilegível — o done gate abaixo lida com o estado */ }
|
|
374
|
+
}
|
|
375
|
+
|
|
336
376
|
if (contractContent && progressContent) {
|
|
337
377
|
const force = options.force === true;
|
|
338
378
|
let progress = null;
|
|
@@ -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,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `aioson harness:preview <file> [--max-bytes=8192] [--json]` (requirements §5.2).
|
|
5
|
+
*
|
|
6
|
+
* Wrapper fino e read-only de `previewArtifact` sobre um arquivo já persistido
|
|
7
|
+
* (ex.: `npm test > test.log`). Devolve preview + ponteiro para o agente de teste
|
|
8
|
+
* consumir sem despejar o log integral no contexto. Tema 2 (should-have).
|
|
9
|
+
*
|
|
10
|
+
* Exit codes: 0 sucesso; 12 input inválido (arquivo ausente/ilegível).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
const { previewArtifact, DEFAULT_MAX_BYTES } = require('../harness/preview-artifact');
|
|
17
|
+
|
|
18
|
+
const EXIT_OK = 0;
|
|
19
|
+
const EXIT_INPUT = 12;
|
|
20
|
+
|
|
21
|
+
function tr(t, key, params, fallback) {
|
|
22
|
+
if (typeof t !== 'function') return fallback;
|
|
23
|
+
const msg = t(key, params);
|
|
24
|
+
return msg && msg !== key ? msg : fallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function runHarnessPreview({ args, options = {}, logger, t } = {}) {
|
|
28
|
+
const log = logger || { log() {}, error() {} };
|
|
29
|
+
const file = args && args[0];
|
|
30
|
+
|
|
31
|
+
if (!file || typeof file !== 'string') {
|
|
32
|
+
log.error(tr(t, 'cli.harnessPreview.file_required', null, 'harness:preview requires a <file> path argument.'));
|
|
33
|
+
process.exitCode = EXIT_INPUT;
|
|
34
|
+
return { ok: false, exitCode: EXIT_INPUT, error: 'file_required' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// SF-02 (decisão de design, não bug): leitor read-only operador-local. Lê
|
|
38
|
+
// qualquer path legível por design — o caso de uso é previewar logs de teste
|
|
39
|
+
// (ex.: `npm test > test.log`) que podem viver fora do cwd. Sem cruzamento de
|
|
40
|
+
// fronteira de confiança (o operador já tem acesso ao FS); por isso não há
|
|
41
|
+
// contenção de workspace aqui. Mantém-se intencionalmente irrestrito.
|
|
42
|
+
const abs = path.resolve(process.cwd(), file);
|
|
43
|
+
let content;
|
|
44
|
+
try {
|
|
45
|
+
content = fs.readFileSync(abs, 'utf8');
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.code === 'ENOENT') {
|
|
48
|
+
log.error(tr(t, 'cli.harnessPreview.not_found', { path: file }, `File not found: ${file}`));
|
|
49
|
+
process.exitCode = EXIT_INPUT;
|
|
50
|
+
return { ok: false, exitCode: EXIT_INPUT, error: 'not_found' };
|
|
51
|
+
}
|
|
52
|
+
log.error(tr(t, 'cli.harnessPreview.read_error', { path: file, error: err.message }, `Could not read file: ${file} (${err.message})`));
|
|
53
|
+
process.exitCode = EXIT_INPUT;
|
|
54
|
+
return { ok: false, exitCode: EXIT_INPUT, error: 'read_error' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let maxBytes = Number(options['max-bytes'] ?? options.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
58
|
+
if (!Number.isInteger(maxBytes) || maxBytes <= 0) maxBytes = DEFAULT_MAX_BYTES;
|
|
59
|
+
|
|
60
|
+
// Modo leitura: o arquivo já está persistido — não reescrever (persist:false).
|
|
61
|
+
const result = previewArtifact(content, { maxBytes, artifactPath: abs, persist: false });
|
|
62
|
+
log.log(result.preview);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
exitCode: EXIT_OK,
|
|
67
|
+
file: file.replaceAll('\\', '/'),
|
|
68
|
+
totalBytes: result.totalBytes,
|
|
69
|
+
truncated: result.truncated,
|
|
70
|
+
preview: result.preview
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { runHarnessPreview };
|