omniconductor 0.6.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 (48) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +16 -12
  3. package/VISION.md +2 -2
  4. package/adapters/README.md +8 -8
  5. package/adapters/claude/README.md +2 -5
  6. package/adapters/claude/SUPPORTED-FEATURES.md +1 -1
  7. package/adapters/claude/metadata.json +39 -0
  8. package/adapters/claude/transform.sh +198 -34
  9. package/adapters/codex/README.md +23 -27
  10. package/adapters/codex/metadata.json +35 -0
  11. package/adapters/codex/transform-spec.md +5 -6
  12. package/adapters/codex/transform.sh +282 -35
  13. package/adapters/copilot/README.md +26 -29
  14. package/adapters/copilot/metadata.json +36 -0
  15. package/adapters/copilot/transform.sh +124 -33
  16. package/adapters/cursor/README.md +31 -30
  17. package/adapters/cursor/metadata.json +35 -0
  18. package/adapters/cursor/transform.sh +115 -26
  19. package/adapters/gemini/README.md +14 -15
  20. package/adapters/gemini/metadata.json +36 -0
  21. package/adapters/gemini/transform.sh +310 -34
  22. package/adapters/windsurf/README.md +20 -19
  23. package/adapters/windsurf/metadata.json +36 -0
  24. package/adapters/windsurf/transform.sh +137 -53
  25. package/bin/doctor.js +257 -0
  26. package/bin/omniconductor.js +15 -2
  27. package/core/anti-patterns/frequent-rule-file-edit.md +1 -1
  28. package/core/anti-patterns/single-monolithic-rule-file.md +1 -1
  29. package/core/recipes/README.md +2 -2
  30. package/core/universal-rules/README.md +4 -4
  31. package/core/universal-rules/meta-discipline.md +4 -4
  32. package/core/universal-rules/spec-as-you-go.md +1 -1
  33. package/docs/ADAPTER-LIVE-VERIFICATION.md +73 -0
  34. package/docs/COMPARISON.md +133 -0
  35. package/docs/COMPATIBILITY-MATRIX.md +126 -0
  36. package/docs/DESIGN-DECISIONS.md +1268 -0
  37. package/docs/MANUAL-INSTALL.md +15 -15
  38. package/docs/PUBLICATION-POLICY.md +46 -0
  39. package/docs/PUBLISH-GUIDE.md +141 -0
  40. package/package.json +7 -1
  41. package/tools/check-adapter-metadata.sh +211 -0
  42. package/tools/check-stale-tokens.sh +130 -0
  43. package/tools/generate-adapter-docs.js +123 -0
  44. package/tools/live-verify.sh +195 -0
  45. package/tools/manifest-safety.sh +118 -0
  46. package/tools/stale-tokens.txt +32 -0
  47. package/tools/test-install-modes.sh +277 -0
  48. package/tools/validate-adapter-output.sh +1 -1
@@ -24,13 +24,14 @@
24
24
  # core/recipes/*.md (selected) → <target>/.devin/rules/*.md (front-matter stripped)
25
25
  # core/docs-templates/*.md → <target>/docs/*.md (CURRENT_WORK, REMAINING_TASKS, etc.)
26
26
  # core/hooks/*.sh.template → SKIPPED (Reflector hook emitted via --recipes=self-improvement, ADR-032; other guards Claude-only, ADR-034)
27
- # core/roles/*.md → SKIPPED (Windsurf has no sub-agent dispatch)
27
+ # core/roles/*.md → SKIPPED (role emission is Claude-only today; Windsurf supports sub-agents natively — ADR-031)
28
28
  # adapters/claude/hookify-... → SKIPPED (Claude-only plugin)
29
29
  #
