@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.
Files changed (98) hide show
  1. package/README.md +105 -0
  2. package/extension/adapters/planAdapterPlannotator.ts +269 -0
  3. package/extension/adapters/planAdapterTombell.ts +147 -0
  4. package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
  5. package/extension/checkpoints/checkpoints.ts +542 -0
  6. package/extension/checkpoints/planSteps.ts +108 -0
  7. package/extension/doors/address.ts +360 -0
  8. package/extension/doors/askUser.ts +194 -0
  9. package/extension/doors/ciExecutor.ts +583 -0
  10. package/extension/doors/land.ts +222 -0
  11. package/extension/doors/learn.ts +235 -0
  12. package/extension/doors/learnDocs.ts +99 -0
  13. package/extension/doors/lifecycleGates.ts +171 -0
  14. package/extension/doors/prReview.ts +339 -0
  15. package/extension/doors/ready.ts +86 -0
  16. package/extension/doors/selfcheck.ts +155 -0
  17. package/extension/doors/submit.ts +253 -0
  18. package/extension/factories/objective.ts +240 -0
  19. package/extension/factories/objectiveAuthor.ts +114 -0
  20. package/extension/factories/objectiveDraft.ts +343 -0
  21. package/extension/factories/objectivePlan.ts +838 -0
  22. package/extension/factories/objectiveSave.ts +285 -0
  23. package/extension/factories/planDraft.ts +140 -0
  24. package/extension/factories/planMode.ts +214 -0
  25. package/extension/factories/planReview.ts +644 -0
  26. package/extension/factories/planSave.ts +589 -0
  27. package/extension/factories/planTitle.ts +123 -0
  28. package/extension/index.ts +459 -0
  29. package/extension/substrate/bindingDelivery.ts +199 -0
  30. package/extension/substrate/bindings.ts +180 -0
  31. package/extension/substrate/cache.ts +163 -0
  32. package/extension/substrate/coldDoor.ts +226 -0
  33. package/extension/substrate/config.ts +339 -0
  34. package/extension/substrate/miniYaml.ts +262 -0
  35. package/extension/substrate/prompts.ts +35 -0
  36. package/extension/substrate/providers.ts +177 -0
  37. package/extension/substrate/registry.ts +62 -0
  38. package/extension/substrate/resources.ts +41 -0
  39. package/extension/substrate/result.ts +72 -0
  40. package/extension/substrate/runId.ts +49 -0
  41. package/extension/substrate/sessionData.ts +229 -0
  42. package/extension/substrate/structuredOutput.ts +141 -0
  43. package/extension/substrate/toolGating.ts +400 -0
  44. package/extension/substrate/toolParams.ts +106 -0
  45. package/extension/substrate/workflowState.ts +233 -0
  46. package/extension/surfaces/footerProvider.ts +43 -0
  47. package/extension/surfaces/report.ts +34 -0
  48. package/extension/surfaces/surfaces.ts +460 -0
  49. package/extension/vendor/btw/btw.ts +964 -0
  50. package/extension/vendor/btw/core.ts +153 -0
  51. package/extension/vendor/whimsical/whimsical.ts +485 -0
  52. package/extension/worker/readOnlySession.ts +282 -0
  53. package/extension/worker/worker.ts +765 -0
  54. package/extension/workerMain.ts +150 -0
  55. package/package.json +55 -0
  56. package/prompts/README.md +15 -0
  57. package/prompts/_fixtures/cases.yaml +140 -0
  58. package/prompts/_fixtures/golden/address-action-model.txt +10 -0
  59. package/prompts/_fixtures/golden/address-action.txt +10 -0
  60. package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
  61. package/prompts/_fixtures/golden/address-preview.txt +6 -0
  62. package/prompts/_fixtures/golden/hello.txt +1 -0
  63. package/prompts/_fixtures/golden/implement-github.txt +8 -0
  64. package/prompts/_fixtures/golden/learn-docs.txt +8 -0
  65. package/prompts/_fixtures/golden/learn-github.txt +11 -0
  66. package/prompts/_fixtures/golden/learn-linear.txt +11 -0
  67. package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
  68. package/prompts/_fixtures/golden/learn-other.txt +8 -0
  69. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
  70. package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
  71. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
  72. package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
  73. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
  74. package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
  75. package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
  76. package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
  77. package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
  78. package/prompts/_fixtures/golden/with_include.txt +4 -0
  79. package/prompts/_fixtures/templates/_greeting.md +1 -0
  80. package/prompts/_fixtures/templates/hello.md +1 -0
  81. package/prompts/_fixtures/templates/with_include.md +4 -0
  82. package/prompts/common/objective-read/linear.md +1 -0
  83. package/prompts/common/plan-read/github.md +1 -0
  84. package/prompts/common/plan-read/linear.md +1 -0
  85. package/prompts/common/plan-read/other.md +1 -0
  86. package/prompts/stages/address/action.md +10 -0
  87. package/prompts/stages/address/preview.md +6 -0
  88. package/prompts/stages/implement.md +8 -0
  89. package/prompts/stages/learn-docs.md +8 -0
  90. package/prompts/stages/learn.md +21 -0
  91. package/prompts/stages/objective-plan/guidance.md +12 -0
  92. package/prompts/stages/objective-plan/seed.md +20 -0
  93. package/shared/README.md +29 -0
  94. package/shared/bindings.yaml +64 -0
  95. package/shared/contracts-history.md +403 -0
  96. package/shared/contracts.md +4172 -0
  97. package/shared/providers.yaml +221 -0
  98. 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
+ }