@jaimevalasek/aioson 1.29.1 → 1.30.1

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.
Files changed (115) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +7 -5
  3. package/docs/en/5-reference/cli-reference.md +40 -10
  4. package/docs/pt/4-agentes/pm.md +1 -1
  5. package/docs/pt/5-referencia/autopilot-handoff.md +4 -4
  6. package/docs/pt/5-referencia/comandos-cli.md +5 -3
  7. package/docs/pt/5-referencia/fluxo-artefatos.md +1 -1
  8. package/docs/pt/5-referencia/memoria-e-contexto.md +2 -2
  9. package/docs/pt/_arquivo/monitor-de-contexto.md +2 -2
  10. package/package.json +4 -2
  11. package/src/cli.js +67 -24
  12. package/src/commands/ac-test-audit.js +45 -0
  13. package/src/commands/artifact-validate.js +62 -50
  14. package/src/commands/classify.js +73 -2
  15. package/src/commands/context-brief.js +59 -0
  16. package/src/commands/context-guard.js +88 -0
  17. package/src/commands/context-monitor.js +1 -1
  18. package/src/commands/context-search.js +101 -52
  19. package/src/commands/context-select.js +11 -2
  20. package/src/commands/feature-archive.js +21 -12
  21. package/src/commands/feature-current.js +82 -0
  22. package/src/commands/gate-check.js +32 -15
  23. package/src/commands/harness-check.js +17 -1
  24. package/src/commands/hooks-install.js +169 -26
  25. package/src/commands/hygiene-scan.js +423 -0
  26. package/src/commands/rules-lint.js +11 -3
  27. package/src/commands/sdd-benchmark.js +134 -0
  28. package/src/commands/spec-analyze.js +6 -4
  29. package/src/commands/store-system.js +329 -49
  30. package/src/constants.js +19 -6
  31. package/src/context-brief.js +585 -0
  32. package/src/context-guard.js +209 -0
  33. package/src/context-search.js +796 -96
  34. package/src/context-selector.js +802 -444
  35. package/src/handoff-contract.js +14 -6
  36. package/src/harness/contract-schema.js +1 -1
  37. package/src/i18n/messages/en.js +12 -5
  38. package/src/i18n/messages/es.js +11 -4
  39. package/src/i18n/messages/fr.js +11 -4
  40. package/src/i18n/messages/pt-BR.js +12 -5
  41. package/src/lib/ac-test-audit.js +194 -0
  42. package/src/preflight-engine.js +10 -6
  43. package/src/squad/state-manager.js +1 -1
  44. package/template/.aioson/agents/analyst.md +41 -17
  45. package/template/.aioson/agents/architect.md +4 -2
  46. package/template/.aioson/agents/briefing-refiner.md +15 -2
  47. package/template/.aioson/agents/briefing.md +12 -8
  48. package/template/.aioson/agents/committer.md +1 -1
  49. package/template/.aioson/agents/copywriter.md +20 -9
  50. package/template/.aioson/agents/design-hybrid-forge.md +9 -5
  51. package/template/.aioson/agents/dev.md +22 -25
  52. package/template/.aioson/agents/deyvin.md +126 -124
  53. package/template/.aioson/agents/discover.md +3 -1
  54. package/template/.aioson/agents/discovery-design-doc.md +11 -2
  55. package/template/.aioson/agents/forge-run.md +3 -0
  56. package/template/.aioson/agents/genome.md +9 -5
  57. package/template/.aioson/agents/neo.md +30 -24
  58. package/template/.aioson/agents/orache.md +10 -6
  59. package/template/.aioson/agents/orchestrator.md +4 -2
  60. package/template/.aioson/agents/pentester.md +22 -12
  61. package/template/.aioson/agents/pm.md +5 -3
  62. package/template/.aioson/agents/product.md +25 -18
  63. package/template/.aioson/agents/profiler-enricher.md +10 -6
  64. package/template/.aioson/agents/profiler-forge.md +10 -6
  65. package/template/.aioson/agents/profiler-researcher.md +10 -6
  66. package/template/.aioson/agents/qa.md +21 -19
  67. package/template/.aioson/agents/scope-check.md +9 -3
  68. package/template/.aioson/agents/sheldon.md +22 -8
  69. package/template/.aioson/agents/site-forge.md +2 -0
  70. package/template/.aioson/agents/squad.md +4 -2
  71. package/template/.aioson/agents/tester.md +19 -15
  72. package/template/.aioson/agents/ux-ui.md +16 -8
  73. package/template/.aioson/config.md +4 -3
  74. package/template/.aioson/design-docs/agent-loading-contract.md +3 -3
  75. package/template/.aioson/docs/autopilot-handoff.md +3 -3
  76. package/template/.aioson/docs/dev/simple-plan-lane.md +73 -27
  77. package/template/.aioson/docs/dev/stack-conventions.md +1 -1
  78. package/template/.aioson/docs/deyvin/continuity-recovery.md +1 -1
  79. package/template/.aioson/docs/deyvin/runtime-handoffs.md +3 -3
  80. package/template/.aioson/docs/feature-expansion-taxonomy.md +53 -0
  81. package/template/.aioson/docs/handoff-persistence.md +14 -12
  82. package/template/.aioson/docs/integrations/dashboard-app-form-publish-mapping.md +183 -0
  83. package/template/.aioson/docs/play/README.md +72 -0
  84. package/template/.aioson/docs/play/agent-usage-guide.md +106 -0
  85. package/template/.aioson/docs/play/app-compatibility-guide.md +112 -0
  86. package/template/.aioson/docs/play/auth-services-and-testing.md +220 -0
  87. package/template/.aioson/docs/play/llm-data-and-bindings.md +238 -0
  88. package/template/.aioson/docs/play/manifest-and-runtime.md +244 -0
  89. package/template/.aioson/docs/play/source-map.md +104 -0
  90. package/template/.aioson/docs/product/conversation-playbook.md +1 -1
  91. package/template/.aioson/docs/sheldon/enrichment-paths.md +44 -1
  92. package/template/.aioson/docs/sheldon/harness-contract.md +23 -21
  93. package/template/.aioson/docs/tester/coverage-quality.md +1 -1
  94. package/template/.aioson/docs/ux-ui/design-execution.md +9 -7
  95. package/template/.aioson/rules/README.md +35 -17
  96. package/template/.aioson/rules/agent-structural-contract.md +165 -160
  97. package/template/.aioson/rules/aioson-context-boundary.md +5 -4
  98. package/template/.aioson/rules/canonical-path-contract.md +5 -4
  99. package/template/.aioson/rules/data-format-convention.md +5 -4
  100. package/template/.aioson/rules/disk-first-artifacts.md +2 -2
  101. package/template/.aioson/rules/implementation-structure-and-data-access.md +50 -0
  102. package/template/.aioson/rules/security-baseline.md +4 -3
  103. package/template/.aioson/rules/simple-plan-lane.md +18 -6
  104. package/template/.aioson/rules/source-code-language-convention.md +34 -0
  105. package/template/.aioson/skills/process/aioson-spec-driven/references/artifact-map.md +24 -23
  106. package/template/.aioson/skills/process/aioson-spec-driven/references/classification-map.md +4 -0
  107. package/template/.aioson/skills/process/aioson-spec-driven/references/dev.md +2 -2
  108. package/template/.aioson/skills/process/aioson-spec-driven/references/qa.md +1 -1
  109. package/template/.aioson/skills/process/briefing-expansion-scout/SKILL.md +72 -0
  110. package/template/.aioson/skills/process/product-scope-expansion/SKILL.md +74 -0
  111. package/template/.aioson/skills/process/sheldon-expansion-audit/SKILL.md +67 -0
  112. package/template/.aioson/skills/static/context-budget-guide.md +1 -1
  113. package/template/.aioson/skills/static/multi-agent-patterns.md +5 -4
  114. package/template/AGENTS.md +36 -19
  115. 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
- * Enumerate every artefact that belongs to a feature slug — the exact surface
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
- try {
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="Session ended via ${agentName} hook" 2>/dev/null || true`;
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
- function buildClaudeHooks(agentName) {
45
- const emitCmd = makeEmitCommand(agentName, 'claude');
46
- const doneCmd = makeDoneCommand(agentName);
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
- return {
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 emitCmd = makeEmitCommand(agentName, 'antigravity');
116
- const doneCmd = makeDoneCommand(agentName);
117
- const startCmd = `aioson live:start "$PWD" --agent=${agentName} --tool=antigravity --no-launch 2>/dev/null || true`;
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=${agentName}
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="${agentName}"
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
- const agentName = options.agent ? String(options.agent).replace(/^@/, '') : 'dev';
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
- const agentName = options.agent ? String(options.agent).replace(/^@/, '') : 'dev';
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 = { runHooksInstall, runHooksUninstall };
482
+ module.exports = {
483
+ runHooksInstall,
484
+ runHooksUninstall,
485
+ buildClaudeHooks,
486
+ buildAntigravityHooks,
487
+ scrubAntigravityHookFile,
488
+ normalizeHookAgentName,
489
+ isAiosonHookEntry
490
+ };