pi-orchestrator-config 4.2.0 → 4.3.2

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 (100) hide show
  1. package/README.md +76 -67
  2. package/agents/code-reviewer-docs.md +11 -1
  3. package/agents/code-reviewer-guidelines.md +11 -1
  4. package/agents/code-reviewer-quality.md +11 -1
  5. package/agents/code-reviewer-security.md +11 -1
  6. package/agents/code-reviewer-spec.md +11 -1
  7. package/agents/git-expert.md +42 -3
  8. package/agents/github-expert.md +11 -0
  9. package/extensions/acpx-provider/runtime-models.ts +18 -6
  10. package/extensions/cli-provider/agents/claude.ts +3 -2
  11. package/extensions/cli-provider/agents/cursor.ts +3 -5
  12. package/extensions/cli-provider/agents/gemini.ts +3 -2
  13. package/extensions/cli-provider/discover.ts +7 -7
  14. package/extensions/cli-provider/index.ts +5 -0
  15. package/extensions/cli-provider/runtime-models.ts +18 -6
  16. package/extensions/cli-provider/session-reaper.ts +5 -7
  17. package/extensions/cli-provider/sessions.ts +4 -0
  18. package/extensions/cli-provider/shared/discover-cache.ts +17 -176
  19. package/extensions/coms/coms-p2p.ts +1719 -423
  20. package/extensions/coms/coms-shared.ts +261 -39
  21. package/extensions/coms/coms-wrapper.ts +100 -62
  22. package/extensions/coms/index.ts +8 -4
  23. package/extensions/coms/mixed-turn.ts +26 -0
  24. package/extensions/coms/probe-socket.ts +42 -0
  25. package/extensions/coms/themeMap.ts +0 -1
  26. package/extensions/image-gen/image-gen.ts +19 -1
  27. package/extensions/orchestrator/async-agents.ts +563 -102
  28. package/extensions/orchestrator/async-capability.ts +8 -8
  29. package/extensions/orchestrator/cron.ts +15 -9
  30. package/extensions/orchestrator/dreaming.ts +32 -25
  31. package/extensions/orchestrator/enforcement-helpers.ts +459 -17
  32. package/extensions/orchestrator/enforcement-rules.ts +5 -4
  33. package/extensions/orchestrator/enforcement.ts +80 -27
  34. package/extensions/orchestrator/extended-autocomplete.ts +7 -0
  35. package/extensions/orchestrator/git-helpers.ts +2 -2
  36. package/extensions/orchestrator/index.ts +78 -10
  37. package/extensions/orchestrator/markdown-transformer.ts +313 -0
  38. package/extensions/orchestrator/memory-embeddings.ts +36 -34
  39. package/extensions/orchestrator/memory-provenance.ts +29 -25
  40. package/extensions/orchestrator/memory-scoring.ts +23 -18
  41. package/extensions/orchestrator/memory-tools.ts +10 -3
  42. package/extensions/orchestrator/overlay-dashboard.ts +17 -5
  43. package/extensions/orchestrator/parse-model-override.ts +29 -0
  44. package/extensions/orchestrator/pi-config-review-state.ts +57 -33
  45. package/extensions/orchestrator/project-settings.ts +333 -127
  46. package/extensions/orchestrator/resolve-agent-model.ts +24 -13
  47. package/extensions/orchestrator/review-porcelain.ts +4 -0
  48. package/extensions/orchestrator/review-ui.ts +63 -15
  49. package/extensions/orchestrator/rule-placeholders.ts +411 -2
  50. package/extensions/orchestrator/rules.ts +80 -132
  51. package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
  52. package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
  53. package/extensions/orchestrator/settings-tui.ts +636 -0
  54. package/extensions/orchestrator/state-jsonl.ts +431 -0
  55. package/extensions/orchestrator/subagent-tool.ts +157 -59
  56. package/extensions/orchestrator/sync-limit.ts +23 -0
  57. package/extensions/orchestrator/task-lifecycle.ts +77 -67
  58. package/extensions/orchestrator/utils.ts +8 -5
  59. package/extensions/pidash/pidash.ts +193 -156
  60. package/extensions/pidiff/pidiff.ts +96 -38
  61. package/extensions/pidiff/spawn-lock.ts +62 -0
  62. package/extensions/pitasks/index.ts +430 -0
  63. package/extensions/pitasks/reminders.ts +37 -0
  64. package/extensions/pitasks/task-store.ts +467 -0
  65. package/extensions/pitasks/task-tools.ts +375 -0
  66. package/extensions/pitasks/task-widget.ts +181 -0
  67. package/extensions/pitasks/types.ts +38 -0
  68. package/extensions/providers/acpx-driver.ts +12 -2
  69. package/extensions/providers/built-in-drivers.ts +6 -6
  70. package/extensions/providers/claude-driver.ts +1 -1
  71. package/extensions/providers/cursor-cli-driver.ts +1 -1
  72. package/extensions/providers/gemini-driver.ts +1 -1
  73. package/extensions/providers/index.ts +144 -26
  74. package/extensions/providers/initialized-guard.ts +27 -0
  75. package/extensions/providers/restore-default-model.ts +596 -0
  76. package/extensions/providers/session-shutdown.ts +39 -0
  77. package/extensions/shared/coms-active.ts +23 -0
  78. package/extensions/shared/create-runtime-provider.ts +2 -0
  79. package/extensions/shared/daemon-manager.ts +0 -8
  80. package/extensions/shared/file-logger.ts +132 -25
  81. package/extensions/shared/logger.ts +50 -0
  82. package/extensions/shared/models-dev.ts +421 -0
  83. package/extensions/shared/oneshot.ts +116 -0
  84. package/extensions/shared/resolve-binary.ts +191 -0
  85. package/extensions/shared/types.ts +3 -0
  86. package/package.json +9 -3
  87. package/prompts/coderabbit-rate-limit.md +2 -0
  88. package/rules/00-orchestrator-core.md +3 -0
  89. package/rules/10-agent-routing.md +12 -1
  90. package/rules/20-code-review-loop.md +52 -25
  91. package/rules/30-prompt-templates.md +4 -0
  92. package/rules/35-memory.md +3 -3
  93. package/rules/40-critical-rules.md +3 -0
  94. package/rules/55-coms-protocol.md +84 -18
  95. package/rules/60-task-tracking.md +2 -0
  96. package/settings-keys.json +563 -0
  97. package/templates/coms-feature-manager-prompt.md +4 -6
  98. package/extensions/coms/coms-net-server.ts +0 -1642
  99. package/extensions/coms/coms-net-wrapper.ts +0 -521
  100. package/extensions/coms/coms-net.ts +0 -1589