30
- # Windsurf has NO per-pattern glob scoping (all files in .devin/rules/ load
31
- # together) and NO sub-agents. CONDUCTOR emits the Reflector hook when
32
- # --recipes=self-improvement (ADR-032); other guards remain Claude-only (ADR-034)
33
- # and are noted in .windsurfrules.
30
+ # This adapter emits no per-pattern glob scoping (all files in .devin/rules/ load
31
+ # together) and no agent personas Windsurf supports hooks/sub-agents natively
32
+ # (ADR-031); full emission is Phase 2. CONDUCTOR emits the Reflector hook when
33
+ # --recipes=self-improvement (ADR-032); other guards remain Claude-only emission
34
+ # (ADR-034) and are noted in .windsurfrules.
34
35
 
35
36
  set -eu
36
37
 
@@ -38,6 +39,7 @@ set -eu
38
39
 
39
40
  TARGET=""
40
41
  RECIPES=""
42
+ MODE="full"
41
43
  DRY_RUN="false"
42
44
  NO_PROMPT="false"
43
45
  UNINSTALL="false"
@@ -49,6 +51,7 @@ WIZARD_APPLY_RULES="true"
49
51
  while [ $# -gt 0 ]; do
50
52
  case "$1" in
51
53
  --recipes=*) RECIPES="${1#--recipes=}" ;;
54
+ --mode=*) MODE="${1#--mode=}" ;;
52
55
  --dry-run) DRY_RUN="true" ;;
53
56
  --no-prompt) NO_PROMPT="true" ;;
54
57
  --uninstall|--rollback) UNINSTALL="true" ;;
@@ -59,6 +62,11 @@ Usage: bash adapters/windsurf/transform.sh <target-project> [options]
59
62
 
60
63
  Options:
61
64
  --recipes=A,B,C Comma-separated list of recipes to install
65
+ --mode=<m> Install preset (ADR-044): full (default) | minimal (rules text +
66
+ docs only; no Reflector runtime) | strict (abort if .windsurfrules or
67
+ .devin/rules/ already exists) | recipes-only (ONLY the selected recipe
68
+ files into .devin/rules/; requires --recipes=) | reflector-only
69
+ (self-improvement loop standalone)
62
70
  --dry-run Preview only — no files written
63
71
  --no-prompt Skip all interactive prompts; apply sensible defaults (CI-safe)
64
72
  --uninstall Revert a previous install using <target>/.conductor-manifest.json
@@ -71,7 +79,7 @@ Recipes available: web-mobile-parity, i18n, monorepo, branch-strategy, auto-mock
71
79
 
72
80
  What this adapter does NOT install (per ADR-004 honesty + ADR-021):
73
81
  - Hook guards (CONDUCTOR emits the Reflector hook when --recipes=self-improvement, ADR-032; other guards remain Claude-only, ADR-034)
