omniconductor 0.5.0 → 1.0.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 (52) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +59 -19
  3. package/VISION.md +2 -2
  4. package/adapters/README.md +8 -8
  5. package/adapters/claude/README.md +3 -6
  6. package/adapters/claude/SUPPORTED-FEATURES.md +4 -4
  7. package/adapters/claude/metadata.json +39 -0
  8. package/adapters/claude/transform-spec.md +1 -1
  9. package/adapters/claude/transform.sh +208 -38
  10. package/adapters/codex/README.md +23 -27
  11. package/adapters/codex/metadata.json +35 -0
  12. package/adapters/codex/transform-spec.md +5 -6
  13. package/adapters/codex/transform.sh +284 -37
  14. package/adapters/copilot/README.md +26 -29
  15. package/adapters/copilot/metadata.json +36 -0
  16. package/adapters/copilot/transform.sh +126 -35
  17. package/adapters/cursor/README.md +31 -30
  18. package/adapters/cursor/metadata.json +35 -0
  19. package/adapters/cursor/transform.sh +117 -28
  20. package/adapters/gemini/README.md +14 -15
  21. package/adapters/gemini/metadata.json +36 -0
  22. package/adapters/gemini/transform.sh +312 -36
  23. package/adapters/windsurf/README.md +20 -19
  24. package/adapters/windsurf/metadata.json +36 -0
  25. package/adapters/windsurf/transform.sh +139 -55
  26. package/bin/doctor.js +257 -0
  27. package/bin/omniconductor.js +15 -2
  28. package/core/anti-patterns/frequent-rule-file-edit.md +1 -1
  29. package/core/anti-patterns/single-monolithic-rule-file.md +1 -1
  30. package/core/hooks/README.md +1 -0
  31. package/core/hooks/pretool-loop-guard.sh.template +177 -0
  32. package/core/recipes/README.md +6 -4
  33. package/core/recipes/loop-engineering.md +73 -0
  34. package/core/universal-rules/README.md +4 -4
  35. package/core/universal-rules/meta-discipline.md +4 -4
  36. package/core/universal-rules/spec-as-you-go.md +1 -1
  37. package/docs/ADAPTER-LIVE-VERIFICATION.md +73 -0
  38. package/docs/COMPARISON.md +133 -0
  39. package/docs/COMPATIBILITY-MATRIX.md +126 -0
  40. package/docs/DESIGN-DECISIONS.md +1268 -0
  41. package/docs/MANUAL-INSTALL.md +15 -15
  42. package/docs/PUBLICATION-POLICY.md +46 -0
  43. package/docs/PUBLISH-GUIDE.md +141 -0
  44. package/package.json +7 -1
  45. package/tools/check-adapter-metadata.sh +211 -0
  46. package/tools/check-stale-tokens.sh +130 -0
  47. package/tools/generate-adapter-docs.js +123 -0
  48. package/tools/live-verify.sh +195 -0
  49. package/tools/manifest-safety.sh +118 -0
  50. package/tools/stale-tokens.txt +32 -0
  51. package/tools/test-install-modes.sh +277 -0
  52. package/tools/validate-adapter-output.sh +1 -1