@@ -0,0 +1,4 @@
1
+ /** Normalize git status --porcelain to path-only snapshot (ignores staging indicators). */
2
+ export function normalizePorcelain(stdout: string): string {
3
+ return stdout.split("\n").map((line) => line.slice(3)).filter(Boolean).sort().join("\n");
4
+ }
@@ -8,10 +8,13 @@
8
8
 
9
9
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
10
  import { Box, Text } from "@earendil-works/pi-tui";
11
- import { onStateTransition, readReviewState, type ReviewState } from "./pi-config-review-state.js";
11
+ import { onStateTransition, readReviewState, markNeedsReview, resetReviewState, type ReviewState } from "./pi-config-review-state.js";
12
12
  import { ICON_REVIEW_CLEAN, ICON_REVIEW_NEEDED, ICON_REVIEW_PROGRESS, ICON_REVIEW_FINDINGS } from "./icons.js";
13
13
  import { getSetting } from "./project-settings.js";
14
14
  import { setSlot, clearSlot } from "./status-bar.js";
15
+ import { runGit, getCurrentBranch } from "./git-helpers.js";
16
+ import { isBumpVersionBranch, getCachedBranch } from "./enforcement.js";
17
+ import { normalizePorcelain } from "./review-porcelain.js";
15
18
 
