@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,199 @@
|
|
|
1
|
+
// Warm-door (TS-extension) delivery of resolved skill bindings — the in-session twin of the cold
|
|
2
|
+
// door (perk/substrate/binding_delivery.py). Both planes render the SAME resolved overlay (defaults
|
|
3
|
+
// ⊕ user bindings; `nudge` -> a pointer line, `transclude` -> the inlined skill body) under the
|
|
4
|
+
// SAME header literal. The cold door appends it to a launch's initial prompt; this module renders
|
|
5
|
+
// it at two WARM surfaces:
|
|
6
|
+
//
|
|
7
|
+
// Mechanism A — stage triggers: a `before_agent_start` handler (mirroring planMode.ts /
|
|
8
|
+
// objectiveAuthor.ts) injects the launched stage's bindings as a hidden context message — the
|
|
9
|
+
// delivery path for `stage:plan`'s `perk-plan` pointer, since a cold `perk plan` launches idle
|
|
10
|
+
// (no initial prompt to augment).
|
|
11
|
+
// Mechanism B — `bindingSuffix()` is appended into the guidance of perk's warm slash-commands so
|
|
12
|
+
// each self-delivers its pointer (a warm `/objective-plan` run outside a stage:objective-plan
|
|
13
|
+
// session gets none from Mechanism A).
|
|
14
|
+
//
|
|
15
|
+
// This is the SINGLE delivery path for perk's own nudges. Delivery NEVER double-delivers: the
|
|
16
|
+
// cold↔warm dedup marker is `BINDING_HEADER` itself — the cold door's initial prompt and every warm
|
|
17
|
+
// injection carry it, so Mechanism A injects ONLY when nothing on the branch already carries the
|
|
18
|
+
// header (idempotent across turns/reloads; after compaction drops the original it re-delivers).
|
|
19
|
+
//
|
|
20
|
+
// LBYL throughout: a missing/unreadable transclude target degrades to the nudge pointer with a
|
|
21
|
+
// loud-but-non-fatal warning, never throws, never blocks a turn. Resolver shape `issues` are NOT
|
|
22
|
+
// surfaced warm (the cold launch + doctor own them); only the transclude `warnings` are.
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { loadDefaultBindings, resolveBindings, type SkillBinding } from "./bindings.ts";
|
|
28
|
+
import { loadPerkConfig } from "./config.ts";
|
|
29
|
+
import { type BranchEntry, branchOf, rebuildWorkflowState } from "./workflowState.ts";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The cross-plane dedup marker AND render header. MUST stay byte-identical to the Python cold
|
|
33
|
+
* door's `_HEADER` (perk/substrate/binding_delivery.py) — both planes render under it so a cold launch and a
|
|
34
|
+
* warm injection never double-deliver. Pinned by a literal test in both planes (§8.9).
|
|
35
|
+
*/
|
|
36
|
+
export const BINDING_HEADER = "The following skill binding(s) apply here:";
|
|
37
|
+
|
|
38
|
+
/** The hidden context customType carrying a warm-injected stage-binding render (Mechanism A). */
|
|
39
|
+
export const BINDING_CONTEXT_TYPE = "perk:binding-context";
|
|
40
|
+
|
|
41
|
+
const SKILLS_SUBDIR = join(".agents", "skills");
|
|
42
|
+
const SKILL_FILENAME = "SKILL.md";
|
|
43
|
+
|
|
44
|
+
/** The rendered warm delivery for one trigger: the prompt fragment (or `null`) + any warnings. */
|
|
45
|
+
export interface BindingRender {
|
|
46
|
+
text: string | null;
|
|
47
|
+
warnings: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The full resolved bindings: the shipped defaults ⊕ the user overlay. The TS twin of cold's
|
|
52
|
+
* `resolve_bindings(...).bindings` (perk/substrate/binding_delivery.py) — delivers the defaults too
|
|
53
|
+
* (perk's own nudges are no longer hardcoded), so there is no longer a default subtraction.
|
|
54
|
+
*/
|
|
55
|
+
export function resolvedBindings(cwd: string): SkillBinding[] {
|
|
56
|
+
return resolveBindings(loadPerkConfig(cwd).bindings, loadDefaultBindings()).bindings;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Render the resolved bindings matching `trigger` into a header-joined fragment (or `null` when
|
|
61
|
+
* none match). `nudge` renders a `Follow the \`<skill>\` skill.` pointer; `transclude` inlines
|
|
62
|
+
* `.agents/skills/<skill>/SKILL.md` (frontmatter stripped), degrading to the nudge pointer with a
|
|
63
|
+
* loud-but-non-fatal warning when the file is absent/unreadable. Pure but for the LBYL file read.
|
|
64
|
+
*/
|
|
65
|
+
export function renderBindings(cwd: string, trigger: string): BindingRender {
|
|
66
|
+
const mine = resolvedBindings(cwd).filter((binding) => binding.trigger === trigger);
|
|
67
|
+
const warnings: string[] = [];
|
|
68
|
+
const parts: string[] = [];
|
|
69
|
+
for (const binding of mine) {
|
|
70
|
+
if (binding.mode === "transclude") {
|
|
71
|
+
const body = readSkillBody(cwd, binding.skill);
|
|
72
|
+
if (body !== null) {
|
|
73
|
+
parts.push(`Skill \`${binding.skill}\` (inlined for \`${binding.trigger}\`):\n\n${body}`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
warnings.push(
|
|
77
|
+
`skill binding: transclude target for \`${binding.skill}\` not found under ` +
|
|
78
|
+
`${SKILLS_SUBDIR}/${binding.skill}/${SKILL_FILENAME} — falling back to a pointer.`,
|
|
79
|
+
);
|
|
80
|
+
} else if (!skillInstalled(cwd, binding.skill)) {
|
|
81
|
+
// The nudge mirror of the transclude warning: a binding to a skill that is
|
|
82
|
+
// not installed is reported loud-but-non-fatal, never silently delivered. The pointer is
|
|
83
|
+
// still emitted so the model gets the nudge.
|
|
84
|
+
warnings.push(
|
|
85
|
+
`skill binding: skill \`${binding.skill}\` for \`${binding.trigger}\` is not installed ` +
|
|
86
|
+
`under ${SKILLS_SUBDIR}/${binding.skill}/${SKILL_FILENAME} — the pointer may dangle.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
parts.push(`Follow the \`${binding.skill}\` skill.`);
|
|
90
|
+
}
|
|
91
|
+
const text = parts.length > 0 ? [BINDING_HEADER, ...parts].join("\n\n") : null;
|
|
92
|
+
return { text, warnings };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The Mechanism-B suffix: the rendered bindings for `trigger` to append into a warm command's
|
|
97
|
+
* guidance (empty string when none match). Every perk warm slash-command self-delivers its pointer
|
|
98
|
+
* this way, by `stage:<id>` or `command:<id>`. A leading blank line keeps it
|
|
99
|
+
* visually distinct from the guidance it follows. Any render warnings (missing transclude target or
|
|
100
|
+
* uninstalled nudge skill) are `console.error`-ed loud-but-non-fatal — the nudge
|
|
101
|
+
* fallback still reaches the model, but the misconfiguration is no longer surfaced silently.
|
|
102
|
+
*/
|
|
103
|
+
export function bindingSuffix(cwd: string, trigger: string): string {
|
|
104
|
+
const { text, warnings } = renderBindings(cwd, trigger);
|
|
105
|
+
for (const warning of warnings) console.error(`perk: ${warning}`);
|
|
106
|
+
return text ? `\n\n${text}` : "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Whether `.agents/skills/<skill>/SKILL.md` exists under `cwd` (the warm delivery read path). */
|
|
110
|
+
function skillInstalled(cwd: string, skill: string): boolean {
|
|
111
|
+
return existsSync(join(cwd, SKILLS_SUBDIR, skill, SKILL_FILENAME));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Read `.agents/skills/<skill>/SKILL.md` (frontmatter stripped); `null` if absent/unreadable. */
|
|
115
|
+
function readSkillBody(cwd: string, skill: string): string | null {
|
|
116
|
+
const path = join(cwd, SKILLS_SUBDIR, skill, SKILL_FILENAME);
|
|
117
|
+
if (!existsSync(path)) return null;
|
|
118
|
+
try {
|
|
119
|
+
return stripFrontmatter(readFileSync(path, "utf8"));
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Drop a leading `---`-delimited YAML frontmatter block; return the body stripped. */
|
|
126
|
+
function stripFrontmatter(text: string): string {
|
|
127
|
+
if (!text.startsWith("---\n")) return text;
|
|
128
|
+
const lines = text.split("\n");
|
|
129
|
+
for (let i = 1; i < lines.length; i++) {
|
|
130
|
+
if (lines[i] === "---")
|
|
131
|
+
return lines
|
|
132
|
+
.slice(i + 1)
|
|
133
|
+
.join("\n")
|
|
134
|
+
.trim();
|
|
135
|
+
}
|
|
136
|
+
return text; // no closing delimiter — leave the text unchanged
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether anything on the branch already carries `BINDING_HEADER` — the cold launch's initial
|
|
141
|
+
* prompt OR a prior warm injection. Serializing each entry is the robust, shape-agnostic scan: the
|
|
142
|
+
* header is a distinctive literal, so a substring hit means "already delivered on this branch".
|
|
143
|
+
*/
|
|
144
|
+
function branchHasHeader(branch: readonly BranchEntry[]): boolean {
|
|
145
|
+
return branch.some((entry) => JSON.stringify(entry).includes(BINDING_HEADER));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The launched stage's `stage:<id>` render, or `null` when there is no stage / nothing matches. */
|
|
149
|
+
function activeStageRender(cwd: string, branch: readonly BranchEntry[]): BindingRender | null {
|
|
150
|
+
const stage = rebuildWorkflowState(branch).stage;
|
|
151
|
+
if (!stage) return null;
|
|
152
|
+
return renderBindings(cwd, `stage:${stage}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Register warm-door binding delivery: Mechanism A's dedup-guarded `before_agent_start` injection
|
|
157
|
+
* plus a `context` strip mirroring planMode.ts / objectiveAuthor.ts (keep while the stage's
|
|
158
|
+
* bindings are live; strip the stale custom otherwise). Inert when nothing matches the stage;
|
|
159
|
+
* never throws. Mechanism B (`bindingSuffix`) is wired by the command modules themselves.
|
|
160
|
+
*/
|
|
161
|
+
export function registerBindingDelivery(pi: ExtensionAPI): void {
|
|
162
|
+
// Mechanism A — inject the launched stage's resolved bindings as a hidden context message,
|
|
163
|
+
// but ONLY when no entry on the branch already carries BINDING_HEADER (the cold door's initial
|
|
164
|
+
// prompt or a prior warm inject) — the cold↔warm idempotency guard.
|
|
165
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
166
|
+
const branch = branchOf(ctx);
|
|
167
|
+
const rendered = activeStageRender(ctx.cwd, branch);
|
|
168
|
+
if (rendered === null || rendered.text === null) return;
|
|
169
|
+
if (branchHasHeader(branch)) return;
|
|
170
|
+
for (const warning of rendered.warnings) console.error(`perk: ${warning}`);
|
|
171
|
+
return {
|
|
172
|
+
message: {
|
|
173
|
+
customType: BINDING_CONTEXT_TYPE,
|
|
174
|
+
content: rendered.text,
|
|
175
|
+
display: false,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Strip a STALE binding-context custom from the model window when the current stage no longer
|
|
181
|
+
// renders bindings (stage changed, or the overlay was removed) — the same hygiene planMode /
|
|
182
|
+
// objectiveAuthor apply to their authoring contexts. While the stage IS live we keep it (the
|
|
183
|
+
// model must see the nudge), and the dedup above relies on it persisting on the branch.
|
|
184
|
+
//
|
|
185
|
+
// Deliberately NARROWER than planMode: it strips ONLY the BINDING_CONTEXT_TYPE custom, never a
|
|
186
|
+
// user message carrying the header — a cold launch's initial prompt legitimately carries
|
|
187
|
+
// BINDING_HEADER and must survive in context.
|
|
188
|
+
pi.on("context", async (event, ctx) => {
|
|
189
|
+
const branch = branchOf(ctx);
|
|
190
|
+
const rendered = activeStageRender(ctx.cwd, branch);
|
|
191
|
+
if (rendered !== null && rendered.text !== null) return;
|
|
192
|
+
return {
|
|
193
|
+
messages: event.messages.filter((m) => {
|
|
194
|
+
const msg = m as { customType?: string };
|
|
195
|
+
return msg.customType !== BINDING_CONTEXT_TYPE;
|
|
196
|
+
}),
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The TS plane's reader for the shared skill-binding set (`shared/bindings.yaml`).
|
|
2
|
+
//
|
|
3
|
+
// Twin of perk/substrate/bindings.py: both planes parse the SAME bundled file (no codegen). This is
|
|
4
|
+
// the SECOND parsed cross-plane contract (the first being registry.yaml). A binding maps a
|
|
5
|
+
// `trigger` ("<kind>:<id>", kind ∈ {stage, command}) to a `skill` plus a per-binding
|
|
6
|
+
// delivery `mode` (nudge/transclude).
|
|
7
|
+
//
|
|
8
|
+
// The Python CLI is the authoritative validator (perk/substrate/bindings.py); this side does a thin
|
|
9
|
+
// structural parse only — no deep content validation here. The binding set is CONSUMED: the
|
|
10
|
+
// resolver (`resolveBindings`) and warm-door delivery (`extension/substrate/bindingDelivery.ts`)
|
|
11
|
+
// are 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 SkillBinding {
|
|
19
|
+
trigger: string;
|
|
20
|
+
kind: string;
|
|
21
|
+
targetId: string;
|
|
22
|
+
skill: string;
|
|
23
|
+
mode: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const BINDING_TRIGGER_KINDS = ["stage", "command"] as const;
|
|
27
|
+
export const BINDING_MODES = ["nudge", "transclude"] as const;
|
|
28
|
+
|
|
29
|
+
/** One shape finding for a user binding (registry-free; mirror of Python's `Issue`). */
|
|
30
|
+
export interface BindingIssue {
|
|
31
|
+
where: string;
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The effective binding set after overlaying user bindings onto shipped defaults. */
|
|
36
|
+
export interface ResolvedBindings {
|
|
37
|
+
bindings: SkillBinding[];
|
|
38
|
+
issues: BindingIssue[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Split a `"<kind>:<id>"` trigger on the first `:`. No colon -> `["", ""]`. */
|
|
42
|
+
function splitTrigger(trigger: string): [string, string] {
|
|
43
|
+
const idx = trigger.indexOf(":");
|
|
44
|
+
if (idx === -1) return ["", ""];
|
|
45
|
+
return [trigger.slice(0, idx), trigger.slice(idx + 1)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Parse the bundled `bindings.yaml`. Throws on a missing file or unexpected shape. */
|
|
49
|
+
export function loadDefaultBindings(): SkillBinding[] {
|
|
50
|
+
const path = join(sharedDir(), "bindings.yaml");
|
|
51
|
+
const data = parse(readFileSync(path, "utf8")) as unknown;
|
|
52
|
+
|
|
53
|
+
if (typeof data !== "object" || data === null) {
|
|
54
|
+
throw new Error(`perk: ${path} is not a mapping`);
|
|
55
|
+
}
|
|
56
|
+
const record = data as Record<string, unknown>;
|
|
57
|
+
const bindings = record.bindings;
|
|
58
|
+
if (!Array.isArray(bindings)) {
|
|
59
|
+
throw new Error(`perk: ${path} has no bindings`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return bindings.map((raw) => {
|
|
63
|
+
const entry = raw as Record<string, unknown>;
|
|
64
|
+
const trigger = typeof entry.trigger === "string" ? entry.trigger : "";
|
|
65
|
+
const [kind, targetId] = splitTrigger(trigger);
|
|
66
|
+
return {
|
|
67
|
+
trigger,
|
|
68
|
+
kind,
|
|
69
|
+
targetId,
|
|
70
|
+
skill: typeof entry.skill === "string" ? entry.skill : "",
|
|
71
|
+
mode: typeof entry.mode === "string" ? entry.mode : "",
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse `.pi/perk.toml` `[[bindings]]` rows (string tables) into `SkillBinding`s. Tolerant like
|
|
78
|
+
* the YAML reader: absent/ill-typed fields become empty strings so the *resolver* reports them.
|
|
79
|
+
*/
|
|
80
|
+
export function parseUserBindings(rows: Array<Record<string, string>>): SkillBinding[] {
|
|
81
|
+
return rows.map((row) => {
|
|
82
|
+
const trigger = typeof row.trigger === "string" ? row.trigger : "";
|
|
83
|
+
const [kind, targetId] = splitTrigger(trigger);
|
|
84
|
+
return {
|
|
85
|
+
trigger,
|
|
86
|
+
kind,
|
|
87
|
+
targetId,
|
|
88
|
+
skill: typeof row.skill === "string" ? row.skill : "",
|
|
89
|
+
mode: typeof row.mode === "string" ? row.mode : "",
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Shape issues for a *single* binding (skill/mode/trigger well-formedness). Registry-free —
|
|
96
|
+
* target-existence is `doctor`'s job. Duplicate-trigger detection is the caller's.
|
|
97
|
+
*/
|
|
98
|
+
function bindingIssues(binding: SkillBinding): BindingIssue[] {
|
|
99
|
+
const issues: BindingIssue[] = [];
|
|
100
|
+
const where = binding.trigger || "bindings";
|
|
101
|
+
|
|
102
|
+
if (!binding.skill) issues.push({ where, message: "missing `skill`" });
|
|
103
|
+
if (!(BINDING_MODES as readonly string[]).includes(binding.mode)) {
|
|
104
|
+
issues.push({ where, message: `\`mode\` must be one of ${BINDING_MODES.join(", ")}` });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!binding.trigger) {
|
|
108
|
+
issues.push({ where: "bindings", message: "a binding is missing its `trigger`" });
|
|
109
|
+
} else if (!binding.trigger.includes(":")) {
|
|
110
|
+
issues.push({ where, message: "`trigger` must be of the form `<kind>:<id>`" });
|
|
111
|
+
} else if (!(BINDING_TRIGGER_KINDS as readonly string[]).includes(binding.kind)) {
|
|
112
|
+
issues.push({
|
|
113
|
+
where,
|
|
114
|
+
message: `\`trigger\` kind must be one of ${BINDING_TRIGGER_KINDS.join(", ")}`,
|
|
115
|
+
});
|
|
116
|
+
} else if (!binding.targetId) {
|
|
117
|
+
issues.push({ where, message: "`trigger` has an empty `<id>`" });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return issues;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Return every shape issue across a user binding set (empty == valid); duplicates included. */
|
|
124
|
+
export function validateUserBindings(bindings: SkillBinding[]): BindingIssue[] {
|
|
125
|
+
const issues: BindingIssue[] = [];
|
|
126
|
+
const seen = new Set<string>();
|
|
127
|
+
for (const binding of bindings) {
|
|
128
|
+
issues.push(...bindingIssues(binding));
|
|
129
|
+
if (binding.trigger) {
|
|
130
|
+
if (seen.has(binding.trigger)) {
|
|
131
|
+
issues.push({ where: binding.trigger, message: "duplicate `trigger`" });
|
|
132
|
+
}
|
|
133
|
+
seen.add(binding.trigger);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return issues;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Overlay user bindings onto shipped defaults (trigger-keyed; pure). Defaults are trusted (not
|
|
141
|
+
* re-validated). Each user binding is applied iff it is shape-valid AND its trigger was not already
|
|
142
|
+
* applied by an earlier user binding; otherwise it is dropped and its issue recorded. An applied
|
|
143
|
+
* binding replaces in place the default with the same trigger, or appends at a new trigger — so the
|
|
144
|
+
* resolved set has unique triggers by construction. Target-existence stays `doctor`.
|
|
145
|
+
*/
|
|
146
|
+
export function resolveBindings(
|
|
147
|
+
userBindings: SkillBinding[],
|
|
148
|
+
defaults: SkillBinding[] = loadDefaultBindings(),
|
|
149
|
+
): ResolvedBindings {
|
|
150
|
+
const resolved = [...defaults];
|
|
151
|
+
const index = new Map<string, number>();
|
|
152
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
153
|
+
const entry = resolved[i];
|
|
154
|
+
if (entry) index.set(entry.trigger, i);
|
|
155
|
+
}
|
|
156
|
+
const issues: BindingIssue[] = [];
|
|
157
|
+
const applied = new Set<string>();
|
|
158
|
+
|
|
159
|
+
for (const binding of userBindings) {
|
|
160
|
+
const shapeIssues = bindingIssues(binding);
|
|
161
|
+
if (shapeIssues.length > 0) {
|
|
162
|
+
issues.push(...shapeIssues);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (applied.has(binding.trigger)) {
|
|
166
|
+
issues.push({ where: binding.trigger, message: "duplicate `trigger`" });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
applied.add(binding.trigger);
|
|
170
|
+
const at = index.get(binding.trigger);
|
|
171
|
+
if (at !== undefined) {
|
|
172
|
+
resolved[at] = binding;
|
|
173
|
+
} else {
|
|
174
|
+
index.set(binding.trigger, resolved.length);
|
|
175
|
+
resolved.push(binding);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { bindings: resolved, issues };
|
|
180
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// `.pi/workflow/` cache-tier I/O — the TS twin of perk/state/cache.py (contracts.md §8.1).
|
|
2
|
+
//
|
|
3
|
+
// Both planes read and write the SAME files; the cross-plane contract is the *files*, not a
|
|
4
|
+
// shared module. State-tiering primitives only — no workflow semantics. Imports use no
|
|
5
|
+
// relative paths (only node builtins), so this module loads cleanly under `node --test`.
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export interface Handoff {
|
|
11
|
+
run_id: string;
|
|
12
|
+
consumed: boolean;
|
|
13
|
+
mode?: string;
|
|
14
|
+
/** The registry stage id the cold launch primed (e.g. `objective-author`). */
|
|
15
|
+
stage?: string;
|
|
16
|
+
pi_session_id?: string;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function workflowDir(cwd: string): string {
|
|
21
|
+
return join(cwd, ".pi", "workflow");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// --- handoff -----------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export function handoffPath(cwd: string, runId: string): string {
|
|
27
|
+
return join(workflowDir(cwd), "handoff", `${runId}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readHandoff(cwd: string, runId: string): Handoff | null {
|
|
31
|
+
const path = handoffPath(cwd, runId);
|
|
32
|
+
if (!existsSync(path)) return null;
|
|
33
|
+
return JSON.parse(readFileSync(path, "utf8")) as Handoff;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Mark a handoff consumed (idempotent); a no-op when absent. Keeps the file (audit + GC). */
|
|
37
|
+
export function markHandoffConsumed(
|
|
38
|
+
cwd: string,
|
|
39
|
+
runId: string,
|
|
40
|
+
opts: { piSessionId?: string } = {},
|
|
41
|
+
): void {
|
|
42
|
+
const data = readHandoff(cwd, runId);
|
|
43
|
+
if (data === null) return;
|
|
44
|
+
data.consumed = true;
|
|
45
|
+
if (opts.piSessionId !== undefined) data.pi_session_id = opts.piSessionId;
|
|
46
|
+
writeFileSync(handoffPath(cwd, runId), `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- scratch -----------------------------------------------------------------------------
|
|
50
|
+
//
|
|
51
|
+
// This module is the INTERIOR path-primitive seam for scratch/session-data (contracts.md §8.1):
|
|
52
|
+
// production code never hand-builds the `scratch`/`runs` path segments
|
|
53
|
+
// outside this module (guard-tested by cacheGuard.test.ts; the ctx-level current-run seam is
|
|
54
|
+
// sessionData.ts and the exterior twin is perk/state/cache.py).
|
|
55
|
+
|
|
56
|
+
export function scratchDir(cwd: string): string {
|
|
57
|
+
return join(workflowDir(cwd), "scratch");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function runScratchDir(cwd: string, runId: string): string {
|
|
61
|
+
return join(scratchDir(cwd), "runs", runId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The session data dir for a run — a dedicated `data/` subdir so
|
|
66
|
+
* run-scoped session artifacts never overlap perk machine records (dispatch.json,
|
|
67
|
+
* events.ndjson, ci-*.md) living directly in the run dir. Pure path — created lazily by the
|
|
68
|
+
* sessionData.ts write helpers.
|
|
69
|
+
*/
|
|
70
|
+
export function sessionDataDir(cwd: string, runId: string): string {
|
|
71
|
+
return join(runScratchDir(cwd, runId), "data");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function ensureRunScratch(cwd: string, runId: string): string {
|
|
75
|
+
const dir = runScratchDir(cwd, runId);
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
return dir;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The run-scoped structured run-event stream (contracts §8.12) — an NDJSON file under the
|
|
82
|
+
* gitignored run scratch dir. Co-located with the run's read-only-child scratch so a runner/reader
|
|
83
|
+
* finds all run artifacts under one dir.
|
|
84
|
+
*/
|
|
85
|
+
export function runEventsPath(cwd: string, runId: string): string {
|
|
86
|
+
return join(runScratchDir(cwd, runId), "events.ndjson");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Names of all run scratch dirs (used to enumerate fork siblings). */
|
|
90
|
+
export function listRunIds(cwd: string): string[] {
|
|
91
|
+
const dir = join(scratchDir(cwd), "runs");
|
|
92
|
+
if (!existsSync(dir)) return [];
|
|
93
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
94
|
+
.filter((entry) => entry.isDirectory())
|
|
95
|
+
.map((entry) => entry.name);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- plan-ref: the active plan->branch ref pointer (plan-ref.json) -----------------------
|
|
99
|
+
|
|
100
|
+
/** The provider-agnostic plan ref (contracts.md §8.4); the TS twin of perk.plan.PlanRef. */
|
|
101
|
+
export interface PlanRef {
|
|
102
|
+
provider: string;
|
|
103
|
+
pr_id: string;
|
|
104
|
+
url: string;
|
|
105
|
+
labels: string[];
|
|
106
|
+
objective_id: string | null;
|
|
107
|
+
// The pinned target branch; Python-owned, parity-only on the warm plane.
|
|
108
|
+
base?: string | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function planRefPath(cwd: string): string {
|
|
112
|
+
return join(workflowDir(cwd), "plan-ref.json");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function readPlanRef(cwd: string): PlanRef | null {
|
|
116
|
+
const path = planRefPath(cwd);
|
|
117
|
+
if (!existsSync(path)) return null;
|
|
118
|
+
return JSON.parse(readFileSync(path, "utf8")) as PlanRef;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function writePlanRef(cwd: string, ref: PlanRef): void {
|
|
122
|
+
mkdirSync(workflowDir(cwd), { recursive: true });
|
|
123
|
+
writeFileSync(planRefPath(cwd), `${JSON.stringify(ref, null, 2)}\n`, "utf8");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- plan body cache (`cache.plan`) ------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The materialized plan-body cache (`cache.plan`, contracts §8.1). Written by the Python cold door
|
|
130
|
+
* (`perk implement` → `launch.materialize_plan_body`) when it positions the worktree; read here so
|
|
131
|
+
* in-session checkpoints seed from its `## Steps` list (inert when absent).
|
|
132
|
+
*/
|
|
133
|
+
export function planBodyPath(cwd: string): string {
|
|
134
|
+
return join(workflowDir(cwd), "plan.md");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function readPlanBody(cwd: string): string | null {
|
|
138
|
+
const path = planBodyPath(cwd);
|
|
139
|
+
if (!existsSync(path)) return null;
|
|
140
|
+
return readFileSync(path, "utf8");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- markers (existence-only) ------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/** The land->learn semaphore; the TS twin of perk.state.cache.PENDING_LEARN. */
|
|
146
|
+
export const PENDING_LEARN = "pending-learn";
|
|
147
|
+
|
|
148
|
+
export function markerPath(cwd: string, name: string): string {
|
|
149
|
+
return join(workflowDir(cwd), "markers", name);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function setMarker(cwd: string, name: string): void {
|
|
153
|
+
mkdirSync(join(workflowDir(cwd), "markers"), { recursive: true });
|
|
154
|
+
writeFileSync(markerPath(cwd, name), "", "utf8");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function hasMarker(cwd: string, name: string): boolean {
|
|
158
|
+
return existsSync(markerPath(cwd, name));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function clearMarker(cwd: string, name: string): void {
|
|
162
|
+
rmSync(markerPath(cwd, name), { force: true });
|
|
163
|
+
}
|