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.
- package/README.md +76 -67
- package/agents/code-reviewer-docs.md +11 -1
- package/agents/code-reviewer-guidelines.md +11 -1
- package/agents/code-reviewer-quality.md +11 -1
- package/agents/code-reviewer-security.md +11 -1
- package/agents/code-reviewer-spec.md +11 -1
- package/agents/git-expert.md +42 -3
- package/agents/github-expert.md +11 -0
- package/extensions/acpx-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/agents/claude.ts +3 -2
- package/extensions/cli-provider/agents/cursor.ts +3 -5
- package/extensions/cli-provider/agents/gemini.ts +3 -2
- package/extensions/cli-provider/discover.ts +7 -7
- package/extensions/cli-provider/index.ts +5 -0
- package/extensions/cli-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/session-reaper.ts +5 -7
- package/extensions/cli-provider/sessions.ts +4 -0
- package/extensions/cli-provider/shared/discover-cache.ts +17 -176
- package/extensions/coms/coms-p2p.ts +1719 -423
- package/extensions/coms/coms-shared.ts +261 -39
- package/extensions/coms/coms-wrapper.ts +100 -62
- package/extensions/coms/index.ts +8 -4
- package/extensions/coms/mixed-turn.ts +26 -0
- package/extensions/coms/probe-socket.ts +42 -0
- package/extensions/coms/themeMap.ts +0 -1
- package/extensions/image-gen/image-gen.ts +19 -1
- package/extensions/orchestrator/async-agents.ts +563 -102
- package/extensions/orchestrator/async-capability.ts +8 -8
- package/extensions/orchestrator/cron.ts +15 -9
- package/extensions/orchestrator/dreaming.ts +32 -25
- package/extensions/orchestrator/enforcement-helpers.ts +459 -17
- package/extensions/orchestrator/enforcement-rules.ts +5 -4
- package/extensions/orchestrator/enforcement.ts +80 -27
- package/extensions/orchestrator/extended-autocomplete.ts +7 -0
- package/extensions/orchestrator/git-helpers.ts +2 -2
- package/extensions/orchestrator/index.ts +78 -10
- package/extensions/orchestrator/markdown-transformer.ts +313 -0
- package/extensions/orchestrator/memory-embeddings.ts +36 -34
- package/extensions/orchestrator/memory-provenance.ts +29 -25
- package/extensions/orchestrator/memory-scoring.ts +23 -18
- package/extensions/orchestrator/memory-tools.ts +10 -3
- package/extensions/orchestrator/overlay-dashboard.ts +17 -5
- package/extensions/orchestrator/parse-model-override.ts +29 -0
- package/extensions/orchestrator/pi-config-review-state.ts +57 -33
- package/extensions/orchestrator/project-settings.ts +333 -127
- package/extensions/orchestrator/resolve-agent-model.ts +24 -13
- package/extensions/orchestrator/review-porcelain.ts +4 -0
- package/extensions/orchestrator/review-ui.ts +63 -15
- package/extensions/orchestrator/rule-placeholders.ts +411 -2
- package/extensions/orchestrator/rules.ts +80 -132
- package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
- package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
- package/extensions/orchestrator/settings-tui.ts +636 -0
- package/extensions/orchestrator/state-jsonl.ts +431 -0
- package/extensions/orchestrator/subagent-tool.ts +157 -59
- package/extensions/orchestrator/sync-limit.ts +23 -0
- package/extensions/orchestrator/task-lifecycle.ts +77 -67
- package/extensions/orchestrator/utils.ts +8 -5
- package/extensions/pidash/pidash.ts +193 -156
- package/extensions/pidiff/pidiff.ts +96 -38
- package/extensions/pidiff/spawn-lock.ts +62 -0
- package/extensions/pitasks/index.ts +430 -0
- package/extensions/pitasks/reminders.ts +37 -0
- package/extensions/pitasks/task-store.ts +467 -0
- package/extensions/pitasks/task-tools.ts +375 -0
- package/extensions/pitasks/task-widget.ts +181 -0
- package/extensions/pitasks/types.ts +38 -0
- package/extensions/providers/acpx-driver.ts +12 -2
- package/extensions/providers/built-in-drivers.ts +6 -6
- package/extensions/providers/claude-driver.ts +1 -1
- package/extensions/providers/cursor-cli-driver.ts +1 -1
- package/extensions/providers/gemini-driver.ts +1 -1
- package/extensions/providers/index.ts +144 -26
- package/extensions/providers/initialized-guard.ts +27 -0
- package/extensions/providers/restore-default-model.ts +596 -0
- package/extensions/providers/session-shutdown.ts +39 -0
- package/extensions/shared/coms-active.ts +23 -0
- package/extensions/shared/create-runtime-provider.ts +2 -0
- package/extensions/shared/daemon-manager.ts +0 -8
- package/extensions/shared/file-logger.ts +132 -25
- package/extensions/shared/logger.ts +50 -0
- package/extensions/shared/models-dev.ts +421 -0
- package/extensions/shared/oneshot.ts +116 -0
- package/extensions/shared/resolve-binary.ts +191 -0
- package/extensions/shared/types.ts +3 -0
- package/package.json +9 -3
- package/prompts/coderabbit-rate-limit.md +2 -0
- package/rules/00-orchestrator-core.md +3 -0
- package/rules/10-agent-routing.md +12 -1
- package/rules/20-code-review-loop.md +52 -25
- package/rules/30-prompt-templates.md +4 -0
- package/rules/35-memory.md +3 -3
- package/rules/40-critical-rules.md +3 -0
- package/rules/55-coms-protocol.md +84 -18
- package/rules/60-task-tracking.md +2 -0
- package/settings-keys.json +563 -0
- package/templates/coms-feature-manager-prompt.md +4 -6
- package/extensions/coms/coms-net-server.ts +0 -1642
- package/extensions/coms/coms-net-wrapper.ts +0 -521
- package/extensions/coms/coms-net.ts +0 -1589
|
@@ -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
|
|
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.
|
|
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
|
|
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")
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|