16
19
  interface ReviewStatusData {
17
20
  status: ReviewState["status"];
@@ -23,14 +26,18 @@ interface ReviewStatusData {
23
26
  timestamp: number;
24
27
  }
25
28
 
26
- // Deduplication — skip append if status+cycle hasn't changed
29
+ // Deduplication — skip append if status hasn't changed
27
30
  let lastAppendedKey = "";
28
31
 
29
32
  /** Dedup key shared by status bar and transcript cards. */
30
33
  function stateKey(s: { status: string; cycle: number; findings_count: number; tests_passed: boolean; reviewers_pending: { length: number } }): string {
34
+ // For needs_review, only status+cycle matter — tests_passed flips shouldn't re-trigger the card
35
+ if (s.status === "needs_review") return `${s.status}:${s.cycle}`;
31
36
  return `${s.status}:${s.cycle}:${s.findings_count}:${s.tests_passed}:${s.reviewers_pending.length}`;
32
37
  }
33
38
 
39
+ export { normalizePorcelain } from "./review-porcelain.js";
40
+
34
41
  export function registerReviewUI(pi: ExtensionAPI): void {
35
42
  // Register the entry renderer for "review-status" custom type
36
43
  pi.registerEntryRenderer<ReviewStatusData>("review-status", (entry, { expanded }, theme) => {
@@ -95,6 +102,7 @@ export function registerReviewUI(pi: ExtensionAPI): void {
95
102
 
96
103
  let lastCtx: ExtensionContext | null = null;
97
104
  let lastBarKey = "";
105
+ let lastGitSnapshot = "";
98
106
 
99
107
  function updateStatusBar(state: ReviewState): void {
100
108
  if (!lastCtx?.hasUI) return;
@@ -142,6 +150,15 @@ export function registerReviewUI(pi: ExtensionAPI): void {
142
150
  // Capture ctx and show current state on load
143
151
  pi.on("session_start", (_event, ctx) => {
144
152
  lastCtx = ctx;
153
+ // Initialize git snapshot for dirty detection
154
+ lastGitSnapshot = normalizePorcelain(runGit(["status", "--porcelain"], ctx.cwd).stdout);
155
+ // If tree is already dirty on session start, ensure review state reflects it
156
+ if (lastGitSnapshot && getSetting(ctx.cwd, "review_loop_enforcement") && !isBumpVersionBranch(getCurrentBranch(ctx.cwd))) {
157
+ const state = readReviewState(ctx.cwd);
158
+ if (state.status === "none" || state.status === "clean") {
159
+ try { markNeedsReview(ctx.cwd); } catch (e: any) { console.debug("[review-ui] markNeedsReview failed:", e?.message); }
160
+ }
161
+ }
145
162
  if (getSetting(ctx.cwd, "review_loop_enforcement")) {
146
163
  updateStatusBar(readReviewState(ctx.cwd));
147
164
  } else if (ctx.hasUI) {
@@ -151,7 +168,7 @@ export function registerReviewUI(pi: ExtensionAPI): void {
151
168
  });
152
169
  pi.on("agent_end", (_event, ctx) => { lastCtx = ctx; });
153
170
 
154
- // Poll pi-config-review-state.json for cross-process updates (subagents write state too)
171
+ // Poll pi-config-review-state.jsonl for cross-process updates (subagents write state too)
155
172
  const reviewPoller = setInterval(() => {
156
173
  if (!lastCtx) return;
157
174
  // Access a ctx property to detect stale/disposed context (throws if invalidated)
@@ -169,22 +186,60 @@ export function registerReviewUI(pi: ExtensionAPI): void {
169
186
  }, 5000);
170
187
  if (reviewPoller.unref) reviewPoller.unref();
171
188
 
189
+ // Poll git status to detect file changes from CLI/ACPX agents
190
+ const gitDirtyPoller = setInterval(() => {
191
+ if (!lastCtx) return;
192
+ try { void lastCtx.ui.theme; } catch { lastCtx = null; return; }
193
+ try {
194
+ if (!getSetting(lastCtx.cwd, "review_loop_enforcement")) return;
195
+ // Skip on version bump branches (release workflow only)
196
+ if (isBumpVersionBranch(getCachedBranch(lastCtx.cwd))) return;
197
+ const result = runGit(["status", "--porcelain"], lastCtx.cwd);
198
+ const snapshot = normalizePorcelain(result.stdout);
199
+ if (snapshot === lastGitSnapshot) return;
200
+ lastGitSnapshot = snapshot;
201
+ if (snapshot) {
202
+ // Dirty tree — markNeedsReview handles all states:
203
+ // none/clean/has_findings → sets needs_review
204
+ // in_progress → sets edited_during_cycle + clears tests_passed
205
+ // needs_review → no-op (deduped internally)
206
+ try { markNeedsReview(lastCtx.cwd); } catch (e: any) { console.debug("[review-ui] markNeedsReview failed:", e?.message); }
207
+ } else if (result.code === 0) {
208
+ // Tree clean AND git succeeded — reset when status is clean (committed-away),
209
+ // needs_review (edits reverted, nothing left to review), or has_findings
210
+ // (committed+pushed — fresh start). Skip in_progress (reviewers still running).
211
+ const state = readReviewState(lastCtx.cwd);
212
+ if (state.status === "clean" || state.status === "needs_review" || state.status === "has_findings") {
213
+ try { resetReviewState(lastCtx.cwd); } catch (e: any) { console.debug("[review-ui] resetReviewState failed:", e?.message); }
214
+ }
215
+ }
216
+ } catch (e: any) { console.debug("[review-ui] gitDirtyPoller error:", e?.message); }
217
+ }, 3000);
218
+ if (gitDirtyPoller.unref) gitDirtyPoller.unref();
219
+
172
220
  pi.on("session_shutdown", () => {
173
221
  clearInterval(reviewPoller);
222
+ clearInterval(gitDirtyPoller);
174
223
  });
175
224
 
176
225
  // ── Hook into review state transitions ───────────────────────────────
177
226
 
178
227
  onStateTransition((state: ReviewState) => {
179
- // Always update status bar (even for "none" — clears it)
180
- // Only show if enforcement is enabled
228
+ // Always update status bar
181
229
  if (lastCtx && getSetting(lastCtx.cwd, "review_loop_enforcement")) {
182
230
  updateStatusBar(state);
183
231
  }
184
232
 
185
- if (state.status === "none") return;
233
+ if (state.status === "none") {
234
+ lastAppendedKey = "";
235
+ return;
236
+ }
237
+
238
+ // Card only shows on status change — not on tests_passed/findings/pending changes
239
+ if (state.status === lastAppendedKey) return;
240
+ lastAppendedKey = state.status;
186
241
 
187
- const data: ReviewStatusData = {
242
+ pi.appendEntry<ReviewStatusData>("review-status", {
188
243
  status: state.status,
189
244
  cycle: state.cycle,
190
245
  findings_count: state.findings_count,
@@ -192,13 +247,6 @@ export function registerReviewUI(pi: ExtensionAPI): void {
192
247
  reviewers_pending: [...state.reviewers_pending],
193
248
  reviewers_total: state.reviewers_total,
194
249
  timestamp: Date.now(),
195
- };
196
-
197
- // Deduplicate transcript cards — skip if nothing meaningful changed
198
- const key = stateKey(data);
199
- if (key === lastAppendedKey) return;
200
- lastAppendedKey = key;
201
-
202
- pi.appendEntry<ReviewStatusData>("review-status", data);
250
+ });
203
251
  });
204
252
  }
@@ -1,12 +1,421 @@
1
1
  /**
2
- * Pure placeholder substitution for rules/*.md prompt text.
2
+ * Pure placeholder substitution and conditional assembly for rules/*.md
3
+ * and agent system-prompt text.
3
4
  *
4
5
  * Kept in its own module (no heavy imports) so it can be unit tested without
5
6
  * pulling in the full orchestrator dependency chain (e.g. rules.ts → async-agents.ts
6
7
  * → @earendil-works/pi-tui, which is a runtime-only dependency not present in
7
- * the test install).
8
+ * the test install). Callers pass a resolve function for settings lookups.
8
9
  */
9
10
 
11
+ import { createLogger } from "../shared/logger.js";
12
+
13
+ const log = createLogger("rules");
14
+
10
15
  export function substituteRulePlaceholders(text: string, values: { reviewLoopMaxCycles: number }): string {
11
16
  return text.replaceAll("{{REVIEW_LOOP_MAX_CYCLES}}", String(values.reviewLoopMaxCycles));
12
17
  }
18
+
19
+ const SETTINGS_PATTERN = /\{\{SETTINGS(?::([^}]+))?\}\}/g;
20
+
21
+ /**
22
+ * Replace `{{SETTINGS:key1,key2,...}}` with JSON of resolved setting values.
23
+ * `{{SETTINGS}}` (no colon) resolves every key in `allKeys`.
24
+ */
25
+ export function substituteSettingsPlaceholders(
26
+ text: string,
27
+ resolve: (key: string) => unknown,
28
+ allKeys: string[],
29
+ ): string {
30
+ return text.replace(new RegExp(SETTINGS_PATTERN.source, "g"), (_match, keysGroup: string | undefined) => {
31
+ const keys = keysGroup !== undefined
32
+ ? keysGroup.split(",").map((k) => k.trim()).filter((k) => k.length > 0)
33
+ : allKeys;
34
+ const result: Record<string, unknown> = {};
35
+ for (const key of keys) {
36
+ if (!allKeys.includes(key)) {
37
+ continue;
38
+ }
39
+ result[key] = resolve(key);
40
+ }
41
+ return JSON.stringify(result);
42
+ });
43
+ }
44
+
45
+ /** Truthiness for settings-gated rule blocks. */
46
+ export function isSettingTruthy(value: unknown): boolean {
47
+ if (value === false || value === null || value === undefined || value === "" || value === 0) {
48
+ log.debug("isSettingTruthy", { result: false, kind: value === "" ? "empty-string" : String(value) });
49
+ return false;
50
+ }
51
+ if (Array.isArray(value) && value.length === 0) {
52
+ log.debug("isSettingTruthy", { result: false, kind: "empty-array" });
53
+ return false;
54
+ }
55
+ if (typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value as object).length === 0) {
56
+ log.debug("isSettingTruthy", { result: false, kind: "empty-object" });
57
+ return false;
58
+ }
59
+ log.debug("isSettingTruthy", { result: true, kind: Array.isArray(value) ? "array" : typeof value });
60
+ return true;
61
+ }
62
+
63
+ /** Parse comparison / truthy condition text after `IF:` or `IFNOT:`. */
64
+ export function parseConditionExpr(expr: string): {
65
+ key: string;
66
+ op: "truthy" | "eq" | "neq";
67
+ literal?: unknown;
68
+ } | null {
69
+ const trimmed = expr.trim();
70
+ if (!trimmed) {
71
+ log.debug("parseConditionExpr empty");
72
+ return null;
73
+ }
74
+ const cmp = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*(==|!=)\s*(.*)$/);
75
+ if (cmp) {
76
+ const parsed = {
77
+ key: cmp[1],
78
+ op: (cmp[2] === "==" ? "eq" : "neq") as "eq" | "neq",
79
+ literal: parseConditionLiteral(cmp[3].trim()),
80
+ };
81
+ log.debug("parseConditionExpr", parsed);
82
+ return parsed;
83
+ }
84
+ const keyOnly = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)$/);
85
+ if (keyOnly) {
86
+ const parsed = { key: keyOnly[1], op: "truthy" as const };
87
+ log.debug("parseConditionExpr", parsed);
88
+ return parsed;
89
+ }
90
+ log.debug("parseConditionExpr malformed", trimmed);
91
+ return null;
92
+ }
93
+
94
+ /** Parse `true`/`false`/`null`/number/string literals used in `key==value` conditions. */
95
+ export function parseConditionLiteral(raw: string): unknown {
96
+ let result: unknown = raw;
97
+ if (raw === "true") result = true;
98
+ else if (raw === "false") result = false;
99
+ else if (raw === "null") result = null;
100
+ else if (
101
+ (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) ||
102
+ (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2)
103
+ ) {
104
+ result = raw.slice(1, -1);
105
+ } else if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(raw)) {
106
+ result = Number(raw);
107
+ }
108
+ log.debug("parseConditionLiteral", { raw, resultType: typeof result });
109
+ return result;
110
+ }
111
+
112
+ export type ConditionalEvalOpts = {
113
+ knownKeys?: string[];
114
+ /** Feature predicates consulted for truthy `{{IF:key}}` when key is not a setting. */
115
+ featurePredicates?: Record<string, () => boolean>;
116
+ onWarn?: (msg: string) => void;
117
+ };
118
+
119
+ type OpenKind = "if" | "ifnot";
120
+
121
+ type OpenMarker = {
122
+ kind: OpenKind;
123
+ cond: string;
124
+ index: number;
125
+ end: number;
126
+ };
127
+
128
+ const MARKER_RE = /\{\{(IFNOT:([^}]+)|IF:([^}]+)|\/IFNOT|\/IF)\}\}/g;
129
+
130
+ function markerRe(): RegExp {
131
+ return new RegExp(MARKER_RE.source, "g");
132
+ }
133
+
134
+ function findNextOpen(text: string, from: number): OpenMarker | null {
135
+ const re = markerRe();
136
+ re.lastIndex = from;
137
+ let m: RegExpExecArray | null;
138
+ while ((m = re.exec(text)) !== null) {
139
+ const full = m[0];
140
+ if (full.startsWith("{{IFNOT:")) {
141
+ return { kind: "ifnot", cond: m[2], index: m.index, end: m.index + full.length };
142
+ }
143
+ if (full.startsWith("{{IF:")) {
144
+ return { kind: "if", cond: m[3], index: m.index, end: m.index + full.length };
145
+ }
146
+ // stray close — leave for later scan
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Find the matching closer for `open`, tracking a stack of open kinds.
153
+ * A mismatched closer at any depth does not pop — unbalanced → null.
154
+ */
155
+ function findMatchingClose(
156
+ text: string,
157
+ open: OpenMarker,
158
+ ): { index: number; end: number } | null {
159
+ const stack: OpenKind[] = [open.kind];
160
+ const re = markerRe();
161
+ re.lastIndex = open.end;
162
+ let m: RegExpExecArray | null;
163
+ while ((m = re.exec(text)) !== null) {
164
+ const full = m[0];
165
+ if (full.startsWith("{{IF:")) {
166
+ stack.push("if");
167
+ continue;
168
+ }
169
+ if (full.startsWith("{{IFNOT:")) {
170
+ stack.push("ifnot");
171
+ continue;
172
+ }
173
+ if (full === "{{/IF}}" || full === "{{/IFNOT}}") {
174
+ const expectedKind: OpenKind = full === "{{/IF}}" ? "if" : "ifnot";
175
+ const top = stack[stack.length - 1];
176
+ if (top !== expectedKind) {
177
+ // Mismatched closer — do not pop; treat as unbalanced for this open.
178
+ return null;
179
+ }
180
+ stack.pop();
181
+ if (stack.length === 0) {
182
+ return { index: m.index, end: m.index + full.length };
183
+ }
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
189
+ function conditionHolds(
190
+ condRaw: string,
191
+ invert: boolean,
192
+ resolve: (key: string) => unknown,
193
+ opts?: ConditionalEvalOpts,
194
+ ): boolean {
195
+ const parsed = parseConditionExpr(condRaw);
196
+ if (!parsed) {
197
+ opts?.onWarn?.(`malformed conditional condition: ${condRaw}`);
198
+ return false;
199
+ }
200
+ const { key, op, literal } = parsed;
201
+
202
+ // Truthy op: feature predicates win when the key is registered as a feature
203
+ // (does not require knownKeys — features are not settings keys).
204
+ if (op === "truthy" && opts?.featurePredicates && key in opts.featurePredicates) {
205
+ let holds = false;
206
+ try {
207
+ holds = opts.featurePredicates[key]() === true;
208
+ } catch (e: any) {
209
+ opts.onWarn?.(`feature predicate ${key} threw: ${e?.message ?? String(e)}`);
210
+ holds = false;
211
+ }
212
+ return invert ? !holds : holds;
213
+ }
214
+
215
+ let value: unknown = undefined;
216
+ if (opts?.knownKeys && !opts.knownKeys.includes(key)) {
217
+ opts.onWarn?.(`unknown setting key in conditional: ${key}`);
218
+ // Fail closed: unknown keys never keep the block, including {{IFNOT:typo}}.
219
+ log.warn("conditionHolds fail-closed unknown key", { key, invert, op });
220
+ return false;
221
+ } else {
222
+ value = resolve(key);
223
+ }
224
+ let holds: boolean;
225
+ if (op === "truthy") {
226
+ holds = isSettingTruthy(value);
227
+ } else if (op === "eq") {
228
+ holds = value === literal;
229
+ } else {
230
+ holds = value !== literal;
231
+ }
232
+ return invert ? !holds : holds;
233
+ }
234
+
235
+ /**
236
+ * Collapse blank lines between consecutive markdown table rows so
237
+ * removed/kept IF markers do not break table adjacency (`|...|` rows).
238
+ */
239
+ export function collapseBlankLinesBetweenTableRows(text: string): string {
240
+ // Two or more newlines between lines that both start with `|` → single newline
241
+ return text.replace(/(\|[^\n]*)\n(?:[ \t]*\n)+(?=\|)/g, "$1\n");
242
+ }
243
+
244
+ /**
245
+ * Evaluate `{{IF:key}}...{{/IF}}` / `{{IFNOT:key}}...{{/IFNOT}}` blocks
246
+ * (including `key==value` / `key!=value`). Nesting supported.
247
+ * Malformed/unbalanced markers: leave text as-is and warn — never throw.
248
+ */
249
+ export function evaluateConditionalBlocks(
250
+ text: string,
251
+ resolve: (key: string) => unknown,
252
+ opts?: ConditionalEvalOpts,
253
+ ): string {
254
+ try {
255
+ const processed = processRegion(text, resolve, opts);
256
+ return collapseBlankLinesBetweenTableRows(processed);
257
+ } catch (e: any) {
258
+ opts?.onWarn?.(`conditional evaluation failed: ${e?.message ?? String(e)}`);
259
+ return text;
260
+ }
261
+ }
262
+
263
+ function processRegion(
264
+ region: string,
265
+ resolve: (key: string) => unknown,
266
+ opts?: ConditionalEvalOpts,
267
+ ): string {
268
+ let result = "";
269
+ let i = 0;
270
+ while (i < region.length) {
271
+ // Warn on stray closes before the next open
272
+ const peekRe = markerRe();
273
+ peekRe.lastIndex = i;
274
+ const peek = peekRe.exec(region);
275
+ if (peek && (peek[0] === "{{/IF}}" || peek[0] === "{{/IFNOT}}")) {
276
+ const open = findNextOpen(region, i);
277
+ if (!open || open.index > peek.index) {
278
+ opts?.onWarn?.(`unbalanced conditional marker: ${peek[0]}`);
279
+ result += region.slice(i, peek.index + peek[0].length);
280
+ i = peek.index + peek[0].length;
281
+ continue;
282
+ }
283
+ }
284
+
285
+ const open = findNextOpen(region, i);
286
+ if (!open) {
287
+ result += region.slice(i);
288
+ break;
289
+ }
290
+ result += region.slice(i, open.index);
291
+ const close = findMatchingClose(region, open);
292
+ if (!close) {
293
+ opts?.onWarn?.(`unbalanced conditional block starting at {{${open.kind === "if" ? "IF" : "IFNOT"}:${open.cond}}}`);
294
+ // Leave from open marker to end as-is (do not throw)
295
+ result += region.slice(open.index);
296
+ break;
297
+ }
298
+ const body = region.slice(open.end, close.index);
299
+ const keep = conditionHolds(open.cond, open.kind === "ifnot", resolve, opts);
300
+ if (keep) {
301
+ result += processRegion(body, resolve, opts);
302
+ }
303
+ i = close.end;
304
+ }
305
+ return result;
306
+ }
307
+
308
+ /**
309
+ * Parse a simple YAML-ish frontmatter block (`---` / `key: value` / `---`).
310
+ * No attrs → body = raw.
311
+ */
312
+ export function parseRuleFrontmatter(raw: string): { attrs: Record<string, string>; body: string } {
313
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
314
+ if (!match) {
315
+ return { attrs: {}, body: raw };
316
+ }
317
+ const attrs: Record<string, string> = {};
318
+ for (const line of match[1].split(/\r?\n/)) {
319
+ const trimmed = line.trim();
320
+ if (!trimmed || trimmed.startsWith("#")) continue;
321
+ const colon = trimmed.indexOf(":");
322
+ if (colon <= 0) continue;
323
+ const key = trimmed.slice(0, colon).trim();
324
+ const value = trimmed.slice(colon + 1).trim();
325
+ if (key) attrs[key] = value;
326
+ }
327
+ return { attrs, body: match[2] };
328
+ }
329
+
330
+ export type FrontmatterGateOpts = {
331
+ resolveSetting: (key: string) => unknown;
332
+ featurePredicates: Record<string, () => boolean>;
333
+ knownKeys?: string[];
334
+ onWarn?: (msg: string) => void;
335
+ };
336
+
337
+ /**
338
+ * Whole-file gate from frontmatter `requires_setting` / `requires` (AND if both present).
339
+ */
340
+ export function rulePassesFrontmatterGate(
341
+ attrs: Record<string, string>,
342
+ opts: FrontmatterGateOpts,
343
+ ): boolean {
344
+ const requiresSetting = attrs.requires_setting?.trim();
345
+ const requires = attrs.requires?.trim();
346
+ if (!requiresSetting && !requires) return true;
347
+
348
+ if (requiresSetting) {
349
+ if (opts.knownKeys && !opts.knownKeys.includes(requiresSetting)) {
350
+ opts.onWarn?.(`unknown requires_setting key: ${requiresSetting}`);
351
+ return false;
352
+ }
353
+ if (!isSettingTruthy(opts.resolveSetting(requiresSetting))) {
354
+ return false;
355
+ }
356
+ }
357
+
358
+ if (requires) {
359
+ const pred = opts.featurePredicates[requires];
360
+ if (!pred) {
361
+ opts.onWarn?.(`unknown feature predicate: ${requires}`);
362
+ return false;
363
+ }
364
+ try {
365
+ if (pred() !== true) {
366
+ return false;
367
+ }
368
+ } catch (e: any) {
369
+ opts.onWarn?.(`feature predicate ${requires} threw: ${e?.message ?? String(e)}`);
370
+ return false;
371
+ }
372
+ }
373
+
374
+ return true;
375
+ }
376
+
377
+ export type AssembleRuleTextOpts = {
378
+ resolve: (key: string) => unknown;
379
+ knownKeys: string[];
380
+ featurePredicates: Record<string, () => boolean>;
381
+ reviewLoopMaxCycles: number;
382
+ onWarn?: (msg: string) => void;
383
+ onSkip?: (info: { attrs: Record<string, string>; index: number }) => void;
384
+ onInclude?: (info: { attrs: Record<string, string>; index: number }) => void;
385
+ };
386
+
387
+ /**
388
+ * Pure rule assembly: per-file frontmatter gate → per-file conditionals →
389
+ * join → placeholder substitution.
390
+ *
391
+ * Conditionals run per body so an open `{{IF}}` in one file cannot close in
392
+ * another and strip intervening package rules.
393
+ */
394
+ export function assembleRuleText(contents: string[], opts: AssembleRuleTextOpts): string {
395
+ const bodies: string[] = [];
396
+ const condOpts: ConditionalEvalOpts = {
397
+ knownKeys: opts.knownKeys,
398
+ featurePredicates: opts.featurePredicates,
399
+ onWarn: opts.onWarn,
400
+ };
401
+ for (let index = 0; index < contents.length; index++) {
402
+ const raw = contents[index];
403
+ const { attrs, body } = parseRuleFrontmatter(raw);
404
+ if (!rulePassesFrontmatterGate(attrs, {
405
+ resolveSetting: opts.resolve,
406
+ featurePredicates: opts.featurePredicates,
407
+ knownKeys: opts.knownKeys,
408
+ onWarn: opts.onWarn,
409
+ })) {
410
+ opts.onSkip?.({ attrs, index });
411
+ continue;
412
+ }
413
+ opts.onInclude?.({ attrs, index });
414
+ // Evaluate conditionals per file before join (no cross-file IF matching).
415
+ bodies.push(evaluateConditionalBlocks(body, opts.resolve, condOpts));
416
+ }
417
+ const joined = bodies.join("\n\n");
418
+ // Safety net: collapse table-row blank lines across join boundaries too.
419
+ const collapsed = collapseBlankLinesBetweenTableRows(joined);
420
+ return substituteRulePlaceholders(collapsed, { reviewLoopMaxCycles: opts.reviewLoopMaxCycles });
421
+ }