@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.
- package/README.md +7 -21
- package/extension/checkpoints/checkpoints.ts +2 -1
- package/extension/doors/address.ts +2 -1
- package/extension/doors/ciExecutor.ts +3 -2
- package/extension/doors/land.ts +2 -1
- package/extension/doors/learn.ts +239 -33
- package/extension/doors/learnCode.ts +100 -0
- package/extension/doors/learnDocs.ts +4 -3
- package/extension/doors/lifecycleGates.ts +2 -1
- package/extension/doors/prReview.ts +20 -35
- package/extension/doors/prReviewLocal.ts +229 -0
- package/extension/doors/ready.ts +2 -1
- package/extension/doors/selfcheck.ts +2 -1
- package/extension/doors/submit.ts +17 -19
- package/extension/factories/implementHere.ts +116 -0
- package/extension/factories/objective.ts +2 -1
- package/extension/factories/objectivePlan.ts +4 -24
- package/extension/factories/objectiveSave.ts +5 -15
- package/extension/factories/planMode.ts +5 -1
- package/extension/factories/planReview.ts +103 -11
- package/extension/factories/planSave.ts +16 -1
- package/extension/index.ts +42 -3
- package/extension/substrate/bindings.ts +1 -1
- package/extension/substrate/cache.ts +2 -2
- package/extension/substrate/command.ts +25 -0
- package/extension/substrate/config.ts +12 -13
- package/extension/substrate/consoleCapture.ts +90 -0
- package/extension/substrate/git.ts +34 -0
- package/extension/substrate/miniJinja.ts +480 -0
- package/extension/substrate/paths.ts +38 -0
- package/extension/substrate/prompts.ts +15 -24
- package/extension/substrate/providers.ts +1 -1
- package/extension/substrate/sessionData.ts +1 -1
- package/extension/substrate/sessionPointers.ts +155 -0
- package/extension/substrate/toolGating.ts +8 -0
- package/extension/surfaces/surfaces.ts +10 -3
- package/extension/worker/worker.ts +111 -25
- package/extension/workerMain.ts +5 -3
- package/package.json +1 -5
- package/prompts/README.md +56 -5
- package/prompts/_fixtures/cases.yaml +52 -131
- package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
- package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
- package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
- package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
- package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
- package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
- package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
- package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
- package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
- package/prompts/_fixtures/golden/trim_block.txt +3 -0
- package/prompts/_fixtures/golden/trim_inline.txt +1 -0
- package/prompts/_fixtures/live.yaml +281 -0
- package/prompts/_fixtures/templates/cond_elif.md +9 -0
- package/prompts/_fixtures/templates/cond_if.md +7 -0
- package/prompts/_fixtures/templates/cond_ops.md +3 -0
- package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
- package/prompts/_fixtures/templates/trailing_nl.md +2 -0
- package/prompts/_fixtures/templates/trim_block.md +5 -0
- package/prompts/_fixtures/templates/trim_inline.md +1 -0
- package/prompts/stages/conflict-resolution.md +4 -0
- package/prompts/stages/learn-code.md +8 -0
- package/prompts/stages/learn-docs.md +7 -6
- package/prompts/stages/learn-orchestrate.md +6 -0
- package/prompts/stages/learn.md +1 -1
- package/prompts/stages/objective-author/adopt.md +12 -0
- package/prompts/stages/objective-author/file.md +9 -0
- package/prompts/stages/objective-author/seed.md +9 -0
- package/prompts/stages/objective-plan/seed.md +2 -1
- package/prompts/stages/objective-reconcile.md +7 -0
- package/prompts/stages/objective-replan.md +14 -0
- package/prompts/stages/objective-save.md +9 -0
- package/prompts/stages/plan-from/adopt.md +10 -0
- package/prompts/stages/plan-from/file.md +9 -0
- package/prompts/stages/pr-review.md +6 -0
- package/prompts/stages/replan.md +13 -0
- package/prompts/stages/skills/create-from.md +15 -0
- package/prompts/stages/skills/create.md +9 -0
- package/prompts/stages/skills/refine.md +9 -0
- package/shared/README.md +7 -1
- package/shared/bindings.yaml +12 -0
- package/shared/contracts-history.md +167 -0
- package/shared/contracts.md +1307 -342
- package/shared/registry.yaml +3 -3
- package/shared/schemas/contracts/bindings.schema.json +38 -0
- package/shared/schemas/contracts/providers.schema.json +89 -0
- package/shared/schemas/contracts/registry.schema.json +98 -0
- package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
- package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
- package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
- package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
- package/shared/schemas/outputs/doctor-report.schema.json +236 -0
- package/shared/schemas/outputs/init-report.schema.json +419 -0
- package/shared/schemas/outputs/learn-capture.schema.json +90 -0
- package/shared/schemas/outputs/learn-skip.schema.json +59 -0
- package/shared/schemas/outputs/plan-save.schema.json +209 -0
- package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
- package/shared/schemas/outputs/pr-land.schema.json +187 -0
- package/shared/schemas/outputs/pr-ready.schema.json +75 -0
- package/shared/schemas/outputs/pr-review-context.schema.json +86 -0
- package/shared/schemas/outputs/pr-submit.schema.json +147 -0
- package/prompts/_fixtures/golden/address-action-model.txt +0 -10
- package/prompts/_fixtures/golden/address-action.txt +0 -10
- package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
- package/prompts/_fixtures/golden/address-preview.txt +0 -6
- package/prompts/_fixtures/golden/implement-github.txt +0 -8
- package/prompts/_fixtures/golden/learn-docs.txt +0 -8
- package/prompts/_fixtures/golden/learn-github.txt +0 -11
- package/prompts/_fixtures/golden/learn-linear.txt +0 -11
- package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
- package/prompts/_fixtures/golden/learn-other.txt +0 -8
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
- package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
- package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
- package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
- package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
- 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
|
|
5
|
-
// `{{ var }}` substitution
|
|
6
|
-
//
|
|
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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// line emits no spurious newline
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
17
|
-
//
|
|
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
|
|
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
|
|
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 .
|
|
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 `.
|
|
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.
|