package/bin/doctor.js ADDED
@@ -0,0 +1,257 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * omniconductor doctor — read-only health check for an installed project (ADR-041).
5
+ *
6
+ * Anchors on <target>/.conductor-manifest.json and NEVER writes anything.
7
+ * The bash adapters remain the single source of truth for install logic
8
+ * (ADR-002/023/025) — doctor only inspects their output.
9
+ *
10
+ * Check groups:
11
+ * D1 manifest validity — exists, parses, has version + emitted_files
12
+ * D2 version drift — manifest version vs the running package version
13
+ * D3 file integrity — every manifest-emitted file still exists
14
+ * D4 stale legacy paths — adapter's legacy paths present in the target
15
+ * D5 hook validity — emitted .json parse; emitted .sh executable + `bash -n`
16
+ * D6 doc-link liveness — relative markdown links in emitted docs resolve
17
+ * D7 stale claims — emitted files scanned against tools/stale-tokens.txt
18
+ *
19
+ * Severity: FAIL = broken install · WARN = degraded/attention · OK.
20
+ * Exit codes: 0 = all OK · 1 = warnings only · 2 = failures (or unusable target).
21
+ */
22
+
23
+ const path = require('path');
24
+ const fs = require('fs');
25
+ const { spawnSync } = require('child_process');
26
+
27
+ const ROOT = path.resolve(__dirname, '..');
28
+
29
+ function readPkgVersion() {
30
+ try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version || null; }
31
+ catch { return null; }
32
+ }
33
+
34
+ function loadAdapterMetadata(adapter) {
35
+ try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'adapters', adapter, 'metadata.json'), 'utf8')); }
36
+ catch { return null; }
37
+ }
38
+
39
+ // tools/stale-tokens.txt: pattern \t reason \t hint \t allow_regex? (# = comment)
40
+ function loadStaleTokens() {
41
+ let raw;
42
+ try { raw = fs.readFileSync(path.join(ROOT, 'tools', 'stale-tokens.txt'), 'utf8'); }
43
+ catch { return []; }
44
+ const rules = [];
45
+ for (const line of raw.split('\n')) {
46
+ const l = line.replace(/\r$/, '');
47
+ if (!l || l.startsWith('#')) continue;
48
+ const [pattern, reason, hint, allow] = l.split('\t');
49
+ if (!pattern) continue;
50
+ rules.push({ pattern, reason: reason || '', hint: hint || '', allow: allow || null });
51
+ }
52
+ return rules;
53
+ }
54
+
55
+ function inferAdapter(manifest, targetAbs) {
56
+ if (manifest && typeof manifest.adapter === 'string') return manifest.adapter;
57
+ // Pre-0.8.0 claude manifests carry no adapter field — infer from footprint.
58
+ if (fs.existsSync(path.join(targetAbs, '.claude', 'rules'))) return 'claude';
59
+ return null;
60
+ }
61
+
62
+ function run(targetDir, opts) {
63
+ const json = !!(opts && opts.json);
64
+ const results = []; // {id, status: 'OK'|'WARN'|'FAIL', detail}
65
+ const add = (id, status, detail) => results.push({ id, status, detail });
66
+
67
+ const targetAbs = path.resolve(process.cwd(), targetDir || '.');
68
+ const finish = () => {
69
+ const counts = { OK: 0, WARN: 0, FAIL: 0 };
70
+ for (const r of results) counts[r.status]++;
71
+ if (json) {
72
+ process.stdout.write(JSON.stringify({
73
+ doctor: readPkgVersion(), target: targetAbs, checks: results, summary: counts,
74
+ }, null, 2) + '\n');
75
+ } else {
76
+ for (const r of results) {
77
+ const pad = r.status === 'OK' ? 'OK ' : (r.status === 'WARN' ? 'WARN' : 'FAIL');
78
+ process.stdout.write(`${pad} [${r.id}] ${r.detail}\n`);
79
+ }
80
+ process.stdout.write(`\n${counts.FAIL ? 'FAIL' : counts.WARN ? 'WARN' : 'OK'} — ${counts.OK} ok, ${counts.WARN} warn, ${counts.FAIL} fail (${targetAbs})\n`);
81
+ if (counts.FAIL || counts.WARN) {
82
+ process.stdout.write(`Re-install (safe: backups + manifest): npx omniconductor init --target=<tool> ${targetDir || '.'}\n`);
83
+ }
84
+ }
85
+ return counts.FAIL ? 2 : (counts.WARN ? 1 : 0);
86
+ };
87
+
88
+ if (!fs.existsSync(targetAbs) || !fs.statSync(targetAbs).isDirectory()) {
89
+ add('D1', 'FAIL', `target directory does not exist: ${targetAbs}`);
90
+ return finish();
91
+ }
92
+
93
+ // ---- D1 manifest validity ------------------------------------------------
94
+ const manifestPath = path.join(targetAbs, '.conductor-manifest.json');
95
+ let manifest = null;
96
+ if (!fs.existsSync(manifestPath)) {
97
+ add('D1', 'FAIL', `no .conductor-manifest.json in ${targetAbs} — not a CONDUCTOR install (or installed pre-manifest / uninstalled)`);
98
+ return finish();
99
+ }
100
+ try {
101
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
102
+ } catch (e) {
103
+ add('D1', 'FAIL', `.conductor-manifest.json does not parse: ${e.message}`);
104
+ return finish();
105
+ }
106
+ if (typeof manifest.version !== 'string' || !Array.isArray(manifest.emitted_files)) {
107
+ add('D1', 'FAIL', `.conductor-manifest.json missing 'version' or 'emitted_files'`);
108
+ return finish();
109
+ }
110
+ if (manifest.emitted_files.length === 0) {
111
+ add('D1', 'FAIL', `.conductor-manifest.json has an empty 'emitted_files' — a real install always tracks files (manifest damaged or hand-edited?)`);
112
+ return finish();
113
+ }
114
+ // Every tracked path must be a non-empty RELATIVE path that stays inside the
115
+ // target — a forged/merge-damaged manifest must not let doctor read (or later
116
+ // checks reason about) files outside the install root.
117
+ const badPaths = [];
118
+ for (const ef of manifest.emitted_files) {
119
+ const p = ef && ef.path;
120
+ if (typeof p !== 'string' || !p.trim() || path.isAbsolute(p)) { badPaths.push(String(p)); continue; }
121
+ const resolved = path.resolve(targetAbs, p);
122
+ if (resolved !== targetAbs && !resolved.startsWith(targetAbs + path.sep)) badPaths.push(p);
123
+ }
124
+ if (badPaths.length) {
125
+ add('D1', 'FAIL', `manifest contains ${badPaths.length} invalid/escaping path(s): ${badPaths.slice(0, 4).join(', ')}${badPaths.length > 4 ? ', …' : ''}`);
126
+ return finish();
127
+ }
128
+ add('D1', 'OK', `manifest valid — ${manifest.emitted_files.length} tracked files, recipes: ${(manifest.recipes_enabled || []).join(', ') || '(none)'}`);
129
+
130
+ const adapter = inferAdapter(manifest, targetAbs);
131
+ const meta = adapter ? loadAdapterMetadata(adapter) : null;
132
+
133
+ // ---- D2 version drift ------------------------------------------------------
134
+ const pkgVersion = readPkgVersion();
135
+ const installedVersion = String(manifest.version).replace(/^v/, '');
136
+ if (!pkgVersion) {
137
+ add('D2', 'WARN', 'cannot read the running package version');
138
+ } else if (installedVersion === 'unknown' || installedVersion === '') {
139
+ add('D2', 'WARN', `manifest has no usable version stamp ('${manifest.version}')`);
140
+ } else if (installedVersion !== pkgVersion) {
141
+ add('D2', 'WARN', `installed by v${installedVersion}, running CLI is v${pkgVersion} — re-run init to refresh (backups + manifest keep it safe)`);
142
+ } else {
143
+ add('D2', 'OK', `install version matches running CLI (v${pkgVersion})`);
144
+ }
145
+
146
+ // ---- D3 file integrity -----------------------------------------------------
147
+ const missing = [];
148
+ for (const ef of manifest.emitted_files) {
149
+ if (!ef || typeof ef.path !== 'string') continue;
150
+ if (!fs.existsSync(path.join(targetAbs, ef.path))) missing.push(ef.path);
151
+ }
152
+ if (missing.length) {
153
+ add('D3', 'FAIL', `${missing.length} manifest-tracked file(s) missing: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? ', …' : ''}`);
154
+ } else {
155
+ add('D3', 'OK', 'all manifest-tracked files exist');
156
+ }
157
+
158
+ // ---- D4 stale legacy paths ---------------------------------------------------
159
+ if (!adapter) {
160
+ add('D4', 'WARN', 'manifest has no adapter field and footprint is ambiguous — skipping legacy-path check');
161
+ } else if (!meta) {
162
+ add('D4', 'WARN', `no metadata for adapter '${adapter}' in this package — skipping legacy-path check`);
163
+ } else {
164
+ const emitted = new Set(manifest.emitted_files.map((e) => e && e.path).filter(Boolean));
165
+ const staleLegacy = (meta.legacy_paths || []).filter((lp) => {
166
+ if (fs.existsSync(path.join(targetAbs, lp)) === false) return false;
167
+ // Intentional legacy emissions are manifest-tracked (e.g. --legacy-cursorrules).
168
+ if (emitted.has(lp) || manifest.legacy_cursorrules === true && lp === '.cursorrules') return false;
169
+ return true;
170
+ });
171
+ if (staleLegacy.length) {
172
+ add('D4', 'WARN', `legacy path(s) present alongside the modern install: ${staleLegacy.join(', ')} — the tool may read both; consider removing after migrating content`);
173
+ } else {
174
+ add('D4', 'OK', `no stale legacy paths (adapter: ${adapter})`);
175
+ }
176
+ }
177
+
178
+ // ---- D5 hook validity ---------------------------------------------------------
179
+ let hookProblems = 0, hookChecked = 0;
180
+ let bashAvailable = true;
181
+ for (const ef of manifest.emitted_files) {
182
+ if (!ef || typeof ef.path !== 'string') continue;
183
+ const abs = path.join(targetAbs, ef.path);
184
+ if (!fs.existsSync(abs)) continue; // D3 already reported it
185
+ if (ef.path.endsWith('.json')) {
186
+ hookChecked++;
187
+ try { JSON.parse(fs.readFileSync(abs, 'utf8')); }
188
+ catch (e) { hookProblems++; add('D5', 'FAIL', `${ef.path} is not valid JSON: ${e.message}`); }
189
+ } else if (ef.path.endsWith('.sh')) {
190
+ hookChecked++;
191
+ try { fs.accessSync(abs, fs.constants.X_OK); }
192
+ catch { hookProblems++; add('D5', 'FAIL', `${ef.path} is not executable (chmod +x)`); }
193
+ if (bashAvailable) {
194
+ const r = spawnSync('bash', ['-n', abs], { stdio: 'pipe' });
195
+ if (r.error) { bashAvailable = false; add('D5', 'WARN', 'bash not available — skipping syntax checks'); }
196
+ else if (r.status !== 0) { hookProblems++; add('D5', 'FAIL', `${ef.path} has a bash syntax error`); }
197
+ }
198
+ }
199
+ }
200
+ if (hookProblems === 0) add('D5', 'OK', `hook/config surfaces sane (${hookChecked} .json/.sh file(s) checked)`);
201
+
202
+ // ---- D6 doc-link liveness -------------------------------------------------------
203
+ let deadLinks = 0, docsChecked = 0;
204
+ const linkRe = /\[[^\]]*\]\(([^)\s]+)\)/g;
205
+ for (const ef of manifest.emitted_files) {
206
+ if (!ef || typeof ef.path !== 'string' || !ef.path.endsWith('.md')) continue;
207
+ const abs = path.join(targetAbs, ef.path);
208
+ if (!fs.existsSync(abs)) continue;
209
+ docsChecked++;
210
+ const src = fs.readFileSync(abs, 'utf8');
211
+ let m;
212
+ while ((m = linkRe.exec(src)) !== null) {
213
+ const href = m[1];
214
+ if (/^(https?:|mailto:|#)/.test(href)) continue;
215
+ const dest = path.resolve(path.dirname(abs), href.split('#')[0]);
216
+ if (!fs.existsSync(dest)) {
217
+ deadLinks++;
218
+ if (deadLinks <= 5) add('D6', 'WARN', `${ef.path}: dead relative link → ${href}`);
219
+ }
220
+ }
221
+ }
222
+ if (deadLinks === 0) add('D6', 'OK', `relative links resolve in ${docsChecked} emitted doc(s)`);
223
+ else if (deadLinks > 5) add('D6', 'WARN', `…and ${deadLinks - 5} more dead link(s)`);
224
+
225
+ // ---- D7 stale claims ---------------------------------------------------------------
226
+ const rules = loadStaleTokens();
227
+ if (!rules.length) {
228
+ add('D7', 'WARN', 'tools/stale-tokens.txt not found in this package — skipping stale-claim scan');
229
+ } else {
230
+ let staleHits = 0;
231
+ for (const ef of manifest.emitted_files) {
232
+ if (!ef || typeof ef.path !== 'string') continue;
233
+ if (!/\.(md|sh|mdc|json|toml)$/.test(ef.path) && !/(^|\/)\.(windsurfrules|cursorrules)$/.test(ef.path)) continue;
234
+ const abs = path.join(targetAbs, ef.path);
235
+ if (!fs.existsSync(abs)) continue;
236
+ const lines = fs.readFileSync(abs, 'utf8').split('\n');
237
+ for (const rule of rules) {
238
+ let allowRe = null;
239
+ if (rule.allow) { try { allowRe = new RegExp(rule.allow); } catch { allowRe = null; } }
240
+ for (const line of lines) {
241
+ if (!line.includes(rule.pattern)) continue;
242
+ if (line.includes('stale-ok:')) continue;
243
+ if (allowRe && allowRe.test(line)) continue;
244
+ staleHits++;
245
+ if (staleHits <= 5) add('D7', 'WARN', `${ef.path}: stale claim '${rule.pattern}' (${rule.reason}) — re-install to refresh`);
246
+ break; // one report per rule per file
247
+ }
248
+ }
249
+ }
250
+ if (staleHits === 0) add('D7', 'OK', 'no known-stale claims in emitted files');
251
+ else if (staleHits > 5) add('D7', 'WARN', `…and ${staleHits - 5} more stale claim(s)`);
252
+ }
253
+
254
+ return finish();
255
+ }
256
+
257
+ module.exports = { run };
@@ -7,13 +7,15 @@
7
7
  * Usage:
8
8
  * omniconductor init --target=<tool> [target-dir] [--recipes=a,b] [--dry-run] [--no-prompt]
9
9
  * omniconductor init --target=<tool> [target-dir] --uninstall [--force]
10
+ * omniconductor doctor [target-dir] [--json]
10
11
  * omniconductor list
11
12
  * omniconductor --help | --version
12
13
  *
13
14
  * It does NOT reimplement any install logic. It locates this repo's
14
15
  * adapters/<tool>/transform.sh and runs it with `bash`, forwarding all flags
15
16
  * and inheriting stdio. The shell adapters remain the single source of truth
16
- * (ADR-018 / the bash adapters are the validated implementation).
17
+ * (ADR-002/023/025 the bash adapters are the validated implementation).
18
+ * `doctor` (ADR-041) is read-only: it inspects an install, never changes it.
17
19
  */
18
20
 
19
21
  const path = require('path');
@@ -36,6 +38,7 @@ function usage() {
36
38
 
37
39
  Usage:
38
40
  omniconductor init --target=<tool> [target-dir] [options] Install into target-dir (default: .)
41
+ omniconductor doctor [target-dir] [--json] Health-check an existing install (read-only)
39
42
  omniconductor list List available tool adapters
40
43
  omniconductor --help | --version
41
44
 
@@ -43,6 +46,8 @@ Tools: ${TOOLS.join(', ')}
43
46
 
44
47
  Common options (forwarded to the adapter):
45
48
  --recipes=a,b,c Opt-in recipes to install
49
+ --mode=<m> Install preset: full (default) | minimal | strict |
50
+ recipes-only | reflector-only (ADR-044)
46
51
  --dry-run Preview only — write nothing
47
52
  --no-prompt Skip interactive prompts (CI-safe)
48
53
  --uninstall Revert a previous install (manifest-based)
@@ -52,6 +57,7 @@ Examples:
52
57
  omniconductor init --target=claude ./my-app --recipes=tdd,debugging
53
58
  omniconductor init --target=cursor ./my-app --dry-run
54
59
  omniconductor init --target=codex . --uninstall
60
+ omniconductor doctor ./my-app --json
55
61
 
56
62
  Run: npx omniconductor init --target=<tool> <dir>`;
57
63
  }
@@ -85,8 +91,15 @@ function main(argv) {
85
91
  return 0;
86
92
  }
87
93
 
94
+ if (cmd === 'doctor') {
95
+ const rest = args.slice(1);
96
+ const jsonOut = rest.includes('--json');
97
+ const dir = rest.find((a) => !a.startsWith('-')) || '.';
98
+ return require('./doctor.js').run(dir, { json: jsonOut });
99
+ }
100
+
88
101
  if (cmd !== 'init') {
89
- fail(`unknown command '${cmd}'. Expected 'init' or 'list'.`);
102
+ fail(`unknown command '${cmd}'. Expected 'init', 'doctor', or 'list'.`);
90
103
  }
91
104
 
92
105
  // Parse `init` args: extract --target, the positional target-dir, forward the rest.
@@ -54,7 +54,7 @@ If the rule file is edited daily, every session starts with a fresh cache write
54
54
  ```bash
55
55
  # Edits per week to top-of-prefix files
56
56
  git log --since='1 week ago' --pretty=format:'%h %s' -- \
57
- CLAUDE.md AGENT.md GEMINI.md .codex/codex.md \
57
+ CLAUDE.md AGENT.md AGENTS.md GEMINI.md \
58
58
  .claude/rules/*.md .cursor/rules/*.mdc 2>/dev/null | wc -l
59
59
 
60
60
  # Anything > 3 in a week is suspect
@@ -52,7 +52,7 @@ Two corollaries:
52
52
 
53
53
  ```bash
54
54
  # Find oversized rule files
55
- wc -l CLAUDE.md AGENT.md GEMINI.md .codex/codex.md 2>/dev/null | \
55
+ wc -l CLAUDE.md AGENT.md AGENTS.md GEMINI.md 2>/dev/null | \
56
56
  awk '$1 > 500 { print }'
57
57
  ```
58
58
 
@@ -15,6 +15,7 @@ CONDUCTOR's universal hook spec. Templates here are compiled into native shell s
15
15
  | `stop-cache-hit-baseline-check.sh.template` | Session stop event | Non-blocking diagnostic — reads the latest session JSONL, computes cache hit rate, reminds when below baseline (token-economy). Fail-open. Override env disables: `CONDUCTOR_SKIP_CACHE_CHECK=1`. | Claude `.claude/hooks/stop-cache-hit-baseline-check.sh` |
16
16
  | `stop-trajectory-log.sh.template` | Session stop event | Non-blocking — reads `transcript_path` + `session_id` from the Stop hook's **stdin** (exact provenance; no `~/.claude/projects` dir-scan) and **upserts** one pointer record per session (session id, transcript path, git HEAD, cwd) into `.conductor/trajectories/index.jsonl` for the Reflector (recipes/self-improvement.md). Same stdin approach as the non-Claude portable logger `core/reflector/trajectory-log.sh`. **Opt-in gated: no-ops unless `.conductor/reflect/` exists** (created only by the self-improvement recipe). Anchors to the project root; fail-open. Override: `CONDUCTOR_SKIP_TRAJLOG=1`. | Claude `.claude/hooks/stop-trajectory-log.sh` |
17
17
  | `stop-git-hygiene-guard.sh.template` | Session stop event | Non-blocking — detects git-hygiene collapse (extra worktrees, local-only commits not on any remote, abnormally many local branches) and injects a cleanup reminder per `recipes/git-hygiene.md` (G1/G2/G3/G7). **Opt-in gated: no-ops unless `.claude/rules/git-hygiene.md` exists** (created only by the git-hygiene recipe). Anchors to the project root; 15-min cool-down; fail-open, always exits 0. Overrides: `CONDUCTOR_SKIP_GIT_HYGIENE=1`, `CONDUCTOR_GIT_HYGIENE_BRANCH_MAX` (default 20). | Claude `.claude/hooks/stop-git-hygiene-guard.sh` |
18
+ | `pretool-loop-guard.sh.template` | Before each tool call (PreToolUse, `*` matcher) | Non-blocking soft-warn (`permissionDecision: ask`) — tracks a per-session signature of each tool call and surfaces a reminder when the **same action repeats** ≥ `CONDUCTOR_LOOP_REPEAT_MAX` (default 5; oscillation/no-progress) or the session's **total tool calls** ≥ `CONDUCTOR_LOOP_BUDGET` (default 120; runaway), per `recipes/loop-engineering.md` (G2/G3/G6). **Opt-in gated: no-ops unless `.claude/rules/loop-engineering.md` exists.** Per-session cool-down (`CONDUCTOR_LOOP_COOLDOWN_SECONDS`, default 120); trace in `$TMPDIR`, not the repo; fail-open, always exits 0. Override: `CONDUCTOR_SKIP_LOOP_GUARD=1`. | Claude `.claude/hooks/pretool-loop-guard.sh` |
18
19
 
19
20
  The two `pretool-commit-*` templates are **soft `ask` warns**: they emit
20
21
  `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":...}}`,
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # CONDUCTOR universal hook template
4
+ # hook_id: pretool-loop-guard
5
+ # trigger: pretool
6
+ # action: Before each tool call, track a per-session signature of the call and
7
+ # soft-warn (non-blocking "ask") when the SAME action repeats too often
8
+ # (oscillation / no-progress loop) or the session's total tool calls
9
+ # exceed a budget (runaway loop). Enforces the loop-engineering recipe.
10
+ # compile_targets: claude
11
+ # fallback: rule-text-reminder
12
+ #
13
+ # Generated by adapters/<tool>/transform.sh.
14
+ #
15
+ # Enforces:
16
+ # - recipes/loop-engineering.md (bound the loop: iteration budget G2, no-progress
17
+ # halt G3, oscillation/infinite-loop guard G6)
18
+ #
19
+ # Behavior (fail-open, non-blocking):
20
+ # 1. No-op unless the loop-engineering recipe is installed (self-gate on
21
+ # .claude/rules/loop-engineering.md).
22
+ # 2. Per session, keep a trace of tool-call signatures in a private tmp file.
23
+ # A "signature" = tool_name + hash(tool_input): identical calls collide.
24
+ # 3. Warn (permissionDecision:ask + reason) when the CURRENT signature has
25
+ # occurred >= REPEAT_MAX times (same action repeated — likely looping without
26
+ # progress), OR the session's total tool calls >= BUDGET (runaway). A per-
27
+ # session cool-down keeps it from firing on every subsequent call.
28
+ # 4. ALWAYS exit 0. Any error, missing input, or missing python3 → allow silently.
29
+ # "ask" surfaces the reason and routes to the permission flow — it does NOT
30
+ # hard-block and does NOT auto-approve (same contract as the other pretool
31
+ # soft-warns).
32
+ #
33
+ # Overrides (runtime env, not compile-time placeholders):
34
+ # CONDUCTOR_SKIP_LOOP_GUARD=1 disable entirely
35
+ # CONDUCTOR_LOOP_REPEAT_MAX same-action repeat threshold (default 5)
36
+ # CONDUCTOR_LOOP_BUDGET total tool-calls-per-session threshold (default 120)
37
+ # CONDUCTOR_LOOP_COOLDOWN_SECONDS min seconds between warns per session (default 120)
38
+
39
+ set -u
40
+
41
+ # Honor opt-out before any work.
42
+ [ "${CONDUCTOR_SKIP_LOOP_GUARD:-}" = "1" ] && exit 0
43
+
44
+ # Anchor to the project root (emitted hook lives at <project>/.claude/hooks/).
45
+ _self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" || exit 0
46
+ _proj_root="$(cd "$_self_dir/../.." 2>/dev/null && pwd)" || exit 0
47
+ cd "$_proj_root" 2>/dev/null || exit 0
48
+
49
+ # Self-gate: only act when the loop-engineering recipe is installed.
50
+ [ -f ".claude/rules/loop-engineering.md" ] || exit 0
51
+
52
+ # Robust JSON handling needs python3; without it, degrade to silent allow.
53
+ command -v /usr/bin/python3 >/dev/null 2>&1 || exit 0
54
+
55
+ # Read the PreToolUse payload from stdin (no piped stdin → nothing to do).
56
+ export PAYLOAD_RAW="$(/bin/cat 2>/dev/null || true)"
57
+ [ -n "${PAYLOAD_RAW:-}" ] || exit 0
58
+
59
+ # All logic lives in python for safe parsing / hashing / file IO. It prints EITHER
60
+ # the ask-JSON (when a threshold is crossed and not cooling down) or nothing. Any
61
+ # exception prints nothing → the shell falls through to `exit 0` (allow).
62
+ REPEAT_MAX="${CONDUCTOR_LOOP_REPEAT_MAX:-5}" \
63
+ BUDGET="${CONDUCTOR_LOOP_BUDGET:-120}" \
64
+ COOLDOWN="${CONDUCTOR_LOOP_COOLDOWN_SECONDS:-120}" \
65
+ /usr/bin/python3 -c '
66
+ import json, os, sys, hashlib, re, time, tempfile
67
+
68
+ def out_nothing():
69
+ sys.exit(0)
70
+
71
+ try:
72
+ raw = os.environ.get("PAYLOAD_RAW", "") or ""
73
+ d = json.loads(raw)
74
+ if not isinstance(d, dict):
75
+ out_nothing()
76
+
77
+ tool = d.get("tool_name", "") or ""
78
+ if not tool:
79
+ out_nothing() # no identifiable action → nothing to track
80
+
81
+ tool_input = d.get("tool_input", {})
82
+ session = d.get("session_id", "") or d.get("sessionId", "") or "nosession"
83
+
84
+ # Sanitize identifiers used in file paths (defense against path injection).
85
+ session = re.sub(r"[^A-Za-z0-9_-]", "", str(session))[:64] or "nosession"
86
+ user = re.sub(r"[^A-Za-z0-9_-]", "", os.environ.get("USER", "") or "unknown")[:32] or "unknown"
87
+
88
+ # Thresholds (env-tunable, with safe fallbacks on garbage input).
89
+ def envint(name, default):
90
+ try:
91
+ v = int(os.environ.get(name, "") or default)
92
+ return v if v > 0 else default
93
+ except Exception:
94
+ return default
95
+ repeat_max = envint("REPEAT_MAX", 5)
96
+ budget = envint("BUDGET", 120)
97
+ cooldown = envint("COOLDOWN", 120)
98
+
99
+ # Signature = tool + stable hash of its input. Identical calls collide;
100
+ # a canonical (sorted-key) dump makes key order irrelevant.
101
+ try:
102
+ payload = json.dumps(tool_input, sort_keys=True, ensure_ascii=True, default=str)
103
+ except Exception:
104
+ payload = repr(tool_input)
105
+ sig = tool + ":" + hashlib.sha1(payload.encode("utf-8", "replace")).hexdigest()[:16]
106
+
107
+ tmp = tempfile.gettempdir()
108
+ base = os.path.join(tmp, "conductor-loop-%s-%s" % (user, session))
109
+ trace_path = base + ".trace"
110
+ cooldown_path = base + ".cooldown"
111
+
112
+ # Append this signature, then read the (bounded) trace back.
113
+ try:
114
+ with open(trace_path, "a", encoding="utf-8") as f:
115
+ f.write(sig + "\n")
116
+ except Exception:
117
+ out_nothing() # cannot persist → cannot reason about the loop; allow.
118
+
119
+ try:
120
+ with open(trace_path, "r", encoding="utf-8") as f:
121
+ lines = f.read().splitlines()
122
+ except Exception:
123
+ out_nothing()
124
+
125
+ # Bound work: only the last N entries matter for a runaway/oscillation view.
126
+ if len(lines) > 5000:
127
+ lines = lines[-5000:]
128
+
129
+ total = len(lines)
130
+ same = sum(1 for x in lines if x == sig)
131
+
132
+ hit_repeat = same >= repeat_max
133
+ hit_budget = total >= budget
134
+ if not (hit_repeat or hit_budget):
135
+ out_nothing()
136
+
137
+ # Cool-down: at most one warn per `cooldown` seconds per session.
138
+ now = int(time.time())
139
+ try:
140
+ with open(cooldown_path, "r", encoding="utf-8") as f:
141
+ last = int((f.read().strip() or "0"))
142
+ except Exception:
143
+ last = 0
144
+ if now - last < cooldown:
145
+ out_nothing()
146
+ try:
147
+ with open(cooldown_path, "w", encoding="utf-8") as f:
148
+ f.write(str(now))
149
+ except Exception:
150
+ pass # cool-down is best-effort; still warn this once.
151
+
152
+ if hit_repeat:
153
+ reason = ("[loop-guard] The same action (%s) has run %d times this session — "
154
+ "you may be looping without progress (recipe G3/G6). Before repeating: "
155
+ "confirm each iteration changes state, verify with an EXTERNAL check "
156
+ "(tests/rules/tool output — not self-judgment), and if stuck after a few "
157
+ "tries, stop and ask rather than loop (G4). " % (tool, same))
158
+ else:
159
+ reason = ("[loop-guard] %d tool calls this session — approaching a runaway loop "
160
+ "(recipe G2 iteration budget). Confirm you are converging on an explicit "
161
+ "done-criterion, not spinning. " % total)
162
+ reason += "(Silence: CONDUCTOR_SKIP_LOOP_GUARD=1; tune CONDUCTOR_LOOP_REPEAT_MAX / CONDUCTOR_LOOP_BUDGET.)"
163
+
164
+ print(json.dumps({
165
+ "hookSpecificOutput": {
166
+ "hookEventName": "PreToolUse",
167
+ "permissionDecision": "ask",
168
+ "permissionDecisionReason": reason,
169
+ }
170
+ }))
171
+ except SystemExit:
172
+ raise
173
+ except Exception:
174
+ # Absolutely never fail a tool call because of this guard.
175
+ pass
176
+ '
177
+ exit 0
@@ -2,7 +2,7 @@
2
2
 
3
3
  Per ADR-013, CONDUCTOR ships project-specific recipes as OPT-IN. They are not loaded by default. Adopters select the recipes that match their project and the adapter wires them into the appropriate native location.
4
4
 
5
- ## The 12 recipes
5
+ ## The 13 recipes
6
6
 
7
7
  | File | When to install |
8
8
  |---|---|
@@ -18,6 +18,7 @@ Per ADR-013, CONDUCTOR ships project-specific recipes as OPT-IN. They are not lo
18
18
  | `design-system.md` | Project maintains a design-token system (color/spacing/typography tokens). Ships 1 recipe-scoped hookify rule (raw-hex-instead-of-token) — see ADR-028 |
19
19
  | `self-improvement.md` | Project wants a periodic, human-approved Reflector that distils session lessons into memory/rules. Propose-only; nothing auto-applies. Drives the `reflector` role — see ADR-030 |
20
20
  | `git-hygiene.md` | Any git project — esp. repos worked by multiple sessions/agents or with protected branches. Shared-repo discipline (no orphan worktrees, push-don't-hoard, merge=delete-branch, backup≠applied). Ships a Claude-only Stop-hook reminder (`stop-git-hygiene-guard`); other tools use the rule text — see ADR-037 |
21
+ | `loop-engineering.md` | Any agentic loop (generate→verify→fix→re-verify, test-fix, multi-step). Bounded, externally-verified loops: explicit done-criterion, iteration+token budget, require-progress, escalate-on-stall, verify-externally-not-self-judgment, oscillation guard. Ships a Claude-only PreToolUse reminder (`pretool-loop-guard`); other tools use the rule text — see ADR-038 |
21
22
 
22
23
  ## Selection patterns
23
24
 
@@ -28,8 +29,9 @@ Per ADR-013, CONDUCTOR ships project-specific recipes as OPT-IN. They are not lo
28
29
  | Multi-locale SaaS | `i18n` + `coding-conventions` + `tdd` + `debugging` |
29
30
  | Relational-DB-backed SaaS (migrations + dev/prod) | `database-discipline` + `coding-conventions` + `tdd` + `debugging` |
30
31
  | Token-driven design system (theming / dark-mode) | `design-system` + `coding-conventions` + `tdd` + `debugging` |
31
- | Full-stack SaaS with web + mobile + i18n | All 12 |
32
+ | Full-stack SaaS with web + mobile + i18n | All 13 |
32
33
  | Any git repo, esp. shared / multi-session | add `git-hygiene` to any of the above |
34
+ | Agentic / iterative loops (fix-verify, test-fix) | add `loop-engineering` to any of the above |
33
35
  | Greenfield experiment | None — universal-rules + roles only is enough |
34
36
 
35
37
  ## How adapters consume these files
@@ -42,7 +44,7 @@ Adapter `transform.sh` accepts a `--recipes=<comma-separated-list>` flag (or per
42
44
  | Cursor | `.cursor/rules/<recipe>.mdc` |
43
45
  | Copilot | `.github/instructions/<recipe>.instructions.md` |
44
46
  | Gemini | Section in `GEMINI.md` |
45
- | Codex | Section in `.codex/codex.md` |
46
- | Windsurf | `.windsurf/rules/<recipe>.md` |
47
+ | Codex | Section in `AGENTS.md` |
48
+ | Windsurf | `.devin/rules/<recipe>.md` (legacy `.windsurf/rules/` still read) |
47
49
 
48
50
  Recipes are layered on TOP of universal-rules. They never override; they extend.
@@ -0,0 +1,73 @@
1
+ ---
2
+ recipe_id: loop-engineering
3
+ recipe_name: "Loop Engineering (bounded, externally-verified agent loops)"
4
+ applies_when: "any agentic coding work that iterates — generate→verify→fix→re-verify, test-fix loops, multi-step tasks"
5
+ severity: STRONG (when installed)
6
+ linked_rules:
7
+ - quality-gates
8
+ - meta-discipline
9
+ - workflow
10
+ ---
11
+
12
+ # Recipe — Loop Engineering
13
+
14
+ > Opt-in recipe. Install for any project where the agent works in a **loop** — "do → check → fix → re-check until done." It codifies how to run that loop so it **terminates correctly, stays bounded, and never declares success without an external check.** Install if you want agent loops that are reliable instead of ones that thrash, run away, or report "done" on unverified work.
15
+
16
+ ## Why this exists
17
+
18
+ An agent that loops "until it's right" fails in a few well-documented ways. The evidence (peer-reviewed + Anthropic primary sources) is blunt:
19
+
20
+ - **Self-correction without an external signal is unreliable and can make things WORSE.** Models asked to fix their own work with no external feedback often flip correct answers to wrong ones (*LLMs Cannot Self-Correct Reasoning Yet*, Huang et al., DeepMind, ICLR'24). The real gains come from loops grounded in **tests/tools** (Reflexion, CRITIC), not self-judgment.
21
+ - **"The model says it's done" is not evidence.** LLM self-assessment is systematically over-confident, and LLM-as-judge carries position/verbosity/self-preference bias (*Judging LLM-as-a-Judge*, Zheng et al., NeurIPS'23). Declaring victory without running the check ("early victory") is the most common fidelity failure.
22
+ - **Unbounded loops run away.** Infinite / oscillation (edit↔revert) loops are a documented structural failure — in one study **95.6% ended in cost exhaustion / model-DoS** (*When Agents Do Not Stop*, 2026). More iterations help only up to a point, then *saturate and hurt*; long trajectories degrade (context rot, error compounding).
23
+
24
+ So a good loop is **bounded, progress-checked, and terminated by an external verifier** — not by the model's opinion. Prose gets forgotten, so on the Claude adapter this recipe is backed by a `pretool-loop-guard` hook.
25
+
26
+ ## The loop shape (well-supported)
27
+
28
+ ```
29
+ Plan → (act → verify) loop → replan on failure → escalate on repeated stall
30
+ ```
31
+
32
+ - **Plan first**, then act (ReAct: interleave reason+act+observe beats act-only).
33
+ - On a failed check, **reflect and replan** using the failure (Reflexion) — not a blind retry.
34
+ - Reserve expensive search/branching (Tree-of-Thoughts) for genuinely exploratory tasks; the default loop stays simple (Anthropic: "the simplest solution possible; add complexity only when it demonstrably improves outcomes").
35
+
36
+ ## The 6 obligations
37
+
38
+ ### G1 — Terminate on an explicit, verified done-criterion
39
+ Before looping, state what "done" IS (a passing test, a matching value, a satisfied spec item). The loop ends when that criterion is **verified true** — never on a vibe or a word-count of effort.
40
+
41
+ ### G2 — Bound the loop (iteration + token budget)
42
+ Set a ceiling before you start: a max number of iterations AND a token/time budget. When the ceiling is hit, **stop and report** (with what's done and what's left) — do not silently keep going. Anthropic ships this as `max_turns` / `max_budget_usd` ("a good default for production agents").
43
+
44
+ ### G3 — Require progress each iteration
45
+ Every pass must change state toward the goal. If an iteration produces no new information or the same result, that is a **no-progress signal** — stop and rethink rather than repeat. (More iterations past the point of progress saturate, then degrade quality.)
46
+
47
+ ### G4 — Escalate on stall, don't loop forever
48
+ After a small number of failed/no-progress iterations (default ~3–5), **hand back to the human** with the state and the blocker, instead of looping. Ties to `meta-discipline.md` AMB — when stuck or the next step is non-trivially reversible, ASK.
49
+
50
+ ### G5 — Verify externally, never by self-judgment (the core rule)
51
+ A loop's exit signal MUST be an **external / ground-truth check** — run the test, execute the code, lint against the rule, diff the value — **not** the model asserting "looks correct." Verify hierarchy (Anthropic, strongest first):
52
+
53
+ 1. **Rules / tests / tool output** — deterministic, best (e.g. run the test suite; lint TS instead of eyeballing).
54
+ 2. **Visual / rendered feedback** — screenshots, output diffs.
55
+ 3. **LLM-as-judge** — weakest, last resort, and never the sole gate.
56
+
57
+ This is `quality-gates.md` Q4 (verify-after-changes) applied inside the loop. "Declared done without running the check" = the early-victory anti-pattern; it is a rule violation here.
58
+
59
+ ### G6 — Guard against oscillation / infinite loops
60
+ Detect and break repetition: the same action repeated, or an edit↔revert cycle, means the loop is spinning. Track what you've already tried; if you're repeating, **change approach or escalate (G4)** — never keep re-issuing the same failing action.
61
+
62
+ ## Conductor Integration
63
+
64
+ - **Claude** — a PreToolUse hook `pretool-loop-guard` (from `core/hooks/`) tracks a per-session signature of each tool call and fires a **non-blocking soft-warn** (`permissionDecision: ask`) when the **same action repeats too often** (G3/G6 — likely looping without progress) or the **session's total tool calls exceed a budget** (G2 — runaway). It self-gates on this recipe being installed, is fail-open (never blocks a tool call on error), and honors `CONDUCTOR_SKIP_LOOP_GUARD=1`, `CONDUCTOR_LOOP_REPEAT_MAX` (default 5), `CONDUCTOR_LOOP_BUDGET` (default 120), `CONDUCTOR_LOOP_COOLDOWN_SECONDS` (default 120).
65
+ - **Cursor / Copilot / Gemini / Codex / Windsurf** — the hook is Claude-only (per `docs/DESIGN-DECISIONS.md` ADR-034/ADR-038). On these tools this recipe's rule text is the enforcement: follow G1–G6 by discipline (the loop shape + external-verify rule are tool-agnostic).
66
+
67
+ ## Cross-References
68
+
69
+ - `quality-gates.md` §4 (verify-after-changes) — G5's external-verify rule is Q4 applied inside the loop.
70
+ - `meta-discipline.md` §3 (AMB) — G4's escalate-on-stall / ASK-when-stuck gate.
71
+ - `meta-discipline.md` §5.9 (output brevity) + §5.7 — G2's token budget shares the token-economy ceiling.
72
+ - `tdd.md` / `debugging.md` recipes — concrete instances of a G1–G6 loop (Red-Green; reproduce→root-cause→fix→regression).
73
+ - `self-improvement.md` recipe — the session-level Reflector loop is itself a G1–G6 instance (propose → human-verify → apply).
@@ -52,11 +52,11 @@ Adapters do not strip these callouts. The honest acknowledgment of degraded enfo
52
52
  | Adapter | Output |
53
53
  |---|---|
54
54
  | Claude | One file per bundle under `.claude/rules/` with `paths:` frontmatter. Universal bundles also referenced from `CLAUDE.md`. |
55
- | Cursor | One `.mdc` per bundle under `.cursor/rules/` with `globs:` frontmatter. Bundles summarized in `.cursorrules`. |
56
- | Copilot | One `.instructions.md` per bundle under `.github/instructions/` with `applyTo:` frontmatter. |
55
+ | Cursor | One `.mdc` per bundle under `.cursor/rules/` (`alwaysApply: true`; recipes get `globs:`). Optional legacy `.cursorrules` bundle via `--legacy-cursorrules`. |
56
+ | Copilot | All bundles merged into `.github/copilot-instructions.md` (default) or one `.instructions.md` per bundle under `.github/instructions/` with `--per-rule`. |
57
57
  | Gemini | All bundles concatenated into `GEMINI.md`, sectioned. |
58
- | Codex | All bundles concatenated into `.codex/codex.md`. |
59
- | Windsurf | One file per bundle under `.windsurf/rules/`. |
58
+ | Codex | All bundles concatenated into `AGENTS.md` (project root). |
59
+ | Windsurf | One file per bundle under `.devin/rules/` (legacy `.windsurf/rules/` still read). |
60
60
 
61
61
  See `adapters/<tool>/transform-spec.md` for the exact transformation per adapter.
62
62