74
- - Sub-agent personas (Windsurf has no sub-agent dispatch single chat session per task)
82
+ - Sub-agent personas (not yet emitted for Windsurf the tool supports sub-agents natively, ADR-031; agent emission is Phase 2)
75
83
  - Per-pattern glob scoping (all .devin/rules/*.md load together)
76
84
  - Hookify rule templates (Claude-only plugin)
77
85
  EOF
@@ -95,11 +103,31 @@ if [ -z "$TARGET" ]; then
95
103
  exit 1
96
104
  fi
97
105
 
106
+ case "$MODE" in
107
+ full|minimal|strict|recipes-only|reflector-only) : ;;
108
+ *) echo "Error: unknown --mode '$MODE' (one of: full, minimal, strict, recipes-only, reflector-only)" >&2; exit 1 ;;
109
+ esac
110
+ if [ "$MODE" = "reflector-only" ]; then
111
+ if [ -n "$RECIPES" ] && [ "$RECIPES" != "self-improvement" ]; then
112
+ echo "NOTE: --mode=reflector-only ignores --recipes (installs self-improvement only)" >&2
113
+ fi
114
+ RECIPES="self-improvement"
115
+ fi
116
+ if [ "$MODE" = "recipes-only" ] && [ -z "$RECIPES" ] && [ "$UNINSTALL" != "true" ]; then
117
+ echo "Error: --mode=recipes-only requires --recipes=A,B,..." >&2
118
+ exit 1
119
+ fi
120
+
98
121
  # Resolve CONDUCTOR root (where this script lives: adapters/windsurf/).
99
122
  CONDUCTOR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
100
123
  CORE_ROOT="$CONDUCTOR_ROOT/core"
101
124
  [ -d "$CORE_ROOT" ] || { echo "Error: core/ not found at $CORE_ROOT" >&2; exit 1; }
102
125
 
126
+ # CONDUCTOR package version for the manifest — parsed at runtime from package.json
127
+ # so releases never drift the manifest (falls back to "unknown" on any error).
128
+ CONDUCTOR_VERSION="$(/usr/bin/sed -n -E 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$CONDUCTOR_ROOT/package.json" 2>/dev/null | /usr/bin/head -n 1)"
129
+ [ -n "$CONDUCTOR_VERSION" ] || CONDUCTOR_VERSION="unknown"
130
+
103
131
  if [ "$DRY_RUN" = "true" ]; then
104
132
  mkdir -p "$TARGET"
105
133
  fi
@@ -148,18 +176,7 @@ emit_rule() {
148
176
  # downstream step overwrites it. Honors DRY_RUN. Idempotent across re-installs.
149
177
  # Origin: ADR-019 (Claude adapter pattern, mirrored here per ADR-021).
150
178
  backup_if_exists() {
151
- local dest="$1"
152
- if [ -f "$dest" ]; then
153
- local ts
154
- ts="$(/bin/date +%Y%m%d-%H%M%S)"
155
- local backup="${dest}.conductor-backup-${ts}"
156
- if [ "$DRY_RUN" = "true" ]; then
157
- log "would back up existing $dest -> $backup"
158
- else
159
- /bin/cp "$dest" "$backup"
160
- log " backed up existing $dest -> $backup"
161
- fi
162
- fi
179
+ conductor_manifest_backup_and_remember "$1"
163
180
  }
164
181
 
165
182
  # ----- manifest tracking (ADR-020, mirrored per ADR-021) ------------------
@@ -171,6 +188,9 @@ MANIFEST_STAGE_PATH=""
171
188
  MANIFEST_TS=""
172
189
  MANIFEST_LAST_BACKUP=""
173
190
 
191
+ # shellcheck source=../../tools/manifest-safety.sh
192
+ . "$CONDUCTOR_ROOT/tools/manifest-safety.sh"
193
+
174
194
  init_manifest() {
175
195
  if [ "$DRY_RUN" = "true" ]; then
176
196
  log "would init manifest staging at $MANIFEST_PATH.staging"
@@ -189,12 +209,13 @@ record_emit() {
189
209
  local relpath="$1" src="$2" backup="${3:-}"
190
210
  local had_backup="false"
191
211
  [ -n "$backup" ] && had_backup="true"
192
- local esc_path esc_src esc_backup
212
+ local esc_path esc_src esc_backup emitted_sha
193
213
  esc_path="$(printf '%s' "$relpath" | /usr/bin/sed 's/\\/\\\\/g; s/"/\\"/g')"
194
214
  esc_src="$(printf '%s' "$src" | /usr/bin/sed 's/\\/\\\\/g; s/"/\\"/g')"
195
215
  esc_backup="$(printf '%s' "$backup" | /usr/bin/sed 's/\\/\\\\/g; s/"/\\"/g')"
196
- printf ' {"path": "%s", "source": "%s", "had_backup": %s, "backup_path": "%s"},\n' \
197
- "$esc_path" "$esc_src" "$had_backup" "$esc_backup" >> "$MANIFEST_STAGE_PATH"
216
+ emitted_sha="$(conductor_sha256_file "$TARGET_ABS/$relpath")"
217
+ printf ' {"path": "%s", "source": "%s", "had_backup": %s, "backup_path": "%s", "sha256": "%s"},\n' \
218
+ "$esc_path" "$esc_src" "$had_backup" "$esc_backup" "$emitted_sha" >> "$MANIFEST_STAGE_PATH"
198
219
  }
199
220
 
200
221
  finalize_manifest() {
@@ -238,8 +259,9 @@ finalize_manifest() {
238
259
 
239
260
  /bin/cat > "$MANIFEST_PATH" <<EOF
240
261
  {
241
- "version": "v0.2.0",
262
+ "version": "v$CONDUCTOR_VERSION",
242
263
  "adapter": "windsurf",
264
+ "mode": "$MODE",
243
265
  "install_timestamp": "$MANIFEST_TS",
244
266
  "conductor_root": "$CONDUCTOR_ROOT",
245
267
  "recipes_enabled": $recipes_json,
@@ -253,19 +275,19 @@ EOF
253
275
  }
254
276
 
255
277
  backup_and_remember() {
256
- MANIFEST_LAST_BACKUP=""
257
- if [ -f "$1" ]; then
258
- if [ "$DRY_RUN" = "true" ]; then
259
- log "would back up existing $1 -> $1.conductor-backup-<ts>"
260
- MANIFEST_LAST_BACKUP=""
261
- else
262
- local ts
263
- ts="$(/bin/date +%Y%m%d-%H%M%S)"
264
- local backup="$1.conductor-backup-$ts"
265
- /bin/cp "$1" "$backup"
266
- log " backed up existing $1 -> $backup"
267
- MANIFEST_LAST_BACKUP="${backup#$TARGET_ABS/}"
268
- fi
278
+ conductor_manifest_backup_and_remember "$1"
279
+ }
280
+
281
+ # ----- framework detection (ADR-044 suggest, NEVER auto-switch) ----------
282
+
283
+ detect_coexisting_frameworks() {
284
+ local found=""
285
+ [ -d "$TARGET_ABS/.specify" ] && found="$found Spec-Kit"
286
+ { [ -d "$TARGET_ABS/_bmad" ] || [ -d "$TARGET_ABS/.bmad-core" ]; } && found="$found BMAD"
287
+ if [ -n "$found" ] && [ "$MODE" = "full" ]; then
288
+ log "NOTE: detected coexisting framework(s):$found"
289
+ log " Consider --mode=recipes-only or --mode=reflector-only to coexist without"
290
+ log " overlapping workflow rules (suggestion only — nothing was changed)."
269
291
  fi
270
292
  }
271
293
 
@@ -303,6 +325,7 @@ do_uninstall() {
303
325
  local restored=0
304
326
  local deleted=0
305
327
  local missing=0
328
+ local preserved=0
306
329
 
307
330
  while IFS= read -r line; do
308
331
  case "$line" in
@@ -311,16 +334,27 @@ do_uninstall() {
311
334
  *) continue ;;
312
335
  esac
313
336
  entries_count=$((entries_count + 1))
314
- local rel_path src had_backup backup_path
337
+ local rel_path src had_backup backup_path expected_sha
315
338
  rel_path="$(printf '%s' "$line" | /usr/bin/sed -E 's/.*"path": *"([^"]*)".*/\1/')"
316
339
  src="$(printf '%s' "$line" | /usr/bin/sed -E 's/.*"source": *"([^"]*)".*/\1/')"
317
340
  had_backup="$(printf '%s' "$line" | /usr/bin/sed -E 's/.*"had_backup": *(true|false).*/\1/')"
318
341
  backup_path="$(printf '%s' "$line" | /usr/bin/sed -E 's/.*"backup_path": *"([^"]*)".*/\1/')"
342
+ expected_sha="$(conductor_manifest_field "$line" sha256 2>/dev/null || true)"
319
343
 
320
344
  local abs_dest="$TARGET_ABS/$rel_path"
321
345
  local abs_backup=""
322
346
  [ -n "$backup_path" ] && abs_backup="$TARGET_ABS/$backup_path"
323
347
 
348
+ if [ -f "$abs_dest" ] && ! conductor_manifest_file_matches "$abs_dest" "$expected_sha"; then
349
+ if [ -z "$expected_sha" ]; then
350
+ log " WARNING: preserving $rel_path (legacy manifest has no checksum)"
351
+ else
352
+ log " WARNING: preserving user-modified $rel_path"
353
+ fi
354
+ preserved=$((preserved + 1))
355
+ continue
356
+ fi
357
+
324
358
  if [ "$had_backup" = "true" ] && [ -n "$abs_backup" ]; then
325
359
  if [ -f "$abs_backup" ]; then
326
360
  if [ "$DRY_RUN" = "true" ]; then
@@ -393,6 +427,7 @@ do_uninstall() {
393
427
  echo " Backups restored: $restored"
394
428
  echo " Files deleted: $deleted"
395
429
  echo " Backup-missing deletes: $missing"
430
+ [ "$preserved" -gt 0 ] && echo " User-modified files preserved: $preserved"
396
431
  echo "========================================================"
397
432
  }
398
433
 
@@ -427,6 +462,20 @@ if [ -d "$TARGET_ABS/.windsurf" ] || [ -f "$TARGET_ABS/.windsurfrules" ]; then
427
462
  IS_ADOPTER_CASE="true"
428
463
  fi
429
464
 
465
+ detect_coexisting_frameworks
466
+
467
+ # --mode=strict: never touch an existing rules surface (ADR-044).
468
+ if [ "$MODE" = "strict" ] && { [ -f "$TARGET_ABS/.windsurfrules" ] || [ -d "$TARGET_ABS/.devin/rules" ]; }; then
469
+ echo "Error (--mode=strict): .windsurfrules or .devin/rules/ already exists in $TARGET_ABS — strict mode aborts instead of writing next to an existing baseline." >&2
470
+ echo " Use --mode=full (timestamped backups + manifest-based restore), or move them first." >&2
471
+ exit 3
472
+ fi
473
+
474
+ # À-la-carte modes are non-interactive by design.
475
+ if [ "$MODE" != "full" ] && [ "$MODE" != "strict" ]; then
476
+ NO_PROMPT="true"
477
+ fi
478
+
430
479
  if [ "$IS_ADOPTER_CASE" = "true" ] && [ "$NO_PROMPT" = "false" ] && [ "$DRY_RUN" = "false" ]; then
431
480
  echo ""
432
481
  echo "========================================================"
@@ -474,9 +523,13 @@ init_manifest
474
523
 
475
524
  UNIVERSAL_RULES="workflow spec-as-you-go quality-gates operations meta-discipline"
476
525
 
477
- log "Step 1/4: synthesize .windsurfrules (always-loaded baseline)"
478
526
  WINDSURFRULES_DEST="$TARGET_ABS/.windsurfrules"
479
527
 
528
+ if [ "$MODE" = "recipes-only" ] || [ "$MODE" = "reflector-only" ]; then
529
+ log "Step 1/4: .windsurfrules baseline — skipped (--mode=$MODE is à la carte)"
530
+ elif true; then
531
+ log "Step 1/4: synthesize .windsurfrules (always-loaded baseline)"
532
+
480
533
  if [ -f "$WINDSURFRULES_DEST" ]; then
481
534
  log " $WINDSURFRULES_DEST exists — SKIP (exists), leaving in place"
482
535
  elif [ "$DRY_RUN" = "true" ]; then
@@ -494,10 +547,12 @@ else
494
547
  You are the orchestrator: plan first, act deliberately, verify before declaring done.
495
548
  당신은 오케스트레이터입니다: 먼저 계획하고, 신중하게 실행하고, 완료 선언 전에 검증하세요.
496
549
 
497
- Windsurf runs a single chat session per task (no sub-agent dispatch). You play
550
+ Windsurf supports sub-agent dispatch natively (ADR-031), but CONDUCTOR does not
551
+ emit agent personas here yet (Phase 2). You play
498
552
  both planner and implementer; keep the plan explicit so it survives context resets.
499
- Windsurf 는 작업당 단일 채팅 세션으로 동작합니다 (서브에이전트 디스패치 없음). 계획을
500
- 명시적으로 유지해 컨텍스트 리셋에도 살아남게 하세요.
553
+ Windsurf 는 서브에이전트 디스패치를 네이티브로 지원하지만 (ADR-031), CONDUCTOR 아직
554
+ 에이전트 페르소나를 생성하지 않습니다 (Phase 2). 여기서는 계획자와 구현자 역할을 모두
555
+ 수행하며, 계획을 명시적으로 유지해 컨텍스트 리셋에도 살아남게 하세요.
501
556
 
502
557
  ## ABSOLUTE rules / 절대 규칙 (read before every change)
503
558
 
@@ -546,22 +601,29 @@ per-pattern glob scoping). Selected recipes are emitted there too.
546
601
 
547
602
  ## Not enforced on Windsurf / Windsurf 에서 미강제
548
603
 
549
- Windsurf has no hooks and no sub-agent dispatch. The following are self-policed
550
- reminders here (on Claude Code they are enforced by hooks):
551
- Windsurf 훅과 서브에이전트 디스패치가 없습니다. 다음 항목은 여기서는 자율 준수 사항입니다
552
- (Claude Code 에서는 훅으로 강제됨):
604
+ Windsurf supports hooks and sub-agents natively (ADR-031), but CONDUCTOR's
605
+ Windsurf adapter currently emits rule text only full hook/agent emission is
606
+ Phase 2. The following are self-policed
607
+ reminders here (on Claude Code they are enforced by CONDUCTOR-emitted hooks):
608
+ Windsurf 는 훅과 서브에이전트를 네이티브로 지원하지만 (ADR-031), CONDUCTOR 의 Windsurf
609
+ adapter 는 현재 rule 텍스트만 생성합니다 (hook/agent 자동 생성은 Phase 2). 다음 항목은
610
+ 여기서는 자율 준수 사항입니다 (Claude Code 에서는 CONDUCTOR 가 생성한 훅으로 강제됨):
553
611
 
554
- - Spec-as-you-go same-turn update — no Stop hook to block stale docs.
612
+ - Spec-as-you-go same-turn update — CONDUCTOR emits no Stop hook here yet to block stale docs.
555
613
  - Two-stage code review (pre-commit / pre-merge) — pair with a git pre-commit hook for mechanical enforcement.
556
- - Model routing — Windsurf uses a single model per session; choose deliberately.
614
+ - Model routing — Windsurf supports per-task model choice natively (ADR-031); CONDUCTOR does not automate it here yet — choose deliberately.
557
615
  EOF
558
- record_emit ".windsurfrules" "<synthesized>" ""
616
+ record_emit ".windsurfrules" "<synthesized>" "$MANIFEST_LAST_BACKUP"
559
617
  log " wrote $WINDSURFRULES_DEST"
560
618
  fi
561
619
 
620
+ fi
621
+
562
622
  # ----- step 2: universal rules -> .devin/rules/*.md ----------------------
563
623
 
564
- if [ "$WIZARD_APPLY_RULES" = "true" ]; then
624
+ if [ "$MODE" = "recipes-only" ] || [ "$MODE" = "reflector-only" ]; then
625
+ log "Step 2/4: universal-rules — skipped (--mode=$MODE is à la carte)"
626
+ elif [ "$WIZARD_APPLY_RULES" = "true" ]; then
565
627
  log "Step 2/4: universal-rules → .devin/rules/ (preferred; legacy .windsurf/rules/ still read)"
566
628
  mkdir_if_real "$TARGET_ABS/.devin/rules"
567
629
 
@@ -616,7 +678,19 @@ fi
616
678
 
617
679
  # ----- step 3.5: self-improvement runtime (only with --recipes=self-improvement) ----
618
680
 
619
- case ",$RECIPES," in
681
+ if [ "$MODE" = "recipes-only" ] && [ -z "${INSTALLED_RECIPES// /}" ] && [ "$DRY_RUN" != "true" ]; then
682
+ echo "Error: --mode=recipes-only resolved ZERO valid recipes from '--recipes=$RECIPES' — nothing to install (check the names)." >&2
683
+ /bin/rm -f "$MANIFEST_STAGE_PATH"
684
+ exit 1
685
+ fi
686
+
687
+ if [ "$MODE" = "minimal" ]; then
688
+ RECIPES_FOR_RUNTIME=""
689
+ log "Step: self-improvement runtime — skipped (--mode=minimal ships text only)"
690
+ else
691
+ RECIPES_FOR_RUNTIME="$RECIPES"
692
+ fi
693
+ case ",$RECIPES_FOR_RUNTIME," in
620
694
  *",self-improvement,"*)
621
695
  log "Step: self-improvement (Reflector) → hook/workflow/rule"
622
696
  if [ "$DRY_RUN" != "true" ]; then
@@ -664,6 +738,9 @@ esac
664
738
 
665
739
  # ----- step 4: docs templates --------------------------------------------
666
740
 
741
+ if [ "$MODE" = "recipes-only" ] || [ "$MODE" = "reflector-only" ]; then
742
+ log "Step 4/4: docs templates — skipped (--mode=$MODE is à la carte; docs ship with full/minimal)"
743
+ else
667
744
  log "Step 4/4: docs templates → docs/"
668
745
  mkdir_if_real "$TARGET_ABS/docs"
669
746
  mkdir_if_real "$TARGET_ABS/docs/specs"
@@ -698,6 +775,8 @@ if [ -f "$CORE_ROOT/docs-templates/specs/_example.md" ]; then
698
775
  fi
699
776
 
700
777
  # Finalize manifest after all emits.
778
+ fi
779
+
701
780
  finalize_manifest
702
781
 
703
782
  # ----- completion summary -------------------------------------------------
@@ -707,13 +786,18 @@ echo "========================================================"
707
786
  echo " Done."
708
787
  echo " Target: $TARGET_ABS"
709
788
  echo " Adapter: windsurf"
710
- echo " Always-loaded baseline: .windsurfrules"
711
- echo " Universal rules: 5 (.devin/rules/*.md, front-matter stripped)"
789
+ echo " Mode: $MODE"
790
+ if [ "$MODE" = "recipes-only" ] || [ "$MODE" = "reflector-only" ]; then
791
+ echo " Universal rules: 0 (à la carte — no .windsurfrules baseline)"
792
+ else
793
+ echo " Always-loaded baseline: .windsurfrules"
794
+ echo " Universal rules: 5 (.devin/rules/*.md, front-matter stripped)"
795
+ fi
712
796
  echo " Recipes installed:${INSTALLED_RECIPES:- (none)}"
713
797
  echo ""
714
798
  echo " Skipped (per ADR-004 honesty):"
715
799
  echo " - Hooks: CONDUCTOR emits the Reflector hook when --recipes=self-improvement (ADR-032); other guards remain Claude-only (ADR-034)."
716
- echo " - Sub-agent personas: Windsurf has no sub-agent dispatchsingle chat session per task."
800
+ echo " - Sub-agent personas: not yet emitted for Windsurf (tool supports sub-agents nativelyADR-031; Phase 2)."
717
801
  echo " - Per-pattern glob scoping: all .devin/rules/*.md load together."
718
802
  echo " - Hookify rule templates: Claude-only plugin."
719
803
  echo ""
@@ -722,6 +806,6 @@ echo "========================================================"
722
806
  echo ""
723
807
  echo "Next steps for the project:"
724
808
  echo " 1. Open $TARGET_ABS in Windsurf."
725
- echo " 2. Edit docs/CURRENT_WORK.md with your project's current state."
809
+ [ -d "$TARGET_ABS/docs" ] && echo " 2. Edit docs/CURRENT_WORK.md with your project's current state."
726
810
  echo " 3. Verify rule loading: confirm .windsurfrules + all .devin/rules/*.md show in the rule indicator."
727
811
  echo " 4. Add .memory/ to .gitignore if you adopt the DIY memory pattern."
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 };