@mgiles/perk 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -0
- package/extension/adapters/planAdapterPlannotator.ts +269 -0
- package/extension/adapters/planAdapterTombell.ts +147 -0
- package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
- package/extension/checkpoints/checkpoints.ts +542 -0
- package/extension/checkpoints/planSteps.ts +108 -0
- package/extension/doors/address.ts +360 -0
- package/extension/doors/askUser.ts +194 -0
- package/extension/doors/ciExecutor.ts +583 -0
- package/extension/doors/land.ts +222 -0
- package/extension/doors/learn.ts +235 -0
- package/extension/doors/learnDocs.ts +99 -0
- package/extension/doors/lifecycleGates.ts +171 -0
- package/extension/doors/prReview.ts +339 -0
- package/extension/doors/ready.ts +86 -0
- package/extension/doors/selfcheck.ts +155 -0
- package/extension/doors/submit.ts +253 -0
- package/extension/factories/objective.ts +240 -0
- package/extension/factories/objectiveAuthor.ts +114 -0
- package/extension/factories/objectiveDraft.ts +343 -0
- package/extension/factories/objectivePlan.ts +838 -0
- package/extension/factories/objectiveSave.ts +285 -0
- package/extension/factories/planDraft.ts +140 -0
- package/extension/factories/planMode.ts +214 -0
- package/extension/factories/planReview.ts +644 -0
- package/extension/factories/planSave.ts +589 -0
- package/extension/factories/planTitle.ts +123 -0
- package/extension/index.ts +459 -0
- package/extension/substrate/bindingDelivery.ts +199 -0
- package/extension/substrate/bindings.ts +180 -0
- package/extension/substrate/cache.ts +163 -0
- package/extension/substrate/coldDoor.ts +226 -0
- package/extension/substrate/config.ts +339 -0
- package/extension/substrate/miniYaml.ts +262 -0
- package/extension/substrate/prompts.ts +35 -0
- package/extension/substrate/providers.ts +177 -0
- package/extension/substrate/registry.ts +62 -0
- package/extension/substrate/resources.ts +41 -0
- package/extension/substrate/result.ts +72 -0
- package/extension/substrate/runId.ts +49 -0
- package/extension/substrate/sessionData.ts +229 -0
- package/extension/substrate/structuredOutput.ts +141 -0
- package/extension/substrate/toolGating.ts +400 -0
- package/extension/substrate/toolParams.ts +106 -0
- package/extension/substrate/workflowState.ts +233 -0
- package/extension/surfaces/footerProvider.ts +43 -0
- package/extension/surfaces/report.ts +34 -0
- package/extension/surfaces/surfaces.ts +460 -0
- package/extension/vendor/btw/btw.ts +964 -0
- package/extension/vendor/btw/core.ts +153 -0
- package/extension/vendor/whimsical/whimsical.ts +485 -0
- package/extension/worker/readOnlySession.ts +282 -0
- package/extension/worker/worker.ts +765 -0
- package/extension/workerMain.ts +150 -0
- package/package.json +55 -0
- package/prompts/README.md +15 -0
- package/prompts/_fixtures/cases.yaml +140 -0
- package/prompts/_fixtures/golden/address-action-model.txt +10 -0
- package/prompts/_fixtures/golden/address-action.txt +10 -0
- package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
- package/prompts/_fixtures/golden/address-preview.txt +6 -0
- package/prompts/_fixtures/golden/hello.txt +1 -0
- package/prompts/_fixtures/golden/implement-github.txt +8 -0
- package/prompts/_fixtures/golden/learn-docs.txt +8 -0
- package/prompts/_fixtures/golden/learn-github.txt +11 -0
- package/prompts/_fixtures/golden/learn-linear.txt +11 -0
- package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
- package/prompts/_fixtures/golden/learn-other.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
- package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
- package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
- package/prompts/_fixtures/golden/with_include.txt +4 -0
- package/prompts/_fixtures/templates/_greeting.md +1 -0
- package/prompts/_fixtures/templates/hello.md +1 -0
- package/prompts/_fixtures/templates/with_include.md +4 -0
- package/prompts/common/objective-read/linear.md +1 -0
- package/prompts/common/plan-read/github.md +1 -0
- package/prompts/common/plan-read/linear.md +1 -0
- package/prompts/common/plan-read/other.md +1 -0
- package/prompts/stages/address/action.md +10 -0
- package/prompts/stages/address/preview.md +6 -0
- package/prompts/stages/implement.md +8 -0
- package/prompts/stages/learn-docs.md +8 -0
- package/prompts/stages/learn.md +21 -0
- package/prompts/stages/objective-plan/guidance.md +12 -0
- package/prompts/stages/objective-plan/seed.md +20 -0
- package/shared/README.md +29 -0
- package/shared/bindings.yaml +64 -0
- package/shared/contracts-history.md +403 -0
- package/shared/contracts.md +4172 -0
- package/shared/providers.yaml +221 -0
- package/shared/registry.yaml +199 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// A deliberately minimal YAML-subset reader for perk's OWN bundled contract files only
|
|
2
|
+
// (`shared/{registry,bindings,providers}.yaml`) — this is NOT a general-purpose YAML parser.
|
|
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 { parse } from "yaml"` would only resolve if pi's `npm install --omit=dev` happened to
|
|
8
|
+
// install it, which is fragile (and perk pre-materializes the clone with a plain `git
|
|
9
|
+
// clone` and NO `npm install` at all, so the clone has no `node_modules` to resolve against). This
|
|
10
|
+
// reader replaces the lone non-host runtime import (`yaml`) with Node builtins only — NO bare npm
|
|
11
|
+
// imports — so the extension loads from a bare git checkout regardless of whether any install ran.
|
|
12
|
+
//
|
|
13
|
+
// Scope (the bounded feature surface across the three shipped files):
|
|
14
|
+
// - block mappings (indentation-nested) and block sequences (`- item`, incl. `- id: x`
|
|
15
|
+
// map-as-sequence-item),
|
|
16
|
+
// - flow mappings `{ k: v, ... }` and flow sequences `[a, b]` / `[]`,
|
|
17
|
+
// - scalars: integers, `true`/`false`, `null`, bare strings, and double-quoted strings (whose
|
|
18
|
+
// contents may contain `:` / `@` / `/`, and where a `#` is NOT a comment),
|
|
19
|
+
// - trailing `#` comments (outside double quotes), whole-line `#` comments, and blank lines.
|
|
20
|
+
//
|
|
21
|
+
// Explicitly UNSUPPORTED (and made to throw loudly rather than silently mis-parse): YAML
|
|
22
|
+
// anchors/aliases (`&`/`*`), block/multiline scalars (`|`/`>`), single-quoted strings, `~` null,
|
|
23
|
+
// floats, and multi-document `---`/`...` streams. An unsupported future edit fails CI loudly.
|
|
24
|
+
//
|
|
25
|
+
// Fidelity to the reference `yaml` library is pinned by `miniYaml.test.ts`, which deep-equals this
|
|
26
|
+
// reader's output against `yaml.parse` for all three bundled files (the `yaml` lib is a dev-only
|
|
27
|
+
// dependency that powers that test and never ships in the consumer git clone). This reader is the
|
|
28
|
+
// twin of the Python plane's `pyyaml`-based readers in `perk/substrate/{registry,bindings,providers}.py`.
|
|
29
|
+
|
|
30
|
+
interface Line {
|
|
31
|
+
indent: number;
|
|
32
|
+
text: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Cursor {
|
|
36
|
+
i: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Strip a trailing `#` comment, honoring double-quoted strings (a `#` inside a quote is literal). */
|
|
40
|
+
function stripComment(line: string): string {
|
|
41
|
+
let inQuote = false;
|
|
42
|
+
for (let i = 0; i < line.length; i++) {
|
|
43
|
+
const c = line[i];
|
|
44
|
+
if (c === '"' && (i === 0 || line[i - 1] !== "\\")) {
|
|
45
|
+
inQuote = !inQuote;
|
|
46
|
+
} else if (c === "#" && !inQuote && (i === 0 || /\s/.test(line[i - 1] ?? ""))) {
|
|
47
|
+
return line.slice(0, i);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return line;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Split raw text into comment-stripped, blank-free logical lines with their indentation depth. */
|
|
54
|
+
function preprocess(text: string): Line[] {
|
|
55
|
+
const out: Line[] = [];
|
|
56
|
+
for (const raw of text.split("\n")) {
|
|
57
|
+
const stripped = stripComment(raw);
|
|
58
|
+
if (stripped.trim() === "") continue;
|
|
59
|
+
const indent = stripped.length - stripped.trimStart().length;
|
|
60
|
+
const body = stripped.slice(indent).trimEnd();
|
|
61
|
+
if (body === "---" || body === "...") {
|
|
62
|
+
throw new Error("perk miniYaml: multi-document streams (`---`/`...`) are not supported");
|
|
63
|
+
}
|
|
64
|
+
out.push({ indent, text: body });
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Index of the first `:` that delimits a key from a value: not inside double quotes, not inside a
|
|
71
|
+
* flow collection, and followed by whitespace or end-of-string. Returns -1 if there is none.
|
|
72
|
+
*/
|
|
73
|
+
function findColon(s: string): number {
|
|
74
|
+
let inQuote = false;
|
|
75
|
+
let depth = 0;
|
|
76
|
+
for (let i = 0; i < s.length; i++) {
|
|
77
|
+
const c = s[i];
|
|
78
|
+
if (c === '"' && (i === 0 || s[i - 1] !== "\\")) {
|
|
79
|
+
inQuote = !inQuote;
|
|
80
|
+
} else if (!inQuote && (c === "{" || c === "[")) {
|
|
81
|
+
depth++;
|
|
82
|
+
} else if (!inQuote && (c === "}" || c === "]")) {
|
|
83
|
+
depth--;
|
|
84
|
+
} else if (c === ":" && !inQuote && depth === 0) {
|
|
85
|
+
const next = s[i + 1];
|
|
86
|
+
if (next === undefined || /\s/.test(next)) return i;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return -1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Split `key: value` at the delimiting colon into trimmed `[key, value]`. */
|
|
93
|
+
function splitKeyValue(text: string): [string, string] {
|
|
94
|
+
const idx = findColon(text);
|
|
95
|
+
if (idx === -1) return [text.trim(), ""];
|
|
96
|
+
return [text.slice(0, idx).trim(), text.slice(idx + 1).trim()];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Split a flow body on top-level commas (honoring quotes and nested flow collections). */
|
|
100
|
+
function splitTopLevel(s: string): string[] {
|
|
101
|
+
const parts: string[] = [];
|
|
102
|
+
let depth = 0;
|
|
103
|
+
let inQuote = false;
|
|
104
|
+
let cur = "";
|
|
105
|
+
for (let i = 0; i < s.length; i++) {
|
|
106
|
+
const c = s[i];
|
|
107
|
+
if (c === '"' && (i === 0 || s[i - 1] !== "\\")) {
|
|
108
|
+
inQuote = !inQuote;
|
|
109
|
+
cur += c;
|
|
110
|
+
} else if (!inQuote && (c === "{" || c === "[")) {
|
|
111
|
+
depth++;
|
|
112
|
+
cur += c;
|
|
113
|
+
} else if (!inQuote && (c === "}" || c === "]")) {
|
|
114
|
+
depth--;
|
|
115
|
+
cur += c;
|
|
116
|
+
} else if (c === "," && !inQuote && depth === 0) {
|
|
117
|
+
parts.push(cur);
|
|
118
|
+
cur = "";
|
|
119
|
+
} else {
|
|
120
|
+
cur += c;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (cur.trim() !== "") parts.push(cur);
|
|
124
|
+
return parts;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Unescape the minimal set of double-quoted escapes that the reference parser would process. */
|
|
128
|
+
function unescapeDoubleQuoted(inner: string): string {
|
|
129
|
+
return inner.replace(/\\(["\\nt])/g, (_m, ch) => {
|
|
130
|
+
if (ch === "n") return "\n";
|
|
131
|
+
if (ch === "t") return "\t";
|
|
132
|
+
return ch; // `\"` -> `"`, `\\` -> `\`
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Type a leaf scalar token to match `yaml.parse`. Throws on an unsupported construct. */
|
|
137
|
+
function parseScalar(token: string): unknown {
|
|
138
|
+
const t = token.trim();
|
|
139
|
+
if (t === "") return null;
|
|
140
|
+
if (t.startsWith('"')) {
|
|
141
|
+
if (!t.endsWith('"') || t.length < 2) {
|
|
142
|
+
throw new Error(`perk miniYaml: malformed double-quoted scalar: ${token}`);
|
|
143
|
+
}
|
|
144
|
+
return unescapeDoubleQuoted(t.slice(1, -1));
|
|
145
|
+
}
|
|
146
|
+
if (t.startsWith("'")) {
|
|
147
|
+
throw new Error("perk miniYaml: single-quoted strings are not supported");
|
|
148
|
+
}
|
|
149
|
+
if (t.startsWith("&") || t.startsWith("*")) {
|
|
150
|
+
throw new Error("perk miniYaml: anchors/aliases (`&`/`*`) are not supported");
|
|
151
|
+
}
|
|
152
|
+
if (t === "~" || /^[|>][+-]?\d*$/.test(t)) {
|
|
153
|
+
throw new Error(`perk miniYaml: unsupported scalar/block construct: ${token}`);
|
|
154
|
+
}
|
|
155
|
+
if (t === "true") return true;
|
|
156
|
+
if (t === "false") return false;
|
|
157
|
+
if (t === "null") return null;
|
|
158
|
+
if (/^-?\d+$/.test(t)) return Number(t);
|
|
159
|
+
return t;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Parse a value that may be a flow collection or a leaf scalar. */
|
|
163
|
+
function parseFlowOrScalar(token: string): unknown {
|
|
164
|
+
const t = token.trim();
|
|
165
|
+
if (t.startsWith("{")) return parseFlowMapping(t);
|
|
166
|
+
if (t.startsWith("[")) return parseFlowSequence(t);
|
|
167
|
+
return parseScalar(t);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseFlowMapping(s: string): Record<string, unknown> {
|
|
171
|
+
if (!s.endsWith("}")) throw new Error(`perk miniYaml: unterminated flow mapping: ${s}`);
|
|
172
|
+
const map: Record<string, unknown> = {};
|
|
173
|
+
for (const part of splitTopLevel(s.slice(1, -1))) {
|
|
174
|
+
const [key, value] = splitKeyValue(part.trim());
|
|
175
|
+
if (key === "") continue;
|
|
176
|
+
map[key] = parseFlowOrScalar(value);
|
|
177
|
+
}
|
|
178
|
+
return map;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseFlowSequence(s: string): unknown[] {
|
|
182
|
+
if (!s.endsWith("]")) throw new Error(`perk miniYaml: unterminated flow sequence: ${s}`);
|
|
183
|
+
return splitTopLevel(s.slice(1, -1)).map((part) => parseFlowOrScalar(part.trim()));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isDash(text: string): boolean {
|
|
187
|
+
return text === "-" || text.startsWith("- ");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Parse a block node (mapping or sequence) whose content lives at column `indent`. */
|
|
191
|
+
function parseNode(lines: Line[], cursor: Cursor, indent: number): unknown {
|
|
192
|
+
const first = lines[cursor.i];
|
|
193
|
+
if (first === undefined) return null;
|
|
194
|
+
if (isDash(first.text)) return parseSequence(lines, cursor, indent);
|
|
195
|
+
return parseMapping(lines, cursor, indent);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseMapping(lines: Line[], cursor: Cursor, indent: number): Record<string, unknown> {
|
|
199
|
+
const map: Record<string, unknown> = {};
|
|
200
|
+
while (cursor.i < lines.length) {
|
|
201
|
+
const line = lines[cursor.i];
|
|
202
|
+
if (line === undefined || line.indent !== indent) break;
|
|
203
|
+
const [key, rest] = splitKeyValue(line.text);
|
|
204
|
+
cursor.i++;
|
|
205
|
+
if (rest === "") {
|
|
206
|
+
const next = lines[cursor.i];
|
|
207
|
+
if (next !== undefined && next.indent > indent) {
|
|
208
|
+
map[key] = parseNode(lines, cursor, next.indent);
|
|
209
|
+
} else {
|
|
210
|
+
map[key] = null;
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
map[key] = parseFlowOrScalar(rest);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return map;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseSequence(lines: Line[], cursor: Cursor, indent: number): unknown[] {
|
|
220
|
+
const arr: unknown[] = [];
|
|
221
|
+
while (cursor.i < lines.length) {
|
|
222
|
+
const line = lines[cursor.i];
|
|
223
|
+
if (line === undefined || line.indent !== indent || !isDash(line.text)) break;
|
|
224
|
+
const after = line.text === "-" ? "" : line.text.slice(2);
|
|
225
|
+
if (after === "") {
|
|
226
|
+
cursor.i++;
|
|
227
|
+
const next = lines[cursor.i];
|
|
228
|
+
if (next !== undefined && next.indent > indent) {
|
|
229
|
+
arr.push(parseNode(lines, cursor, next.indent));
|
|
230
|
+
} else {
|
|
231
|
+
arr.push(null);
|
|
232
|
+
}
|
|
233
|
+
} else if (findColon(after) !== -1) {
|
|
234
|
+
// A mapping as a sequence item (`- id: x` + deeper-indented continuation keys). Rewrite the
|
|
235
|
+
// dash line into a plain mapping line at the content column, then parse the whole mapping.
|
|
236
|
+
const contentIndent = indent + 2;
|
|
237
|
+
lines[cursor.i] = { indent: contentIndent, text: after };
|
|
238
|
+
arr.push(parseMapping(lines, cursor, contentIndent));
|
|
239
|
+
} else {
|
|
240
|
+
cursor.i++;
|
|
241
|
+
arr.push(parseFlowOrScalar(after));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return arr;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Parse a YAML-subset document into a `yaml.parse`-equivalent JS value graph. Same call shape as
|
|
249
|
+
* `yaml`'s `parse`. Throws a clear `Error` on any construct outside the supported surface.
|
|
250
|
+
*/
|
|
251
|
+
export function parse(text: string): unknown {
|
|
252
|
+
const lines = preprocess(text);
|
|
253
|
+
if (lines.length === 0) return null;
|
|
254
|
+
const cursor: Cursor = { i: 0 };
|
|
255
|
+
const result = parseNode(lines, cursor, lines[0]?.indent ?? 0);
|
|
256
|
+
if (cursor.i !== lines.length) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`perk miniYaml: unexpected content at line ${cursor.i + 1} (indentation not understood)`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The TS plane's prompt render seam — the twin of perk/prompts.py::render.
|
|
2
|
+
//
|
|
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.
|
|
7
|
+
//
|
|
8
|
+
// 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).
|
|
15
|
+
//
|
|
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).
|
|
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
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Render the template at `name` (root-relative under `prompts/`) with `vars`. */
|
|
33
|
+
export function render(name: string, vars: Record<string, unknown>): string {
|
|
34
|
+
return env.render(name, vars);
|
|
35
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// The TS plane's reader for the shared provider-selection supported set (`shared/providers.yaml`).
|
|
2
|
+
//
|
|
3
|
+
// Twin of perk/substrate/providers.py: both planes parse the SAME bundled file (no codegen). This is the
|
|
4
|
+
// THIRD parsed cross-plane contract (after registry.yaml and bindings.yaml). It is the SUPPORTED
|
|
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).
|
|
7
|
+
//
|
|
8
|
+
// The Python CLI is the authoritative validator (perk/substrate/providers.py); this side does a thin
|
|
9
|
+
// structural parse only — no deep content validation here. `resolveProviders` is CONSUMED by
|
|
10
|
+
// `planMode` (plan-seam registration-time vacating when a foreign plan provider is selected); the
|
|
11
|
+
// todo-seam runtime deferral is likewise live.
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { parse } from "./miniYaml.ts";
|
|
16
|
+
import { sharedDir } from "./resources.ts";
|
|
17
|
+
|
|
18
|
+
export interface Provider {
|
|
19
|
+
id: string;
|
|
20
|
+
seam: string;
|
|
21
|
+
package: string | null;
|
|
22
|
+
adapter: string | null;
|
|
23
|
+
default: boolean;
|
|
24
|
+
packageFilter?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const PROVIDER_SEAMS = ["plan", "todo", "askuser", "footer", "web"] as const;
|
|
28
|
+
|
|
29
|
+
/** The bundled reference provider ids (the behavior-preserving no-config defaults per seam). */
|
|
30
|
+
export const PERK_PLAN_PROVIDER_ID = "perk-plan";
|
|
31
|
+
export const PERK_CHECKPOINTS_PROVIDER_ID = "perk-checkpoints";
|
|
32
|
+
export const PERK_ASK_USER_PROVIDER_ID = "perk-ask-user";
|
|
33
|
+
export const PERK_FOOTER_PROVIDER_ID = "perk-footer";
|
|
34
|
+
|
|
35
|
+
/** The foreign `@tombell/pi-plan` plan-provider id. */
|
|
36
|
+
export const TOMBELL_PLAN_PROVIDER_ID = "tombell-plan";
|
|
37
|
+
|
|
38
|
+
/** The foreign `@plannotator/pi-extension` plan-provider id (augment-posture adapter selection check). */
|
|
39
|
+
export const PLANNOTATOR_PLAN_PROVIDER_ID = "plannotator-plan";
|
|
40
|
+
|
|
41
|
+
/** The foreign `@juicesharp/rpiv-todo` todo-provider id. */
|
|
42
|
+
export const JUICESHARP_TODO_PROVIDER_ID = "juicesharp-todo";
|
|
43
|
+
|
|
44
|
+
/** The foreign `@juicesharp/rpiv-ask-user-question` askuser-provider id (vacate-only interface seam). */
|
|
45
|
+
export const JUICESHARP_ASK_USER_PROVIDER_ID = "juicesharp-ask-user";
|
|
46
|
+
|
|
47
|
+
/** The foreign `pi-powerline-footer` footer-provider id (vacate-only interface seam). */
|
|
48
|
+
export const POWERLINE_FOOTER_PROVIDER_ID = "powerline-footer";
|
|
49
|
+
|
|
50
|
+
/** The foreign `pi-bar` footer-provider id (vacate-only interface seam). */
|
|
51
|
+
export const PI_BAR_FOOTER_PROVIDER_ID = "pi-bar-footer";
|
|
52
|
+
|
|
53
|
+
/** The foreign `@tombell/pi-status` footer-provider id (vacate-only; no extension-status rendering). */
|
|
54
|
+
export const PI_STATUS_FOOTER_PROVIDER_ID = "pi-status-footer";
|
|
55
|
+
|
|
56
|
+
/** The "install nothing / pi stock footer" footer-provider id (`package: null`, vacate-only). */
|
|
57
|
+
export const PI_DEFAULT_FOOTER_PROVIDER_ID = "pi-default";
|
|
58
|
+
|
|
59
|
+
/** The web seam DEFAULT id — the FOREIGN reference provider (`pi-web-access`; perk owns no native web impl). */
|
|
60
|
+
export const PI_WEB_ACCESS_PROVIDER_ID = "pi-web-access";
|
|
61
|
+
|
|
62
|
+
/** The foreign `@ollama/pi-web-search` web-provider id (vacate-only interface seam). */
|
|
63
|
+
export const OLLAMA_WEB_PROVIDER_ID = "ollama-web-search";
|
|
64
|
+
|
|
65
|
+
/** The foreign `@juicesharp/rpiv-web-tools` web-provider id (vacate-only interface seam). */
|
|
66
|
+
export const JUICESHARP_WEB_PROVIDER_ID = "juicesharp-web-tools";
|
|
67
|
+
|
|
68
|
+
/** Parse the bundled `providers.yaml`. Throws on a missing file or unexpected shape. */
|
|
69
|
+
export function loadProviders(): Provider[] {
|
|
70
|
+
const path = join(sharedDir(), "providers.yaml");
|
|
71
|
+
const data = parse(readFileSync(path, "utf8")) as unknown;
|
|
72
|
+
|
|
73
|
+
if (typeof data !== "object" || data === null) {
|
|
74
|
+
throw new Error(`perk: ${path} is not a mapping`);
|
|
75
|
+
}
|
|
76
|
+
const record = data as Record<string, unknown>;
|
|
77
|
+
const providers = record.providers;
|
|
78
|
+
if (!Array.isArray(providers)) {
|
|
79
|
+
throw new Error(`perk: ${path} has no providers`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return providers.map((raw) => {
|
|
83
|
+
const entry = raw as Record<string, unknown>;
|
|
84
|
+
const packageFilter = entry.package_filter;
|
|
85
|
+
const provider: Provider = {
|
|
86
|
+
id: typeof entry.id === "string" ? entry.id : "",
|
|
87
|
+
seam: typeof entry.seam === "string" ? entry.seam : "",
|
|
88
|
+
package: typeof entry.package === "string" ? entry.package : null,
|
|
89
|
+
adapter: typeof entry.adapter === "string" ? entry.adapter : null,
|
|
90
|
+
default: entry.default === true,
|
|
91
|
+
};
|
|
92
|
+
if (typeof packageFilter === "object" && packageFilter !== null) {
|
|
93
|
+
provider.packageFilter = packageFilter as Record<string, unknown>;
|
|
94
|
+
}
|
|
95
|
+
return provider;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ------------------------------------------------------------------------ resolve
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The effective provider per seam after resolving a repo selection against the supported set.
|
|
103
|
+
* `issues` collects every loud-but-non-fatal finding (an unknown id or a wrong-seam provider) as a
|
|
104
|
+
* plain string — the TS plane has no `Issue`/`Severity` (those live in `perk/substrate/registry.py`); the
|
|
105
|
+
* Python plane is the authoritative validator. Twin of `perk.substrate.providers.ResolvedProviders`.
|
|
106
|
+
*/
|
|
107
|
+
export interface ResolvedProviders {
|
|
108
|
+
plan: Provider;
|
|
109
|
+
todo: Provider;
|
|
110
|
+
askuser: Provider;
|
|
111
|
+
footer: Provider;
|
|
112
|
+
web: Provider;
|
|
113
|
+
issues: string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The first `default: true` provider for `seam` (the validator enforces exactly one). */
|
|
117
|
+
function defaultFor(set: Provider[], seam: string): Provider | undefined {
|
|
118
|
+
return set.find((p) => p.seam === seam && p.default);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Map `id -> Provider` (last wins on a duplicate id; the Python validator flags duplicates). */
|
|
122
|
+
function byId(set: Provider[]): Map<string, Provider> {
|
|
123
|
+
const map = new Map<string, Provider>();
|
|
124
|
+
for (const p of set) if (p.id) map.set(p.id, p);
|
|
125
|
+
return map;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve a per-seam selection against the supported set (pure mirror of `resolve_providers`).
|
|
130
|
+
*
|
|
131
|
+
* For each seam, the selection resolves to the named provider **iff** the id exists AND its `seam`
|
|
132
|
+
* matches the key; otherwise it falls back to `defaultFor(seam)` and appends a loud-but-non-fatal
|
|
133
|
+
* issue (unknown id / seam mismatch). An **absent** key falls back to the default **silently** (the
|
|
134
|
+
* zero-config default — no issue). Defaults are trusted (not re-validated). Throws if the bundled
|
|
135
|
+
* set has no default for a seam (a corrupt install — the caller's try/catch fails safe). Omitting
|
|
136
|
+
* `set` loads the bundled `providers.yaml`.
|
|
137
|
+
*/
|
|
138
|
+
export function resolveProviders(
|
|
139
|
+
selection: { plan?: string; todo?: string; askuser?: string; footer?: string; web?: string },
|
|
140
|
+
set?: Provider[],
|
|
141
|
+
): ResolvedProviders {
|
|
142
|
+
const providers = set ?? loadProviders();
|
|
143
|
+
const ids = byId(providers);
|
|
144
|
+
const issues: string[] = [];
|
|
145
|
+
|
|
146
|
+
const requireDefault = (seam: string): Provider => {
|
|
147
|
+
const def = defaultFor(providers, seam);
|
|
148
|
+
if (def === undefined) {
|
|
149
|
+
throw new Error(`perk: no default provider for seam \`${seam}\` — reinstall perk`);
|
|
150
|
+
}
|
|
151
|
+
return def;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const resolveSeam = (seam: "plan" | "todo" | "askuser" | "footer" | "web"): Provider => {
|
|
155
|
+
const selected = selection[seam];
|
|
156
|
+
if (selected == null) return requireDefault(seam);
|
|
157
|
+
const provider = ids.get(selected);
|
|
158
|
+
if (provider === undefined) {
|
|
159
|
+
issues.push(`\`${seam}\` selects unknown provider \`${selected}\``);
|
|
160
|
+
return requireDefault(seam);
|
|
161
|
+
}
|
|
162
|
+
if (provider.seam !== seam) {
|
|
163
|
+
issues.push(`provider \`${selected}\` is a \`${provider.seam}\` provider, not \`${seam}\``);
|
|
164
|
+
return requireDefault(seam);
|
|
165
|
+
}
|
|
166
|
+
return provider;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
plan: resolveSeam("plan"),
|
|
171
|
+
todo: resolveSeam("todo"),
|
|
172
|
+
askuser: resolveSeam("askuser"),
|
|
173
|
+
footer: resolveSeam("footer"),
|
|
174
|
+
web: resolveSeam("web"),
|
|
175
|
+
issues,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The TS plane's reader for the shared stage registry (`shared/registry.yaml`).
|
|
2
|
+
//
|
|
3
|
+
// Twin of perk/substrate/registry.py: both planes parse the SAME bundled file (no codegen). The
|
|
4
|
+
// extension drives in-session transitions from the parsed registry. The Python CLI is the
|
|
5
|
+
// authoritative validator (`perk registry check`); this side does a thin structural assertion.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { parse } from "./miniYaml.ts";
|
|
10
|
+
import { sharedDir } from "./resources.ts";
|
|
11
|
+
|
|
12
|
+
export interface RegistryStage {
|
|
13
|
+
id: string;
|
|
14
|
+
command: string;
|
|
15
|
+
doors: Record<string, boolean>;
|
|
16
|
+
predecessors: string[];
|
|
17
|
+
successors: string[];
|
|
18
|
+
requires?: string[];
|
|
19
|
+
reads?: string[];
|
|
20
|
+
writes?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Registry {
|
|
24
|
+
schemaVersion: number;
|
|
25
|
+
stages: RegistryStage[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The registry state-key for the active plan->branch ref (contracts.md §8.1/§8.4). */
|
|
29
|
+
export const PLAN_REF_STATE_KEY = "cache.plan-ref";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether a stage *consumes* the plan-ref selector — i.e. its `requires ∪ reads` lists
|
|
33
|
+
* `cache.plan-ref`. The worktree stages (implement/submit/address/land/learn) do; the
|
|
34
|
+
* root `worktree: none` stages (plan/objective-plan/save) do not. An unknown stage id is
|
|
35
|
+
* treated as non-consuming (false).
|
|
36
|
+
*/
|
|
37
|
+
export function stageConsumesPlanRef(registry: Registry, stageId: string): boolean {
|
|
38
|
+
const stage = registry.stages.find((s) => s.id === stageId);
|
|
39
|
+
if (stage === undefined) return false;
|
|
40
|
+
const keys = [...(stage.requires ?? []), ...(stage.reads ?? [])];
|
|
41
|
+
return keys.includes(PLAN_REF_STATE_KEY);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Parse the bundled `registry.yaml`. Throws on a missing file or unexpected shape. */
|
|
45
|
+
export function loadRegistry(): Registry {
|
|
46
|
+
const path = join(sharedDir(), "registry.yaml");
|
|
47
|
+
const data = parse(readFileSync(path, "utf8")) as unknown;
|
|
48
|
+
|
|
49
|
+
if (typeof data !== "object" || data === null) {
|
|
50
|
+
throw new Error(`perk: ${path} is not a mapping`);
|
|
51
|
+
}
|
|
52
|
+
const record = data as Record<string, unknown>;
|
|
53
|
+
const stages = record.stages;
|
|
54
|
+
if (!Array.isArray(stages) || stages.length === 0) {
|
|
55
|
+
throw new Error(`perk: ${path} has no stages`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
schemaVersion: typeof record.schema_version === "number" ? record.schema_version : 0,
|
|
60
|
+
stages: stages.map((raw) => raw as RegistryStage),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The TS plane's twin of perk/_resources.py: locate the bundled `shared/`
|
|
2
|
+
// contracts dir and read the lockstep version. `extension/` and `shared/` are
|
|
3
|
+
// siblings in both the dev tree and the published tarball (npm `files` preserves
|
|
4
|
+
// the layout), so a single `../shared` path resolves in both modes — no
|
|
5
|
+
// dual-path manifest is needed.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
function packageRoot(): string {
|
|
12
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Absolute path to the bundled `shared/` contracts directory. */
|
|
16
|
+
export function sharedDir(): string {
|
|
17
|
+
const dir = join(packageRoot(), "shared");
|
|
18
|
+
if (!existsSync(dir)) {
|
|
19
|
+
throw new Error(`perk: could not locate the bundled 'shared/' directory at ${dir}`);
|
|
20
|
+
}
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Absolute path to the bundled `prompts/` directory. */
|
|
25
|
+
export function promptsDir(): string {
|
|
26
|
+
const dir = join(packageRoot(), "prompts");
|
|
27
|
+
if (!existsSync(dir)) {
|
|
28
|
+
throw new Error(`perk: could not locate the bundled 'prompts/' directory at ${dir}`);
|
|
29
|
+
}
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** perk version, read from the package.json shipped alongside the extension. */
|
|
34
|
+
export function perkVersion(): string {
|
|
35
|
+
try {
|
|
36
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8"));
|
|
37
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
38
|
+
} catch {
|
|
39
|
+
return "0.0.0";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The one warm-door Result seam — owns the canonical { content, details, terminate? } tool-result
|
|
2
|
+
// shape and the loud-but-soft failure idiom (report + "<label> failed: <message>"), so the nine
|
|
3
|
+
// cold-door-delegating doors share one discriminated union instead of ad-hoc *Result/*Details
|
|
4
|
+
// pairs (cf. report.ts, branchOf).
|
|
5
|
+
|
|
6
|
+
import { type ReportTarget, report } from "../surfaces/report.ts";
|
|
7
|
+
|
|
8
|
+
/** The single text block every warm-door result renders ("one text field, two doors"). */
|
|
9
|
+
export interface TextBlock {
|
|
10
|
+
type: "text";
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The canonical failure details. `X` adds module-specific fail extras (address's batch results). */
|
|
15
|
+
export type FailDetails<X extends object = Record<never, never>> = {
|
|
16
|
+
ok: false;
|
|
17
|
+
error: string;
|
|
18
|
+
error_type: string;
|
|
19
|
+
} & X;
|
|
20
|
+
|
|
21
|
+
export type OkDetails<D extends object> = { ok: true } & D;
|
|
22
|
+
|
|
23
|
+
export interface OkResult<D extends object> {
|
|
24
|
+
content: TextBlock[];
|
|
25
|
+
details: OkDetails<D>;
|
|
26
|
+
terminate?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FailResult<X extends object = Record<never, never>> {
|
|
30
|
+
content: TextBlock[];
|
|
31
|
+
details: FailDetails<X>;
|
|
32
|
+
terminate?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The discriminated-union warm-door result (discriminant: `details.ok`). */
|
|
36
|
+
export type Result<D extends object, X extends object = Record<never, never>> =
|
|
37
|
+
| OkResult<D>
|
|
38
|
+
| FailResult<X>;
|
|
39
|
+
|
|
40
|
+
/** Build a success result. `terminate: true` is included ONLY when requested (key-absent otherwise). */
|
|
41
|
+
export function ok<D extends object>(
|
|
42
|
+
text: string,
|
|
43
|
+
details: D,
|
|
44
|
+
opts?: { terminate?: boolean },
|
|
45
|
+
): OkResult<D> {
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text", text }],
|
|
48
|
+
details: { ok: true, ...details },
|
|
49
|
+
...(opts?.terminate ? { terminate: true } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Bind the module's fail constructor once: `const fail = failFor(ctx, scope)` (or
|
|
55
|
+
* `failFor(ctx, scope, label)` when the content label differs from the report scope). Each call
|
|
56
|
+
* reports loudly (`report(ctx, scope, "error", message, { alsoLog: true })`) and returns the
|
|
57
|
+
* canonical soft failure: content `"<label> failed: <message>"`, details
|
|
58
|
+
* `{ ok: false, error: message, error_type: errorType }`, no `terminate`.
|
|
59
|
+
*/
|
|
60
|
+
export function failFor(
|
|
61
|
+
target: ReportTarget,
|
|
62
|
+
scope: string,
|
|
63
|
+
label: string = scope,
|
|
64
|
+
): (message: string, errorType: string) => FailResult {
|
|
65
|
+
return (message, errorType) => {
|
|
66
|
+
report(target, scope, "error", message, { alsoLog: true });
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: "text", text: `${label} failed: ${message}` }],
|
|
69
|
+
details: { ok: false, error: message, error_type: errorType },
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
}
|