@mgiles/perk 1.0.1 → 1.1.0

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 (120) hide show
  1. package/README.md +7 -21
  2. package/extension/checkpoints/checkpoints.ts +2 -1
  3. package/extension/doors/address.ts +2 -1
  4. package/extension/doors/ciExecutor.ts +3 -2
  5. package/extension/doors/land.ts +2 -1
  6. package/extension/doors/learn.ts +239 -33
  7. package/extension/doors/learnCode.ts +100 -0
  8. package/extension/doors/learnDocs.ts +4 -3
  9. package/extension/doors/lifecycleGates.ts +2 -1
  10. package/extension/doors/prReview.ts +20 -35
  11. package/extension/doors/prReviewLocal.ts +229 -0
  12. package/extension/doors/ready.ts +2 -1
  13. package/extension/doors/selfcheck.ts +2 -1
  14. package/extension/doors/submit.ts +17 -19
  15. package/extension/factories/implementHere.ts +116 -0
  16. package/extension/factories/objective.ts +2 -1
  17. package/extension/factories/objectivePlan.ts +4 -24
  18. package/extension/factories/objectiveSave.ts +5 -15
  19. package/extension/factories/planMode.ts +5 -1
  20. package/extension/factories/planReview.ts +103 -11
  21. package/extension/factories/planSave.ts +16 -1
  22. package/extension/index.ts +42 -3
  23. package/extension/substrate/bindings.ts +1 -1
  24. package/extension/substrate/cache.ts +2 -2
  25. package/extension/substrate/command.ts +25 -0
  26. package/extension/substrate/config.ts +12 -13
  27. package/extension/substrate/consoleCapture.ts +90 -0
  28. package/extension/substrate/git.ts +34 -0
  29. package/extension/substrate/miniJinja.ts +480 -0
  30. package/extension/substrate/paths.ts +38 -0
  31. package/extension/substrate/prompts.ts +15 -24
  32. package/extension/substrate/providers.ts +1 -1
  33. package/extension/substrate/sessionData.ts +1 -1
  34. package/extension/substrate/sessionPointers.ts +155 -0
  35. package/extension/substrate/toolGating.ts +8 -0
  36. package/extension/surfaces/surfaces.ts +10 -3
  37. package/extension/worker/worker.ts +111 -25
  38. package/extension/workerMain.ts +5 -3
  39. package/package.json +1 -5
  40. package/prompts/README.md +56 -5
  41. package/prompts/_fixtures/cases.yaml +52 -131
  42. package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
  43. package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
  44. package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
  45. package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
  46. package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
  47. package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
  48. package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
  49. package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
  50. package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
  51. package/prompts/_fixtures/golden/trim_block.txt +3 -0
  52. package/prompts/_fixtures/golden/trim_inline.txt +1 -0
  53. package/prompts/_fixtures/live.yaml +281 -0
  54. package/prompts/_fixtures/templates/cond_elif.md +9 -0
  55. package/prompts/_fixtures/templates/cond_if.md +7 -0
  56. package/prompts/_fixtures/templates/cond_ops.md +3 -0
  57. package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
  58. package/prompts/_fixtures/templates/trailing_nl.md +2 -0
  59. package/prompts/_fixtures/templates/trim_block.md +5 -0
  60. package/prompts/_fixtures/templates/trim_inline.md +1 -0
  61. package/prompts/stages/conflict-resolution.md +4 -0
  62. package/prompts/stages/learn-code.md +8 -0
  63. package/prompts/stages/learn-docs.md +7 -6
  64. package/prompts/stages/learn-orchestrate.md +6 -0
  65. package/prompts/stages/learn.md +1 -1
  66. package/prompts/stages/objective-author/adopt.md +12 -0
  67. package/prompts/stages/objective-author/file.md +9 -0
  68. package/prompts/stages/objective-author/seed.md +9 -0
  69. package/prompts/stages/objective-plan/seed.md +2 -1
  70. package/prompts/stages/objective-reconcile.md +7 -0
  71. package/prompts/stages/objective-replan.md +14 -0
  72. package/prompts/stages/objective-save.md +9 -0
  73. package/prompts/stages/plan-from/adopt.md +10 -0
  74. package/prompts/stages/plan-from/file.md +9 -0
  75. package/prompts/stages/pr-review.md +6 -0
  76. package/prompts/stages/replan.md +13 -0
  77. package/prompts/stages/skills/create-from.md +15 -0
  78. package/prompts/stages/skills/create.md +9 -0
  79. package/prompts/stages/skills/refine.md +9 -0
  80. package/shared/README.md +7 -1
  81. package/shared/bindings.yaml +12 -0
  82. package/shared/contracts-history.md +167 -0
  83. package/shared/contracts.md +1307 -342
  84. package/shared/registry.yaml +3 -3
  85. package/shared/schemas/contracts/bindings.schema.json +38 -0
  86. package/shared/schemas/contracts/providers.schema.json +89 -0
  87. package/shared/schemas/contracts/registry.schema.json +98 -0
  88. package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
  89. package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
  90. package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
  91. package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
  92. package/shared/schemas/outputs/doctor-report.schema.json +236 -0
  93. package/shared/schemas/outputs/init-report.schema.json +419 -0
  94. package/shared/schemas/outputs/learn-capture.schema.json +90 -0
  95. package/shared/schemas/outputs/learn-skip.schema.json +59 -0
  96. package/shared/schemas/outputs/plan-save.schema.json +209 -0
  97. package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
  98. package/shared/schemas/outputs/pr-land.schema.json +187 -0
  99. package/shared/schemas/outputs/pr-ready.schema.json +75 -0
  100. package/shared/schemas/outputs/pr-review-context.schema.json +86 -0
  101. package/shared/schemas/outputs/pr-submit.schema.json +147 -0
  102. package/prompts/_fixtures/golden/address-action-model.txt +0 -10
  103. package/prompts/_fixtures/golden/address-action.txt +0 -10
  104. package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
  105. package/prompts/_fixtures/golden/address-preview.txt +0 -6
  106. package/prompts/_fixtures/golden/implement-github.txt +0 -8
  107. package/prompts/_fixtures/golden/learn-docs.txt +0 -8
  108. package/prompts/_fixtures/golden/learn-github.txt +0 -11
  109. package/prompts/_fixtures/golden/learn-linear.txt +0 -11
  110. package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
  111. package/prompts/_fixtures/golden/learn-other.txt +0 -8
  112. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
  113. package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
  114. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
  115. package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
  116. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
  117. package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
  118. package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
  119. package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
  120. package/prompts/_fixtures/golden/plan-read-other.txt +0 -1
