@jaimevalasek/aioson 1.29.1 → 1.30.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 +19 -0
- package/README.md +7 -5
- package/docs/en/5-reference/cli-reference.md +40 -10
- package/docs/pt/4-agentes/pm.md +1 -1
- package/docs/pt/5-referencia/autopilot-handoff.md +4 -4
- package/docs/pt/5-referencia/comandos-cli.md +5 -3
- package/docs/pt/5-referencia/fluxo-artefatos.md +1 -1
- package/docs/pt/5-referencia/memoria-e-contexto.md +2 -2
- package/docs/pt/_arquivo/monitor-de-contexto.md +2 -2
- package/package.json +4 -2
- package/src/cli.js +67 -24
- package/src/commands/ac-test-audit.js +45 -0
- package/src/commands/artifact-validate.js +62 -50
- package/src/commands/classify.js +73 -2
- package/src/commands/context-brief.js +59 -0
- package/src/commands/context-guard.js +88 -0
- package/src/commands/context-monitor.js +1 -1
- package/src/commands/context-search.js +101 -52
- package/src/commands/context-select.js +11 -2
- package/src/commands/feature-archive.js +21 -12
- package/src/commands/feature-current.js +82 -0
- package/src/commands/gate-check.js +32 -15
- package/src/commands/harness-check.js +17 -1
- package/src/commands/hooks-install.js +169 -26
- package/src/commands/hygiene-scan.js +423 -0
- package/src/commands/rules-lint.js +11 -3
- package/src/commands/sdd-benchmark.js +134 -0
- package/src/commands/spec-analyze.js +6 -4
- package/src/commands/store-system.js +329 -49
- package/src/constants.js +8 -3
- package/src/context-brief.js +585 -0
- package/src/context-guard.js +209 -0
- package/src/context-search.js +796 -96
- package/src/context-selector.js +802 -444
- package/src/handoff-contract.js +14 -6
- package/src/harness/contract-schema.js +1 -1
- package/src/i18n/messages/en.js +12 -5
- package/src/i18n/messages/es.js +11 -4
- package/src/i18n/messages/fr.js +11 -4
- package/src/i18n/messages/pt-BR.js +12 -5
- package/src/lib/ac-test-audit.js +194 -0
- package/src/preflight-engine.js +10 -6
- package/src/squad/state-manager.js +1 -1
- package/template/.aioson/agents/analyst.md +41 -17
- package/template/.aioson/agents/architect.md +4 -2
- package/template/.aioson/agents/briefing-refiner.md +15 -2
- package/template/.aioson/agents/briefing.md +12 -8
- package/template/.aioson/agents/committer.md +1 -1
- package/template/.aioson/agents/copywriter.md +20 -9
- package/template/.aioson/agents/design-hybrid-forge.md +9 -5
- package/template/.aioson/agents/dev.md +22 -25
- package/template/.aioson/agents/deyvin.md +126 -124
- package/template/.aioson/agents/discover.md +3 -1
- package/template/.aioson/agents/discovery-design-doc.md +11 -2
- package/template/.aioson/agents/forge-run.md +3 -0
- package/template/.aioson/agents/genome.md +9 -5
- package/template/.aioson/agents/neo.md +30 -24
- package/template/.aioson/agents/orache.md +10 -6
- package/template/.aioson/agents/orchestrator.md +4 -2
- package/template/.aioson/agents/pentester.md +22 -12
- package/template/.aioson/agents/pm.md +5 -3
- package/template/.aioson/agents/product.md +25 -18
- package/template/.aioson/agents/profiler-enricher.md +10 -6
- package/template/.aioson/agents/profiler-forge.md +10 -6
- package/template/.aioson/agents/profiler-researcher.md +10 -6
- package/template/.aioson/agents/qa.md +21 -19
- package/template/.aioson/agents/scope-check.md +9 -3
- package/template/.aioson/agents/sheldon.md +22 -8
- package/template/.aioson/agents/site-forge.md +2 -0
- package/template/.aioson/agents/squad.md +4 -2
- package/template/.aioson/agents/tester.md +19 -15
- package/template/.aioson/agents/ux-ui.md +16 -8
- package/template/.aioson/config.md +4 -3
- package/template/.aioson/design-docs/agent-loading-contract.md +3 -3
- package/template/.aioson/docs/autopilot-handoff.md +3 -3
- package/template/.aioson/docs/dev/simple-plan-lane.md +73 -27
- package/template/.aioson/docs/dev/stack-conventions.md +1 -1
- package/template/.aioson/docs/deyvin/continuity-recovery.md +1 -1
- package/template/.aioson/docs/deyvin/runtime-handoffs.md +3 -3
- package/template/.aioson/docs/feature-expansion-taxonomy.md +53 -0
- package/template/.aioson/docs/handoff-persistence.md +14 -12
- package/template/.aioson/docs/product/conversation-playbook.md +1 -1
- package/template/.aioson/docs/sheldon/enrichment-paths.md +44 -1
- package/template/.aioson/docs/sheldon/harness-contract.md +23 -21
- package/template/.aioson/docs/tester/coverage-quality.md +1 -1
- package/template/.aioson/docs/ux-ui/design-execution.md +9 -7
- package/template/.aioson/rules/README.md +35 -17
- package/template/.aioson/rules/agent-structural-contract.md +165 -160
- package/template/.aioson/rules/aioson-context-boundary.md +5 -4
- package/template/.aioson/rules/canonical-path-contract.md +5 -4
- package/template/.aioson/rules/data-format-convention.md +5 -4
- package/template/.aioson/rules/disk-first-artifacts.md +2 -2
- package/template/.aioson/rules/implementation-structure-and-data-access.md +50 -0
- package/template/.aioson/rules/security-baseline.md +4 -3
- package/template/.aioson/rules/simple-plan-lane.md +18 -6
- package/template/.aioson/rules/source-code-language-convention.md +34 -0
- package/template/.aioson/skills/process/aioson-spec-driven/references/artifact-map.md +24 -23
- package/template/.aioson/skills/process/aioson-spec-driven/references/classification-map.md +4 -0
- package/template/.aioson/skills/process/aioson-spec-driven/references/dev.md +2 -2
- package/template/.aioson/skills/process/aioson-spec-driven/references/qa.md +1 -1
- package/template/.aioson/skills/process/briefing-expansion-scout/SKILL.md +72 -0
- package/template/.aioson/skills/process/product-scope-expansion/SKILL.md +74 -0
- package/template/.aioson/skills/process/sheldon-expansion-audit/SKILL.md +67 -0
- package/template/.aioson/skills/static/context-budget-guide.md +1 -1
- package/template/.aioson/skills/static/multi-agent-patterns.md +5 -4
- package/template/AGENTS.md +36 -19
- package/template/CLAUDE.md +9 -5
|
@@ -126,13 +126,26 @@ async function findSlugFiles(ctxDir, slug, otherSlugs = []) {
|
|
|
126
126
|
.filter((name) => !belongsToOtherSlug(name, slug, otherSlugs));
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
async function findArchivedFiles(archiveDir) {
|
|
130
|
-
const entries = await readDirSafe(archiveDir);
|
|
131
|
-
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
129
|
+
async function findArchivedFiles(archiveDir) {
|
|
130
|
+
const entries = await readDirSafe(archiveDir);
|
|
131
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function removeEmptyDirBestEffort(dir) {
|
|
135
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
136
|
+
try {
|
|
137
|
+
await fs.rmdir(dir);
|
|
138
|
+
return;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (!err || err.code === 'ENOENT' || err.code === 'ENOTEMPTY') return;
|
|
141
|
+
if (attempt === 3) return;
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Enumerate every artefact that belongs to a feature slug — the exact surface
|
|
136
149
|
* `feature:archive` would move — but as a pure read-only discovery, for
|
|
137
150
|
* non-destructive consumers (e.g. `feature:export`). Never mutates the tree.
|
|
138
151
|
*
|
|
@@ -552,11 +565,7 @@ async function runRestore({ slug, ctxDir, archiveDir, manifestPath, dryRun, json
|
|
|
552
565
|
dossierRestored = path.relative(ctxDir, dossierSourceDir);
|
|
553
566
|
}
|
|
554
567
|
|
|
555
|
-
|
|
556
|
-
await fs.rmdir(archiveDir);
|
|
557
|
-
} catch {
|
|
558
|
-
// Directory not empty (manual files) — leave it alone.
|
|
559
|
-
}
|
|
568
|
+
await removeEmptyDirBestEffort(archiveDir);
|
|
560
569
|
|
|
561
570
|
await updateManifest(manifestPath, { slug }, 'remove');
|
|
562
571
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* aioson feature:current — resolve the active feature slug deterministically.
|
|
5
|
+
*
|
|
6
|
+
* Single source of truth for "which feature is active right now", so every spec
|
|
7
|
+
* agent (product, sheldon, analyst, ux-ui, scope-check, discovery-design-doc)
|
|
8
|
+
* resolves the SAME {slug} instead of re-guessing and colliding on bare paths
|
|
9
|
+
* (design-doc.md, readiness.md, scope-check.md, ui-spec.md, sheldon-enrichment.md).
|
|
10
|
+
*
|
|
11
|
+
* Resolution order:
|
|
12
|
+
* 1. project-pulse.md `active_feature` (when set and not the `(none)` sentinel)
|
|
13
|
+
* 2. the single `in_progress` row in features.md (unambiguous fallback)
|
|
14
|
+
* 3. empty — genuine project-level work, no active feature
|
|
15
|
+
*
|
|
16
|
+
* When more than one feature is `in_progress`, the result is `ambiguous`: no slug
|
|
17
|
+
* is guessed, and the caller must disambiguate (ask the user) rather than
|
|
18
|
+
* silently overwrite another feature's artifact.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* aioson feature:current . # prints the slug (or nothing) to stdout
|
|
22
|
+
* aioson feature:current . --json # { ok, slug, source, ambiguous, candidates }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const { readProjectPulse, parseFeaturesMap, readFileSafe, contextDir } = require('../preflight-engine');
|
|
27
|
+
|
|
28
|
+
// Values that mean "no active feature" rather than a real slug.
|
|
29
|
+
const NONE_SENTINELS = new Set(['', '(none)', 'none', 'null', '-', 'n/a']);
|
|
30
|
+
|
|
31
|
+
function normalizeSlug(value) {
|
|
32
|
+
return String(value == null ? '' : value).trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isNone(value) {
|
|
36
|
+
return NONE_SENTINELS.has(normalizeSlug(value).toLowerCase());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function resolveActiveFeature(targetDir) {
|
|
40
|
+
// 1. project-pulse.md active_feature — the single source of truth maintained
|
|
41
|
+
// by pulse:update and reset by feature:close.
|
|
42
|
+
const pulse = await readProjectPulse(targetDir);
|
|
43
|
+
if (pulse && pulse.exists && !isNone(pulse.active_feature)) {
|
|
44
|
+
return { slug: normalizeSlug(pulse.active_feature), source: 'pulse', ambiguous: false, candidates: [] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. features.md — fall back to the unique in_progress row.
|
|
48
|
+
const featuresContent = await readFileSafe(path.join(contextDir(targetDir), 'features.md'));
|
|
49
|
+
const map = parseFeaturesMap(featuresContent);
|
|
50
|
+
const inProgress = [];
|
|
51
|
+
for (const [slug, status] of map.entries()) {
|
|
52
|
+
if (normalizeSlug(status).toLowerCase() === 'in_progress' && !inProgress.includes(slug)) {
|
|
53
|
+
inProgress.push(slug);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (inProgress.length === 1) {
|
|
57
|
+
return { slug: inProgress[0], source: 'features.md', ambiguous: false, candidates: [] };
|
|
58
|
+
}
|
|
59
|
+
if (inProgress.length > 1) {
|
|
60
|
+
// Ambiguous: more than one feature is open. The caller must disambiguate
|
|
61
|
+
// (ask the user) rather than silently colliding on a guessed slug.
|
|
62
|
+
return { slug: '', source: 'features.md', ambiguous: true, candidates: inProgress };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. No active feature — genuine project-level work.
|
|
66
|
+
return { slug: '', source: 'none', ambiguous: false, candidates: [] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function runFeatureCurrent({ args = [], options = {}, logger = console } = {}) {
|
|
70
|
+
const targetDir = args[0] || options.dir || '.';
|
|
71
|
+
const resolved = await resolveActiveFeature(targetDir);
|
|
72
|
+
const payload = { ok: true, ...resolved };
|
|
73
|
+
|
|
74
|
+
if (!options.json) {
|
|
75
|
+
// Plain mode prints ONLY the slug so `$(aioson feature:current .)` is
|
|
76
|
+
// directly usable in shell substitution; ambiguous/none print nothing.
|
|
77
|
+
if (resolved.slug) logger.log(resolved.slug);
|
|
78
|
+
}
|
|
79
|
+
return payload;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { runFeatureCurrent, resolveActiveFeature };
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* aioson gate:check . --feature=checkout --gate=C --json
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
const path = require('node:path');
|
|
16
|
-
const {
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { auditAcceptanceCriteriaTests } = require('../lib/ac-test-audit');
|
|
17
|
+
const {
|
|
17
18
|
contextDir,
|
|
18
19
|
readFileSafe,
|
|
19
20
|
fileExists,
|
|
@@ -104,7 +105,7 @@ async function checkGate(targetDir, slug, gateLetter) {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
// Gate D: check for QA sign-off in spec
|
|
107
|
-
if (gateLetter === 'D') {
|
|
108
|
+
if (gateLetter === 'D') {
|
|
108
109
|
if (specContent && specContent.includes('## QA Sign-off')) {
|
|
109
110
|
// Check verdict
|
|
110
111
|
const passMatch = specContent.match(/\*\*Verdict:\*\*\s*(PASS|FAIL)/i);
|
|
@@ -130,12 +131,23 @@ async function checkGate(targetDir, slug, gateLetter) {
|
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
// Also check spec version for explicit gate_execution
|
|
133
|
-
if (gates.execution && gates.execution !== 'pending') {
|
|
134
|
-
const gateD = gates.execution;
|
|
135
|
-
evidence.push({ type: 'gate_field', field: 'gate_execution', value: gateD, ok: gateD === 'approved' });
|
|
136
|
-
if (gateD !== 'approved') missing.push(`gate_execution: ${gateD}`);
|
|
137
|
-
}
|
|
138
|
-
|
|
134
|
+
if (gates.execution && gates.execution !== 'pending') {
|
|
135
|
+
const gateD = gates.execution;
|
|
136
|
+
evidence.push({ type: 'gate_field', field: 'gate_execution', value: gateD, ok: gateD === 'approved' });
|
|
137
|
+
if (gateD !== 'approved') missing.push(`gate_execution: ${gateD}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const acAudit = await auditAcceptanceCriteriaTests(targetDir, slug);
|
|
141
|
+
evidence.push({
|
|
142
|
+
type: 'ac_test_audit',
|
|
143
|
+
ok: acAudit.ok,
|
|
144
|
+
summary: acAudit.summary,
|
|
145
|
+
missing: acAudit.missing
|
|
146
|
+
});
|
|
147
|
+
if (!acAudit.ok) {
|
|
148
|
+
missing.push(`AC test audit failed: missing tests for ${acAudit.missing.join(', ')}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
139
151
|
|
|
140
152
|
const allOk = missing.length === 0;
|
|
141
153
|
const result = allOk ? 'PASS' : 'BLOCKED';
|
|
@@ -231,15 +243,20 @@ async function runGateCheck({ args, options = {}, logger }) {
|
|
|
231
243
|
}
|
|
232
244
|
}
|
|
233
245
|
|
|
234
|
-
const qaEvidence = check.evidence.filter((e) => e.type === 'qa_signoff' || e.type === 'checkpoint' || e.type === 'gate_field');
|
|
246
|
+
const qaEvidence = check.evidence.filter((e) => e.type === 'qa_signoff' || e.type === 'checkpoint' || e.type === 'gate_field' || e.type === 'ac_test_audit');
|
|
235
247
|
if (qaEvidence.length > 0) {
|
|
236
248
|
for (const q of qaEvidence) {
|
|
237
249
|
const icon = q.ok ? ' ✓' : ' ✗';
|
|
238
|
-
if (q.type === 'qa_signoff') logger.log(`${icon} QA sign-off: ${q.exists === false ? 'missing' : `verdict ${q.verdict || 'unclear'}`}`);
|
|
239
|
-
if (q.type === 'checkpoint') logger.log(` ✓ last_checkpoint: "${q.value}"`);
|
|
240
|
-
if (q.type === 'gate_field') logger.log(`${icon} gate_execution: ${q.value}`);
|
|
241
|
-
|
|
242
|
-
|
|
250
|
+
if (q.type === 'qa_signoff') logger.log(`${icon} QA sign-off: ${q.exists === false ? 'missing' : `verdict ${q.verdict || 'unclear'}`}`);
|
|
251
|
+
if (q.type === 'checkpoint') logger.log(` ✓ last_checkpoint: "${q.value}"`);
|
|
252
|
+
if (q.type === 'gate_field') logger.log(`${icon} gate_execution: ${q.value}`);
|
|
253
|
+
if (q.type === 'ac_test_audit') {
|
|
254
|
+
const s = q.summary || {};
|
|
255
|
+
const missing = q.missing && q.missing.length ? ` (missing: ${q.missing.join(', ')})` : '';
|
|
256
|
+
logger.log(`${icon} AC test audit: ${s.covered || 0}/${s.acs_total || 0} covered${missing}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
243
260
|
|
|
244
261
|
logger.log('');
|
|
245
262
|
const resultIcon = check.result === 'PASS' ? '✓' : '✗';
|
|
@@ -97,6 +97,17 @@ async function runHarnessCheck({ args, options = {}, logger, t }) {
|
|
|
97
97
|
(c) => c && typeof c.verification === 'string' && c.verification.trim()
|
|
98
98
|
);
|
|
99
99
|
const skipped = criteria.length - executable.length;
|
|
100
|
+
const strict = Boolean(options.strict);
|
|
101
|
+
const binaryWithoutVerification = criteria.filter(
|
|
102
|
+
(c) => c && c.binary === true && !(typeof c.verification === 'string' && c.verification.trim())
|
|
103
|
+
);
|
|
104
|
+
const strictErrors = [];
|
|
105
|
+
if (strict && criteria.length > 0 && executable.length === 0) {
|
|
106
|
+
strictErrors.push('strict mode requires at least one executable verification criterion');
|
|
107
|
+
}
|
|
108
|
+
if (strict && binaryWithoutVerification.length > 0) {
|
|
109
|
+
strictErrors.push(`strict mode requires verification for binary criteria: ${binaryWithoutVerification.map((c) => c.id).join(', ')}`);
|
|
110
|
+
}
|
|
100
111
|
|
|
101
112
|
const checks = await runCriteria({ criteria, cwd: targetDir, timeoutMs });
|
|
102
113
|
const failed = checks.filter((c) => !c.ok);
|
|
@@ -111,14 +122,16 @@ async function runHarnessCheck({ args, options = {}, logger, t }) {
|
|
|
111
122
|
}
|
|
112
123
|
|
|
113
124
|
const report = {
|
|
114
|
-
ok: failed.length === 0,
|
|
125
|
+
ok: failed.length === 0 && strictErrors.length === 0,
|
|
115
126
|
slug,
|
|
116
127
|
checked_at: new Date().toISOString(),
|
|
128
|
+
strict,
|
|
117
129
|
criteria_total: criteria.length,
|
|
118
130
|
executable_total: executable.length,
|
|
119
131
|
passed: checks.length - failed.length,
|
|
120
132
|
failed: failed.length,
|
|
121
133
|
skipped_no_verification: skipped,
|
|
134
|
+
strict_errors: strictErrors,
|
|
122
135
|
checks
|
|
123
136
|
};
|
|
124
137
|
|
|
@@ -140,6 +153,9 @@ async function runHarnessCheck({ args, options = {}, logger, t }) {
|
|
|
140
153
|
}
|
|
141
154
|
|
|
142
155
|
logger.log(t('harness.check_header', { slug }) || `Harness check — ${slug}`);
|
|
156
|
+
for (const error of strictErrors) {
|
|
157
|
+
logger.log(` ✗ ${error}`);
|
|
158
|
+
}
|
|
143
159
|
if (executable.length === 0) {
|
|
144
160
|
logger.log(t('harness.check_no_executable', { total: criteria.length }) || ` No criteria with verification commands (${criteria.length} criteria total). @validator judges them all.`);
|
|
145
161
|
return report;
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
const path = require('node:path');
|
|
18
18
|
const fs = require('node:fs/promises');
|
|
19
19
|
const os = require('node:os');
|
|
20
|
+
const { getAgentDefinition, normalizeAgentName } = require('../agents');
|
|
20
21
|
|
|
21
22
|
const HOME = os.homedir();
|
|
23
|
+
const HOOK_AGENT_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
22
24
|
|
|
23
25
|
// ─── Config file paths ────────────────────────────────────────────────────────
|
|
24
26
|
|
|
@@ -30,22 +32,63 @@ const CONFIG_PATHS = {
|
|
|
30
32
|
|
|
31
33
|
// ─── Hook command templates ───────────────────────────────────────────────────
|
|
32
34
|
|
|
35
|
+
function normalizeHookAgentName(input = 'dev') {
|
|
36
|
+
const raw = normalizeAgentName(input || 'dev').replace(/^\//, '');
|
|
37
|
+
const definition = getAgentDefinition(raw);
|
|
38
|
+
const agentName = definition ? definition.id : raw;
|
|
39
|
+
|
|
40
|
+
if (!HOOK_AGENT_NAME_RE.test(agentName)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`invalid agent name "${String(input)}"; use a known agent id or kebab-case identifier`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return agentName;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function shellQuote(value) {
|
|
50
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
function makeEmitCommand(agentName, source) {
|
|
34
54
|
// $PWD is the project directory at hook execution time
|
|
35
|
-
return `aioson hooks:emit "$PWD" --agent=${agentName} --source=${source} 2>/dev/null || true`;
|
|
55
|
+
return `aioson hooks:emit "$PWD" --agent=${shellQuote(agentName)} --source=${shellQuote(source)} 2>/dev/null || true`;
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
function makeDoneCommand(agentName) {
|
|
39
|
-
return `aioson agent:done "$PWD" --agent=${agentName} --summary
|
|
59
|
+
return `aioson agent:done "$PWD" --agent=${shellQuote(agentName)} --summary=${shellQuote(`Session ended via ${agentName} hook`)} 2>/dev/null || true`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeGuardCommand(agentName) {
|
|
63
|
+
// PreToolUse: the harness pipes the pending edit event (JSON on stdin); the
|
|
64
|
+
// guard derives a query from the artifact itself, runs context:brief, and
|
|
65
|
+
// injects salient project-rule constraints before the write lands. Advisory —
|
|
66
|
+
// always exits 0, never blocks the tool.
|
|
67
|
+
return `aioson context:guard "$PWD" --tool=claude --agent=${shellQuote(agentName)} --json 2>/dev/null || true`;
|
|
40
68
|
}
|
|
41
69
|
|
|
42
70
|
// ─── Claude Code ─────────────────────────────────────────────────────────────
|
|
43
71
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
72
|
+
// True for any hook entry AIOSON owns, so reinstall/uninstall can scrub them
|
|
73
|
+
// without disturbing user-authored hooks.
|
|
74
|
+
const AIOSON_HOOK_SIGNATURES = [
|
|
75
|
+
'aioson hooks:emit',
|
|
76
|
+
'aioson agent:done',
|
|
77
|
+
'aioson context:guard',
|
|
78
|
+
'aioson live:start'
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
function isAiosonHookEntry(entry) {
|
|
82
|
+
const cmd = entry?.hooks?.[0]?.command || entry?.command || '';
|
|
83
|
+
return AIOSON_HOOK_SIGNATURES.some((sig) => cmd.includes(sig));
|
|
84
|
+
}
|
|
47
85
|
|
|
48
|
-
|
|
86
|
+
function buildClaudeHooks(agentName, includeGuard = true) {
|
|
87
|
+
const safeAgentName = normalizeHookAgentName(agentName);
|
|
88
|
+
const emitCmd = makeEmitCommand(safeAgentName, 'claude');
|
|
89
|
+
const doneCmd = makeDoneCommand(safeAgentName);
|
|
90
|
+
|
|
91
|
+
const hooks = {
|
|
49
92
|
PostToolUse: [
|
|
50
93
|
{
|
|
51
94
|
matcher: 'Write|Edit|MultiEdit',
|
|
@@ -66,9 +109,20 @@ function buildClaudeHooks(agentName) {
|
|
|
66
109
|
}
|
|
67
110
|
]
|
|
68
111
|
};
|
|
112
|
+
|
|
113
|
+
if (includeGuard) {
|
|
114
|
+
hooks.PreToolUse = [
|
|
115
|
+
{
|
|
116
|
+
matcher: 'Write|Edit|MultiEdit|NotebookEdit',
|
|
117
|
+
hooks: [{ type: 'command', command: makeGuardCommand(safeAgentName) }]
|
|
118
|
+
}
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return hooks;
|
|
69
123
|
}
|
|
70
124
|
|
|
71
|
-
async function installClaudeHooks(agentName, dryRun, logger) {
|
|
125
|
+
async function installClaudeHooks(agentName, dryRun, logger, includeGuard = true) {
|
|
72
126
|
const configPath = CONFIG_PATHS.claude;
|
|
73
127
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
74
128
|
|
|
@@ -77,21 +131,26 @@ async function installClaudeHooks(agentName, dryRun, logger) {
|
|
|
77
131
|
existing = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
|
78
132
|
} catch { /* file doesn't exist yet */ }
|
|
79
133
|
|
|
80
|
-
const newHooks = buildClaudeHooks(agentName);
|
|
134
|
+
const newHooks = buildClaudeHooks(agentName, includeGuard);
|
|
81
135
|
|
|
82
136
|
// Merge: add AIOSON hooks without removing existing ones
|
|
83
137
|
const merged = { ...existing };
|
|
84
138
|
if (!merged.hooks) merged.hooks = {};
|
|
85
139
|
|
|
140
|
+
// When the guard is opted out, still scrub any previously-installed guard hook
|
|
141
|
+
// so a reinstall with --no-guard actually removes it.
|
|
142
|
+
for (const event of Object.keys(merged.hooks)) {
|
|
143
|
+
if (newHooks[event]) continue;
|
|
144
|
+
merged.hooks[event] = (merged.hooks[event] || []).filter((entry) => !isAiosonHookEntry(entry));
|
|
145
|
+
if (merged.hooks[event].length === 0) delete merged.hooks[event];
|
|
146
|
+
}
|
|
147
|
+
|
|
86
148
|
for (const [event, hookList] of Object.entries(newHooks)) {
|
|
87
149
|
if (!merged.hooks[event]) {
|
|
88
150
|
merged.hooks[event] = hookList;
|
|
89
151
|
} else {
|
|
90
152
|
// Remove any existing AIOSON hooks (to avoid duplicates on reinstall)
|
|
91
|
-
const filtered = merged.hooks[event].filter((entry) =>
|
|
92
|
-
const cmd = entry.hooks?.[0]?.command || '';
|
|
93
|
-
return !cmd.includes('aioson hooks:emit') && !cmd.includes('aioson agent:done');
|
|
94
|
-
});
|
|
153
|
+
const filtered = merged.hooks[event].filter((entry) => !isAiosonHookEntry(entry));
|
|
95
154
|
merged.hooks[event] = [...filtered, ...hookList];
|
|
96
155
|
}
|
|
97
156
|
}
|
|
@@ -102,6 +161,9 @@ async function installClaudeHooks(agentName, dryRun, logger) {
|
|
|
102
161
|
} else {
|
|
103
162
|
logger.log(` [dry-run] Would write: ${configPath}`);
|
|
104
163
|
logger.log(` Hooks to add:`);
|
|
164
|
+
if (includeGuard) {
|
|
165
|
+
logger.log(` PreToolUse (Write|Edit|MultiEdit|NotebookEdit) → context:guard`);
|
|
166
|
+
}
|
|
105
167
|
logger.log(` PostToolUse (Write|Edit|MultiEdit|Bash|Task|TodoWrite) → hooks:emit`);
|
|
106
168
|
logger.log(` Stop → agent:done`);
|
|
107
169
|
}
|
|
@@ -112,9 +174,10 @@ async function installClaudeHooks(agentName, dryRun, logger) {
|
|
|
112
174
|
// ─── Antigravity ─────────────────────────────────────────────────────────────
|
|
113
175
|
|
|
114
176
|
function buildAntigravityHooks(agentName) {
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
177
|
+
const safeAgentName = normalizeHookAgentName(agentName);
|
|
178
|
+
const emitCmd = makeEmitCommand(safeAgentName, 'antigravity');
|
|
179
|
+
const doneCmd = makeDoneCommand(safeAgentName);
|
|
180
|
+
const startCmd = `aioson live:start "$PWD" --agent=${shellQuote(safeAgentName)} --tool=antigravity --no-launch 2>/dev/null || true`;
|
|
118
181
|
|
|
119
182
|
return {
|
|
120
183
|
SessionStart: [{ type: 'command', command: startCmd }],
|
|
@@ -178,9 +241,39 @@ function mergeAntigravityHooks(existing, newHooks) {
|
|
|
178
241
|
return merged;
|
|
179
242
|
}
|
|
180
243
|
|
|
244
|
+
// Scrub AIOSON entries from one Antigravity hooks file, mirroring the
|
|
245
|
+
// `cmd.includes('aioson')` policy the merge uses on install.
|
|
246
|
+
async function scrubAntigravityHookFile(filePath, dryRun, logger, label) {
|
|
247
|
+
let existing;
|
|
248
|
+
try {
|
|
249
|
+
existing = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
250
|
+
} catch {
|
|
251
|
+
logger.log(` ${label} not found — nothing to remove`);
|
|
252
|
+
return { tool: 'antigravity', path: filePath, removed: false };
|
|
253
|
+
}
|
|
254
|
+
if (existing.hooks) {
|
|
255
|
+
for (const event of Object.keys(existing.hooks)) {
|
|
256
|
+
existing.hooks[event] = (existing.hooks[event] || []).filter((entry) => {
|
|
257
|
+
const cmd = entry.command || entry.hooks?.[0]?.command || '';
|
|
258
|
+
return !cmd.includes('aioson');
|
|
259
|
+
});
|
|
260
|
+
if (existing.hooks[event].length === 0) delete existing.hooks[event];
|
|
261
|
+
}
|
|
262
|
+
if (Object.keys(existing.hooks).length === 0) delete existing.hooks;
|
|
263
|
+
}
|
|
264
|
+
if (!dryRun) {
|
|
265
|
+
await fs.writeFile(filePath, JSON.stringify(existing, null, 2), 'utf8');
|
|
266
|
+
logger.log(` ✓ ${label} hooks removed — ${filePath}`);
|
|
267
|
+
} else {
|
|
268
|
+
logger.log(` [dry-run] Would remove AIOSON hooks from: ${filePath}`);
|
|
269
|
+
}
|
|
270
|
+
return { tool: 'antigravity', path: filePath, removed: true };
|
|
271
|
+
}
|
|
272
|
+
|
|
181
273
|
// ─── Codex (OpenAI) ───────────────────────────────────────────────────────────
|
|
182
274
|
|
|
183
275
|
async function installCodexHooks(agentName, dryRun, logger) {
|
|
276
|
+
const safeAgentName = normalizeHookAgentName(agentName);
|
|
184
277
|
// Codex CLI does not have a native hook system as of 2026.
|
|
185
278
|
// The workaround: add a shell alias that wraps `codex` and calls live:start before / agent:done after.
|
|
186
279
|
const configPath = path.join(HOME, '.codex', 'config.yaml');
|
|
@@ -188,12 +281,12 @@ async function installCodexHooks(agentName, dryRun, logger) {
|
|
|
188
281
|
|
|
189
282
|
const wrapperScript = `#!/bin/bash
|
|
190
283
|
# AIOSON session wrapper for Codex CLI
|
|
191
|
-
# Generated by: aioson hooks:install --tool=codex --agent=${
|
|
284
|
+
# Generated by: aioson hooks:install --tool=codex --agent=${safeAgentName}
|
|
192
285
|
# Usage: replace \`codex\` calls with \`codex-aioson\` OR add to .bashrc:
|
|
193
286
|
# alias codex='${wrapperPath}'
|
|
194
287
|
|
|
195
288
|
PROJECT_DIR="\${1:-$PWD}"
|
|
196
|
-
AGENT
|
|
289
|
+
AGENT=${shellQuote(safeAgentName)}
|
|
197
290
|
|
|
198
291
|
# Start live session before Codex runs
|
|
199
292
|
aioson live:start "$PROJECT_DIR" --agent="$AGENT" --tool=codex --no-launch 2>/dev/null || true
|
|
@@ -248,8 +341,15 @@ async function detectInstalledTools() {
|
|
|
248
341
|
|
|
249
342
|
async function runHooksInstall({ args, options = {}, logger }) {
|
|
250
343
|
const projectDir = path.resolve(process.cwd(), args[0] || '.');
|
|
251
|
-
|
|
344
|
+
let agentName;
|
|
345
|
+
try {
|
|
346
|
+
agentName = normalizeHookAgentName(options.agent || 'dev');
|
|
347
|
+
} catch (err) {
|
|
348
|
+
logger.log(`Invalid agent name: ${err.message}`);
|
|
349
|
+
return { ok: false, reason: 'invalid_agent_name', error: err.message };
|
|
350
|
+
}
|
|
252
351
|
const dryRun = options['dry-run'] || options.dryRun || false;
|
|
352
|
+
const includeGuard = !(options['no-guard'] || options.noGuard);
|
|
253
353
|
let tool = options.tool ? String(options.tool).trim().toLowerCase() : 'all';
|
|
254
354
|
|
|
255
355
|
if (tool === 'all') {
|
|
@@ -271,7 +371,7 @@ async function runHooksInstall({ args, options = {}, logger }) {
|
|
|
271
371
|
for (const t of tools) {
|
|
272
372
|
try {
|
|
273
373
|
if (t === 'claude') {
|
|
274
|
-
results.push(await installClaudeHooks(agentName, dryRun, logger));
|
|
374
|
+
results.push(await installClaudeHooks(agentName, dryRun, logger, includeGuard));
|
|
275
375
|
} else if (t === 'antigravity') {
|
|
276
376
|
results.push(await installAntigravityHooks(agentName, projectDir, dryRun, logger));
|
|
277
377
|
} else if (t === 'codex') {
|
|
@@ -290,6 +390,9 @@ async function runHooksInstall({ args, options = {}, logger }) {
|
|
|
290
390
|
if (!dryRun) {
|
|
291
391
|
logger.log('');
|
|
292
392
|
logger.log('Hooks installed. From now on:');
|
|
393
|
+
if (includeGuard && tools.includes('claude')) {
|
|
394
|
+
logger.log(' • Before each file write/edit → context:guard injects salient project-rule constraints');
|
|
395
|
+
}
|
|
293
396
|
logger.log(' • Every file write/edit → logged as artifact event');
|
|
294
397
|
logger.log(' • Every bash command → logged as step_done event');
|
|
295
398
|
logger.log(' • Session end → logged as agent:done');
|
|
@@ -306,7 +409,14 @@ async function runHooksInstall({ args, options = {}, logger }) {
|
|
|
306
409
|
}
|
|
307
410
|
|
|
308
411
|
async function runHooksUninstall({ args, options = {}, logger }) {
|
|
309
|
-
|
|
412
|
+
let agentName;
|
|
413
|
+
try {
|
|
414
|
+
agentName = normalizeHookAgentName(options.agent || 'dev');
|
|
415
|
+
} catch (err) {
|
|
416
|
+
logger.log(`Invalid agent name: ${err.message}`);
|
|
417
|
+
return { ok: false, reason: 'invalid_agent_name', error: err.message };
|
|
418
|
+
}
|
|
419
|
+
const projectDir = path.resolve(process.cwd(), args && args[0] ? args[0] : '.');
|
|
310
420
|
const dryRun = options['dry-run'] || options.dryRun || false;
|
|
311
421
|
const tool = options.tool ? String(options.tool).trim().toLowerCase() : 'claude';
|
|
312
422
|
const tools = tool.split(',').map((t) => t.trim()).filter(Boolean);
|
|
@@ -314,6 +424,8 @@ async function runHooksUninstall({ args, options = {}, logger }) {
|
|
|
314
424
|
logger.log(`Hooks Uninstall — agent: @${agentName}${dryRun ? ' [dry-run]' : ''}`);
|
|
315
425
|
logger.log('─'.repeat(50));
|
|
316
426
|
|
|
427
|
+
const results = [];
|
|
428
|
+
|
|
317
429
|
for (const t of tools) {
|
|
318
430
|
if (t === 'claude') {
|
|
319
431
|
try {
|
|
@@ -321,10 +433,7 @@ async function runHooksUninstall({ args, options = {}, logger }) {
|
|
|
321
433
|
const existing = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
|
322
434
|
if (existing.hooks) {
|
|
323
435
|
for (const event of Object.keys(existing.hooks)) {
|
|
324
|
-
existing.hooks[event] = (existing.hooks[event] || []).filter((entry) =>
|
|
325
|
-
const cmd = entry.hooks?.[0]?.command || entry.command || '';
|
|
326
|
-
return !cmd.includes('aioson hooks:emit') && !cmd.includes('aioson agent:done') && !cmd.includes('aioson live:start');
|
|
327
|
-
});
|
|
436
|
+
existing.hooks[event] = (existing.hooks[event] || []).filter((entry) => !isAiosonHookEntry(entry));
|
|
328
437
|
if (existing.hooks[event].length === 0) delete existing.hooks[event];
|
|
329
438
|
}
|
|
330
439
|
if (Object.keys(existing.hooks).length === 0) delete existing.hooks;
|
|
@@ -335,13 +444,47 @@ async function runHooksUninstall({ args, options = {}, logger }) {
|
|
|
335
444
|
} else {
|
|
336
445
|
logger.log(` [dry-run] Would remove AIOSON hooks from: ${configPath}`);
|
|
337
446
|
}
|
|
447
|
+
results.push({ tool: 'claude', path: configPath, removed: true });
|
|
338
448
|
} catch {
|
|
339
449
|
logger.log(` Claude Code settings not found — nothing to remove`);
|
|
450
|
+
results.push({ tool: 'claude', removed: false });
|
|
340
451
|
}
|
|
452
|
+
} else if (t === 'antigravity') {
|
|
453
|
+
// Install writes both a global and a workspace hooks file — scrub both.
|
|
454
|
+
results.push(await scrubAntigravityHookFile(CONFIG_PATHS.antigravity, dryRun, logger, 'Antigravity global'));
|
|
455
|
+
results.push(await scrubAntigravityHookFile(
|
|
456
|
+
path.join(projectDir, CONFIG_PATHS.antigravity_workspace), dryRun, logger, 'Antigravity workspace'
|
|
457
|
+
));
|
|
458
|
+
} else if (t === 'codex') {
|
|
459
|
+
const wrapperPath = path.join(HOME, '.codex', 'aioson-wrapper.sh');
|
|
460
|
+
try {
|
|
461
|
+
if (!dryRun) {
|
|
462
|
+
await fs.unlink(wrapperPath);
|
|
463
|
+
logger.log(` ✓ Codex wrapper removed — ${wrapperPath}`);
|
|
464
|
+
logger.log(` ⚠ Also remove the alias from your shell rc if you added it: alias codex='${wrapperPath}'`);
|
|
465
|
+
} else {
|
|
466
|
+
logger.log(` [dry-run] Would remove: ${wrapperPath}`);
|
|
467
|
+
}
|
|
468
|
+
results.push({ tool: 'codex', path: wrapperPath, removed: true });
|
|
469
|
+
} catch {
|
|
470
|
+
logger.log(` Codex wrapper not found — nothing to remove`);
|
|
471
|
+
results.push({ tool: 'codex', removed: false });
|
|
472
|
+
}
|
|
473
|
+
} else {
|
|
474
|
+
logger.log(` ⚠ Unknown tool: ${t} — supported: claude, antigravity, codex`);
|
|
475
|
+
results.push({ tool: t, removed: false, reason: 'unsupported' });
|
|
341
476
|
}
|
|
342
477
|
}
|
|
343
478
|
|
|
344
|
-
return { ok: true };
|
|
479
|
+
return { ok: true, results };
|
|
345
480
|
}
|
|
346
481
|
|
|
347
|
-
module.exports = {
|
|
482
|
+
module.exports = {
|
|
483
|
+
runHooksInstall,
|
|
484
|
+
runHooksUninstall,
|
|
485
|
+
buildClaudeHooks,
|
|
486
|
+
buildAntigravityHooks,
|
|
487
|
+
scrubAntigravityHookFile,
|
|
488
|
+
normalizeHookAgentName,
|
|
489
|
+
isAiosonHookEntry
|
|
490
|
+
};
|