@@ -0,0 +1,480 @@
1
+ // A deliberately minimal Jinja-subset renderer for perk's OWN bundled prompt templates only
2
+ // (`prompts/**`) — this is NOT a general-purpose template engine.
3
+ //
4
+ // Why this exists: pi loads the perk extension from a git-package clone whose imports resolve
5
+ // through a FIXED host-alias set (`@earendil-works/pi-*`, `typebox`) plus native `node_modules`
6
+ // walking. The extension must therefore be SELF-CONTAINED — zero runtime npm deps: a bare
7
+ // `import nunjucks from "nunjucks"` would only resolve if pi's `npm install --omit=dev` happened
8
+ // to install it, which is fragile (and perk pre-materializes the clone with a plain `git clone`
9
+ // and NO `npm install` at all, so the clone has no `node_modules` to resolve against). This
10
+ // renderer replaces the lone non-host runtime import (`nunjucks`) with Node builtins only — NO
11
+ // bare npm imports — so the extension loads from a bare git checkout regardless of whether any
12
+ // install ran. The bare-import invariant is durably guarded by `extension/bareImportGuard.test.ts`.
13
+ //
14
+ // Scope — exactly the frozen "mini-jinja" subset (SSOT: `shared/contracts.md §8.31`):
15
+ // 1. `{{ <ident> }}` variable substitution, identifier matching `^[A-Za-z_][A-Za-z0-9_]*$` only.
16
+ // 2. `{% include "<path>" %}`, double-quoted root-relative path only.
17
+ // 3. `{% if <cond> %}` / `{% elif <cond> %}` / `{% else %}` / `{% endif %}`, where `<cond>` is
18
+ // built only from bare identifiers (truthiness), double-quoted string literals, `==`, and the
19
+ // keywords `and` / `or` / `not` (precedence `or` < `and` < `not` < `==`).
20
+ // 4. Plain `{% %}` tags only — `{%- … -%}` / `{{- … -}}` whitespace-control markers are NOT in
21
+ // the subset; tag-line stripping rides the `trim_blocks` flag (baked in here, see below).
22
+ //
23
+ // Everything outside that surface is made to THROW loudly (prefix `perk mini-jinja:`) rather than
24
+ // silently mis-render: comments (`{# … #}`), loops (`for`/`endfor`), `set`/`macro`/`block`/
25
+ // `extends`/`raw`, filters (`|`), attribute access (`.`), `!=`/`<`/`>`/`in`/`is`, parentheses,
26
+ // numeric literals, and escaped string literals. An unsupported future template edit fails CI
27
+ // loudly (and the frozen-grammar guards in `promptGrammar.test.ts` / `test_prompt_grammar.py`
28
+ // catch it at author time too).
29
+ //
30
+ // The render contract is STRING-ONLY and faithful to jinja2 (`StrictUndefined`): a referenced
31
+ // name that is absent OR non-string THROWS (this deliberately tightens the looser nunjucks
32
+ // `throwOnUndefined`, forbidding a `String(value)` divergence — a future `false` must never render
33
+ // `"false"`). The frozen render config — `trim_blocks` on, `lstrip_blocks` off, trailing newline
34
+ // preserved — is BAKED IN; the subset is frozen, so there is no config object. jinja2
35
+ // (`perk/prompts.py`) remains the reference engine: the committed golden bytes under
36
+ // `prompts/_fixtures/golden/` ARE jinja2's output, and this renderer reproduces them byte-for-byte
37
+ // (proven by `prompts.test.ts`).
38
+
39
+ import { readFileSync } from "node:fs";
40
+ import { isAbsolute, resolve, sep } from "node:path";
41
+
42
+ import { promptsDir } from "./resources.ts";
43
+
44
+ const PFX = "perk mini-jinja:";
45
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Path containment
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Resolve a root-relative template `name` against `rootDir`, rejecting traversal. Guards BOTH the
53
+ * top-level `render(name, …)` and every `{% include "<path>" %}`: reject empty / absolute / any
54
+ * `..`-segment path, then defense-in-depth reject anything that does not stay within `rootDir`.
55
+ */
56
+ function resolveTemplatePath(rootDir: string, name: string): string {
57
+ if (name === "") throw new Error(`${PFX} empty template path`);
58
+ if (isAbsolute(name)) throw new Error(`${PFX} absolute template path not allowed: ${name}`);
59
+ if (name.split(/[\\/]/).includes("..")) {
60
+ throw new Error(`${PFX} '..' segment not allowed in template path: ${name}`);
61
+ }
62
+ const root = resolve(rootDir);
63
+ const full = resolve(root, name);
64
+ if (full !== root && !full.startsWith(root + sep)) {
65
+ throw new Error(`${PFX} template path escapes the prompts root: ${name}`);
66
+ }
67
+ return full;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Tokenizer
72
+ // ---------------------------------------------------------------------------
73
+
74
+ type Token =
75
+ | { kind: "text"; value: string }
76
+ | { kind: "var"; name: string }
77
+ | { kind: "block"; inner: string };
78
+
79
+ /**
80
+ * Scan the template source for `{{ }}`, `{% %}`, and `{# #}` markers. `{# #}` throws (comments are
81
+ * outside the subset). A `{{ }}` must hold a single bare identifier. A `{% %}` keeps its trimmed
82
+ * inner text for the parser. trim_blocks: the single `\n` immediately following a block tag's `%}`
83
+ * is consumed so it never enters the following literal.
84
+ */
85
+ function tokenize(src: string): Token[] {
86
+ const tokens: Token[] = [];
87
+ let text = "";
88
+ let i = 0;
89
+ const flushText = () => {
90
+ if (text !== "") {
91
+ tokens.push({ kind: "text", value: text });
92
+ text = "";
93
+ }
94
+ };
95
+ while (i < src.length) {
96
+ const two = src.slice(i, i + 2);
97
+ if (two === "{#") {
98
+ throw new Error(`${PFX} comments ({# … #}) are not supported`);
99
+ }
100
+ if (two === "{{" || two === "{%") {
101
+ flushText();
102
+ const close = two === "{{" ? "}}" : "%}";
103
+ const end = src.indexOf(close, i + 2);
104
+ if (end === -1) throw new Error(`${PFX} unterminated ${two} tag`);
105
+ const inner = src.slice(i + 2, end);
106
+ if (inner.includes("-")) {
107
+ // Cheap pre-check for whitespace-control markers ({%- … -%} / {{- … -}}); a real `-`
108
+ // never appears elsewhere in the subset (idents, the few string literals, `==`).
109
+ const t = inner.trim();
110
+ if (t.startsWith("-") || t.endsWith("-")) {
111
+ throw new Error(
112
+ `${PFX} whitespace-control markers ({%- … -%} / {{- … -}}) are not supported`,
113
+ );
114
+ }
115
+ }
116
+ if (two === "{{") {
117
+ const name = inner.trim();
118
+ if (!IDENT.test(name)) {
119
+ throw new Error(
120
+ `${PFX} only a bare identifier is allowed in {{ … }}: ${JSON.stringify(inner)}`,
121
+ );
122
+ }
123
+ tokens.push({ kind: "var", name });
124
+ i = end + 2;
125
+ } else {
126
+ tokens.push({ kind: "block", inner: inner.trim() });
127
+ i = end + 2;
128
+ if (src[i] === "\n") i++; // trim_blocks
129
+ }
130
+ continue;
131
+ }
132
+ text += two.charAt(0);
133
+ i += 1;
134
+ }
135
+ flushText();
136
+ return tokens;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Condition lexer / parser / evaluator
141
+ // ---------------------------------------------------------------------------
142
+
143
+ type CTok =
144
+ | { t: "id"; v: string }
145
+ | { t: "str"; v: string }
146
+ | { t: "eq" }
147
+ | { t: "and" }
148
+ | { t: "or" }
149
+ | { t: "not" };
150
+
151
+ /**
152
+ * Lex a condition string into atoms {identifier, double-quoted string (no escapes), `==`,
153
+ * `and`/`or`/`not`}. Any other character (parens, `!`, `<`, `>`, `|`, `.`, digits, a `\` in a
154
+ * string) throws — the runtime double-guard of the frozen subset. `in`/`is` lex as identifiers and
155
+ * are rejected later by the parser (adjacent-atom error).
156
+ */
157
+ function lexCond(s: string): CTok[] {
158
+ const toks: CTok[] = [];
159
+ let i = 0;
160
+ while (i < s.length) {
161
+ const c = s[i];
162
+ if (c === undefined) break;
163
+ if (/\s/.test(c)) {
164
+ i += 1;
165
+ continue;
166
+ }
167
+ if (c === '"') {
168
+ let j = i + 1;
169
+ let v = "";
170
+ while (j < s.length && s[j] !== '"') {
171
+ if (s[j] === "\\") {
172
+ throw new Error(`${PFX} escapes are not supported in a string literal: ${s.slice(i)}`);
173
+ }
174
+ v += s[j];
175
+ j += 1;
176
+ }
177
+ if (j >= s.length) throw new Error(`${PFX} unterminated string literal in condition: ${s}`);
178
+ toks.push({ t: "str", v });
179
+ i = j + 1;
180
+ continue;
181
+ }
182
+ if (c === "=") {
183
+ if (s[i + 1] === "=") {
184
+ toks.push({ t: "eq" });
185
+ i += 2;
186
+ continue;
187
+ }
188
+ throw new Error(
189
+ `${PFX} unsupported operator in condition (only '==' is allowed): ${s.slice(i)}`,
190
+ );
191
+ }
192
+ if (/[A-Za-z_]/.test(c)) {
193
+ let j = i;
194
+ while (j < s.length) {
195
+ const cj = s[j];
196
+ if (cj === undefined || !/[A-Za-z0-9_]/.test(cj)) break;
197
+ j += 1;
198
+ }
199
+ const word = s.slice(i, j);
200
+ if (word === "and" || word === "or" || word === "not") toks.push({ t: word });
201
+ else toks.push({ t: "id", v: word });
202
+ i = j;
203
+ continue;
204
+ }
205
+ throw new Error(`${PFX} unsupported token in condition: ${JSON.stringify(s.slice(i))}`);
206
+ }
207
+ return toks;
208
+ }
209
+
210
+ type CondNode =
211
+ | { op: "or"; l: CondNode; r: CondNode }
212
+ | { op: "and"; l: CondNode; r: CondNode }
213
+ | { op: "not"; e: CondNode }
214
+ | { op: "eq"; l: CondNode; r: CondNode }
215
+ | { op: "id"; name: string }
216
+ | { op: "str"; value: string };
217
+
218
+ interface Cursor {
219
+ i: number;
220
+ }
221
+
222
+ // Recursive descent: precedence `or` < `and` < `not` < `==` < atom. Each level rejects malformed
223
+ // valid-token sequences (missing operand / adjacent atoms with no operator) by throwing.
224
+ function parseOr(toks: CTok[], p: Cursor): CondNode {
225
+ let left = parseAnd(toks, p);
226
+ while (toks[p.i]?.t === "or") {
227
+ p.i += 1;
228
+ left = { op: "or", l: left, r: parseAnd(toks, p) };
229
+ }
230
+ return left;
231
+ }
232
+
233
+ function parseAnd(toks: CTok[], p: Cursor): CondNode {
234
+ let left = parseNot(toks, p);
235
+ while (toks[p.i]?.t === "and") {
236
+ p.i += 1;
237
+ left = { op: "and", l: left, r: parseNot(toks, p) };
238
+ }
239
+ return left;
240
+ }
241
+
242
+ function parseNot(toks: CTok[], p: Cursor): CondNode {
243
+ if (toks[p.i]?.t === "not") {
244
+ p.i += 1;
245
+ return { op: "not", e: parseNot(toks, p) };
246
+ }
247
+ return parseEq(toks, p);
248
+ }
249
+
250
+ function parseEq(toks: CTok[], p: Cursor): CondNode {
251
+ const left = parseAtom(toks, p);
252
+ if (toks[p.i]?.t === "eq") {
253
+ p.i += 1;
254
+ return { op: "eq", l: left, r: parseAtom(toks, p) };
255
+ }
256
+ return left;
257
+ }
258
+
259
+ function parseAtom(toks: CTok[], p: Cursor): CondNode {
260
+ const tok = toks[p.i];
261
+ if (tok === undefined) throw new Error(`${PFX} malformed condition (expected an operand)`);
262
+ if (tok.t === "id") {
263
+ p.i += 1;
264
+ return { op: "id", name: tok.v };
265
+ }
266
+ if (tok.t === "str") {
267
+ p.i += 1;
268
+ return { op: "str", value: tok.v };
269
+ }
270
+ throw new Error(`${PFX} malformed condition (expected an operand, got an operator)`);
271
+ }
272
+
273
+ function parseCondition(raw: string): CondNode {
274
+ const toks = lexCond(raw);
275
+ const p: Cursor = { i: 0 };
276
+ const node = parseOr(toks, p);
277
+ if (p.i !== toks.length) {
278
+ throw new Error(`${PFX} malformed condition (unexpected trailing token): ${raw}`);
279
+ }
280
+ return node;
281
+ }
282
+
283
+ /** String-only variable lookup: absent OR non-string throws (jinja2 StrictUndefined parity). */
284
+ function lookupString(name: string, vars: Record<string, unknown>): string {
285
+ const value = vars[name];
286
+ if (typeof value !== "string") {
287
+ const what = value === undefined ? "is undefined" : "is not a string";
288
+ throw new Error(
289
+ `${PFX} variable ${JSON.stringify(name)} ${what} (the render contract is string-only)`,
290
+ );
291
+ }
292
+ return value;
293
+ }
294
+
295
+ function atomValue(node: CondNode, vars: Record<string, unknown>): string {
296
+ if (node.op === "id") return lookupString(node.name, vars);
297
+ if (node.op === "str") return node.value;
298
+ // Unreachable: `==` operands are always atoms (id/str) by construction in parseEq.
299
+ throw new Error(`${PFX} internal: '==' operand is not an atom`);
300
+ }
301
+
302
+ function evalCondition(node: CondNode, vars: Record<string, unknown>): boolean {
303
+ switch (node.op) {
304
+ case "or":
305
+ return evalCondition(node.l, vars) || evalCondition(node.r, vars);
306
+ case "and":
307
+ return evalCondition(node.l, vars) && evalCondition(node.r, vars);
308
+ case "not":
309
+ return !evalCondition(node.e, vars);
310
+ case "eq":
311
+ return atomValue(node.l, vars) === atomValue(node.r, vars);
312
+ case "id":
313
+ // Truthiness: a string is truthy iff non-empty (StrictUndefined on absent/non-string).
314
+ return lookupString(node.name, vars) !== "";
315
+ case "str":
316
+ return node.value !== "";
317
+ }
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Parser (token stream → node tree)
322
+ // ---------------------------------------------------------------------------
323
+
324
+ type RNode =
325
+ | { kind: "text"; value: string }
326
+ | { kind: "var"; name: string }
327
+ | { kind: "include"; path: string }
328
+ | { kind: "if"; branches: { cond: CondNode | null; body: RNode[] }[] };
329
+
330
+ const STOP = new Set(["elif", "else", "endif"]);
331
+
332
+ /** First leading-alpha word of a block's inner text (the tag keyword), or "" if none. */
333
+ function keyword(inner: string): string {
334
+ const m = /^[A-Za-z_]+/.exec(inner);
335
+ return m ? m[0] : "";
336
+ }
337
+
338
+ /** The condition text after a leading `if`/`elif` keyword (must be whitespace-separated). */
339
+ function conditionAfter(inner: string, kw: string): string {
340
+ const rest = inner.slice(kw.length);
341
+ if (!/^\s/.test(rest)) throw new Error(`${PFX} malformed {% ${kw} %}: ${inner}`);
342
+ const cond = rest.trim();
343
+ if (cond === "") throw new Error(`${PFX} empty condition in {% ${kw} %}`);
344
+ return cond;
345
+ }
346
+
347
+ function parseInclude(inner: string): RNode {
348
+ const m = /^include\s+"([^"\\]*)"$/.exec(inner);
349
+ if (m === null) {
350
+ throw new Error(`${PFX} malformed {% include %} (expected {% include "<path>" %}): ${inner}`);
351
+ }
352
+ return { kind: "include", path: m[1] ?? "" };
353
+ }
354
+
355
+ /** Parse a body up to (but not consuming) the next STOP keyword (elif/else/endif) or EOF. */
356
+ function parseBody(tokens: Token[], p: Cursor): RNode[] {
357
+ const out: RNode[] = [];
358
+ while (p.i < tokens.length) {
359
+ const tok = tokens[p.i];
360
+ if (tok === undefined) break;
361
+ if (tok.kind === "text") {
362
+ out.push({ kind: "text", value: tok.value });
363
+ p.i += 1;
364
+ continue;
365
+ }
366
+ if (tok.kind === "var") {
367
+ out.push({ kind: "var", name: tok.name });
368
+ p.i += 1;
369
+ continue;
370
+ }
371
+ const kw = keyword(tok.inner);
372
+ if (STOP.has(kw)) return out; // leave the stop token for the caller (parseIf / top-level)
373
+ if (kw === "include") {
374
+ out.push(parseInclude(tok.inner));
375
+ p.i += 1;
376
+ continue;
377
+ }
378
+ if (kw === "if") {
379
+ out.push(parseIf(tokens, p));
380
+ continue;
381
+ }
382
+ throw new Error(`${PFX} unsupported construct: {% ${tok.inner} %}`);
383
+ }
384
+ return out;
385
+ }
386
+
387
+ function parseIf(tokens: Token[], p: Cursor): RNode {
388
+ const open = tokens[p.i];
389
+ if (open === undefined || open.kind !== "block") {
390
+ throw new Error(`${PFX} internal: parseIf called off an {% if %}`);
391
+ }
392
+ const ifCond = parseCondition(conditionAfter(open.inner, "if"));
393
+ p.i += 1;
394
+ const branches: { cond: CondNode | null; body: RNode[] }[] = [
395
+ { cond: ifCond, body: parseBody(tokens, p) },
396
+ ];
397
+ let sawElse = false;
398
+ for (;;) {
399
+ const stop = tokens[p.i];
400
+ if (stop === undefined || stop.kind !== "block") {
401
+ throw new Error(`${PFX} unclosed {% if %} (missing {% endif %})`);
402
+ }
403
+ const kw = keyword(stop.inner);
404
+ if (kw === "elif") {
405
+ if (sawElse) throw new Error(`${PFX} {% elif %} after {% else %}`);
406
+ const cond = parseCondition(conditionAfter(stop.inner, "elif"));
407
+ p.i += 1;
408
+ branches.push({ cond, body: parseBody(tokens, p) });
409
+ } else if (kw === "else") {
410
+ if (sawElse) throw new Error(`${PFX} duplicate {% else %}`);
411
+ if (stop.inner !== "else") throw new Error(`${PFX} malformed {% else %}: ${stop.inner}`);
412
+ sawElse = true;
413
+ p.i += 1;
414
+ branches.push({ cond: null, body: parseBody(tokens, p) });
415
+ } else if (kw === "endif") {
416
+ if (stop.inner !== "endif") throw new Error(`${PFX} malformed {% endif %}: ${stop.inner}`);
417
+ p.i += 1;
418
+ break;
419
+ } else {
420
+ throw new Error(`${PFX} unexpected {% ${stop.inner} %} inside {% if %}`);
421
+ }
422
+ }
423
+ return { kind: "if", branches };
424
+ }
425
+
426
+ function parse(tokens: Token[]): RNode[] {
427
+ const p: Cursor = { i: 0 };
428
+ const nodes = parseBody(tokens, p);
429
+ const leftover = tokens[p.i];
430
+ if (leftover !== undefined) {
431
+ // parseBody only returns early on a STOP keyword; reaching here means a stray elif/else/endif.
432
+ const inner = leftover.kind === "block" ? leftover.inner : "";
433
+ throw new Error(`${PFX} stray {% ${inner} %} without a matching {% if %}`);
434
+ }
435
+ return nodes;
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Render
440
+ // ---------------------------------------------------------------------------
441
+
442
+ function renderNodes(nodes: RNode[], vars: Record<string, unknown>, rootDir: string): string {
443
+ let out = "";
444
+ for (const node of nodes) {
445
+ if (node.kind === "text") {
446
+ out += node.value;
447
+ } else if (node.kind === "var") {
448
+ out += lookupString(node.name, vars);
449
+ } else if (node.kind === "include") {
450
+ out += render(node.path, vars, rootDir);
451
+ } else {
452
+ for (const branch of node.branches) {
453
+ if (branch.cond === null || evalCondition(branch.cond, vars)) {
454
+ out += renderNodes(branch.body, vars, rootDir);
455
+ break;
456
+ }
457
+ }
458
+ }
459
+ }
460
+ return out;
461
+ }
462
+
463
+ /**
464
+ * Render the template at `name` (root-relative under `rootDir`) with `vars`. Mirrors the nunjucks
465
+ * `Environment` + `FileSystemLoader` it replaced: the renderer owns the filesystem and resolves
466
+ * `{% include %}` against the SAME `rootDir`. `rootDir` defaults to `promptsDir()` (the bundled
467
+ * `prompts/`); the optional override keeps the renderer unit-testable against throwaway temp dirs.
468
+ */
469
+ export function render(
470
+ name: string,
471
+ vars: Record<string, unknown>,
472
+ rootDir: string = promptsDir(),
473
+ ): string {
474
+ const file = resolveTemplatePath(rootDir, name);
475
+ // Normalize CRLF / lone-CR to LF before tokenizing — Python's text-mode read does universal
476
+ // newline translation before jinja2 sees the source, so a CRLF checkout must not diverge the
477
+ // planes (notably trim_blocks, which consumes a single `\n` after a block tag's `%}`).
478
+ const src = readFileSync(file, "utf8").replace(/\r\n?/g, "\n");
479
+ return renderNodes(parse(tokenize(src)), vars, rootDir);
480
+ }
@@ -0,0 +1,38 @@
1
+ // perk-owned dot-directory path construction — the TS twin of perk/substrate/paths.py
2
+ // (contracts.md §8.1).
3
+ //
4
+ // The sole construction site for the perk-owned config family (`config.toml`/`local.toml`) and
5
+ // the perk dir. The config family now lives at `.perk/` (TS reads the target only — the legacy
6
+ // `.pi/perk.toml` migration is Python-side). The workflow family lives in the established cache
7
+ // seam (substrate/cache.ts's `workflowDir`); together these two modules own every perk-owned
8
+ // dot-path on this plane.
9
+ // Objective #878 migrates each family to `.perk/` one phase at a time — redirecting a family is a
10
+ // single edit here. **Pi-native** `.pi/...` paths (`.pi/settings.json`, `.pi/agents/`, `.pi/npm`,
11
+ // `.pi/APPEND_SYSTEM.md`) are intentionally NOT owned here — `.pi/` is not generally perk-owned.
12
+ //
13
+ // No relative imports (only node builtins), so this module loads cleanly under `node --test`.
14
+
15
+ import { join } from "node:path";
16
+
17
+ export const CONFIG_FILENAME = "config.toml";
18
+ export const LOCAL_CONFIG_FILENAME = "local.toml";
19
+
20
+ /** The perk-owned dot-dir root (shared with Pi today). */
21
+ export function perkDir(cwd: string): string {
22
+ return join(cwd, ".pi");
23
+ }
24
+
25
+ /** The single config-family redirection point (the file helpers derive from it). */
26
+ export function configDir(cwd: string): string {
27
+ return join(cwd, ".perk");
28
+ }
29
+
30
+ /** The committed `config.toml`. */
31
+ export function configFile(cwd: string): string {
32
+ return join(configDir(cwd), CONFIG_FILENAME);
33
+ }
34
+
35
+ /** The gitignored `local.toml`. */
36
+ export function localConfigFile(cwd: string): string {
37
+ return join(configDir(cwd), LOCAL_CONFIG_FILENAME);
38
+ }
@@ -1,35 +1,26 @@
1
1
  // The TS plane's prompt render seam — the twin of perk/prompts.py::render.
2
2
  //
3
3
  // Templates are loaded by explicit `name` (root-relative under `prompts/`) via promptsDir(),
4
- // the same directory the Python twin reads. The feature surface is intentionally small
5
- // `{{ var }}` substitution and `{% include %}` and `throwOnUndefined` makes a missing
6
- // variable fail loudly rather than render an empty string.
4
+ // the same directory the Python twin reads. The feature surface is the frozen mini-jinja subset
5
+ // (`shared/contracts.md §8.31`): `{{ var }}` substitution, `{% include %}`, and
6
+ // `{% if %}`/`{% elif %}`/`{% else %}`/`{% endif %}` conditionals. A missing (or non-string)
7
+ // variable fails loudly rather than rendering an empty string.
7
8
  //
8
9
  // jinja2 is the REFERENCE engine: the committed golden bytes under prompts/_fixtures/golden/
9
- // ARE jinja2's output, and this nunjucks twin must reproduce them byte-for-byte. Golden parity
10
- // is enforced by prompts.test.ts + tests/test_prompts.py. The Environment config below is the
11
- // parity baseline both engines share (autoescape off, trimBlocks on so a block tag on its own
12
- // line emits no spurious newline letting conditional templates keep their tags off the content
13
- // lines while preserving indentation lstripBlocks off; nunjucks keeps the trailing newline,
14
- // matching jinja2's keep_trailing_newline=True).
10
+ // ARE jinja2's output, and this seam must reproduce them byte-for-byte (enforced by prompts.test.ts
11
+ // + tests/test_prompts.py). Rendering is delegated to the vendored, zero-dependency
12
+ // ./miniJinja.ts renderer which bakes in the frozen render config (trim_blocks on so a block
13
+ // tag on its own line emits no spurious newline, lstrip off, trailing newline preserved) and
14
+ // owns the filesystem (resolving `name` and every `{% include %}` under promptsDir()). Vendoring
15
+ // keeps the extension zero-runtime-dep / loadable from a bare git clone (guarded by
16
+ // extension/bareImportGuard.test.ts).
15
17
  //
16
- // This module is imported ONLY by its test in this node — there is no real prompt to render
17
- // until Phase 2, so wiring it into extension/index.ts would be dead code. The runtime nunjucks
18
- // dependency is removed and the zero-dep / bare-clone-loadable invariant restored when the
19
- // renderer is vendored (node 4.2).
18
+ // This seam is LIVE in production: render is imported by extension/worker/worker.ts, the
19
+ // learn/address/learnDocs/lifecycleGates doors, and extension/factories/objectivePlan.ts.
20
20
 
21
- import nunjucks from "nunjucks";
22
-
23
- import { promptsDir } from "./resources.ts";
24
-
25
- const env = new nunjucks.Environment(new nunjucks.FileSystemLoader(promptsDir()), {
26
- throwOnUndefined: true,
27
- autoescape: false,
28
- trimBlocks: true,
29
- lstripBlocks: false,
30
- });
21
+ import { render as miniJinjaRender } from "./miniJinja.ts";
31
22
 
32
23
  /** Render the template at `name` (root-relative under `prompts/`) with `vars`. */
33
24
  export function render(name: string, vars: Record<string, unknown>): string {
34
- return env.render(name, vars);
25
+ return miniJinjaRender(name, vars);
35
26
  }
@@ -3,7 +3,7 @@
3
3
  // Twin of perk/substrate/providers.py: both planes parse the SAME bundled file (no codegen). This is the
4
4
  // THIRD parsed cross-plane contract (after registry.yaml and bindings.yaml). It is the SUPPORTED
5
5
  // SET — the catalog of plan/todo/askuser/footer/web providers perk knows how to wire — distinct from
6
- // the per-repo SELECTION (the flat `[providers]` table in .pi/perk.toml).
6
+ // the per-repo SELECTION (the flat `[providers]` table in .perk/config.toml).
7
7
  //
8
8
  // The Python CLI is the authoritative validator (perk/substrate/providers.py); this side does a thin
9
9
  // structural parse only — no deep content validation here. `resolveProviders` is CONSUMED by
@@ -1,6 +1,6 @@
1
1
  // The session-data accessor seam (contracts.md §8.1).
2
2
  //
3
- // Every run-scoped session artifact lives under `.pi/workflow/scratch/runs/<run_id>/data/`, and
3
+ // Every run-scoped session artifact lives under `.perk/workflow/scratch/runs/<run_id>/data/`, and
4
4
  // ALL session-data paths flow through this module (interior) or `perk/state/cache.py` (exterior) — the
5
5
  // guard tests (cacheGuard.test.ts / tests/test_cache_guard.py) forbid manual construction of the
6
6
  // `scratch`/`runs` path segments anywhere else.