@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,123 @@
|
|
|
1
|
+
// The first consumer of the structured-output substrate: a best-effort, fail-safe
|
|
2
|
+
// LLM-generated GitHub issue title for a perk plan. When a plan is saved WITHOUT an explicit title,
|
|
3
|
+
// the warm door asks the session model for a structured `{ title, category }` object and forwards
|
|
4
|
+
// the sanitized title to `perk plan save --title`. Every failure mode (no model, unresolved auth,
|
|
5
|
+
// the offline gate, a model error, no tool call, schema-invalid args, an empty sanitized title)
|
|
6
|
+
// returns `null`, so the cold door's deterministic `plan.derive_title` fallback takes over and a
|
|
7
|
+
// save is never blocked. The `category` field exercises multi-field structured output but is
|
|
8
|
+
// intentionally ignored for now.
|
|
9
|
+
|
|
10
|
+
import { type Static, StringEnum, Type } from "@earendil-works/pi-ai";
|
|
11
|
+
import {
|
|
12
|
+
completeStructured,
|
|
13
|
+
type ModelAuthContext,
|
|
14
|
+
resolveModelAuth,
|
|
15
|
+
} from "../substrate/structuredOutput.ts";
|
|
16
|
+
|
|
17
|
+
/** The structured result schema. `StringEnum` (not `Type.Enum`) per pi-ai's Google-compat guidance. */
|
|
18
|
+
const PlanTitleSchema = Type.Object({
|
|
19
|
+
title: Type.String({
|
|
20
|
+
minLength: 1,
|
|
21
|
+
maxLength: 120,
|
|
22
|
+
description:
|
|
23
|
+
"A concise, imperative GitHub issue title for the plan (no trailing period, no markdown).",
|
|
24
|
+
}),
|
|
25
|
+
category: StringEnum(["feature", "fix", "refactor", "docs", "test", "chore"], {
|
|
26
|
+
description: "The dominant kind of change (currently informational only).",
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export type PlanTitleResult = Static<typeof PlanTitleSchema>;
|
|
31
|
+
|
|
32
|
+
/** Cap the plan markdown handed to the model (a title needs only the opening, not the whole plan). */
|
|
33
|
+
export const TITLE_INPUT_CHAR_CAP = 12000;
|
|
34
|
+
|
|
35
|
+
/** Max characters in the final issue title (mirrors the schema's `maxLength`). */
|
|
36
|
+
const TITLE_MAX_CHARS = 120;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalize a raw model title into a clean, single-line issue title, or `null` if empty after
|
|
40
|
+
* sanitizing. Pure: trims; strips a leading `"# "`; strips surrounding quotes/backticks; collapses
|
|
41
|
+
* internal whitespace/newlines to single spaces; truncates to ≤120 chars on a word boundary where
|
|
42
|
+
* practical.
|
|
43
|
+
*/
|
|
44
|
+
export function sanitizeTitle(raw: string): string | null {
|
|
45
|
+
let s = raw.trim();
|
|
46
|
+
if (!s) return null;
|
|
47
|
+
// Strip a leading ATX heading marker (the model sometimes echoes the plan's `# ` heading).
|
|
48
|
+
s = s.replace(/^#+\s*/, "");
|
|
49
|
+
if (!s) return null;
|
|
50
|
+
// Strip a single layer of surrounding quotes or backticks.
|
|
51
|
+
const pairs: [string, string][] = [
|
|
52
|
+
['"', '"'],
|
|
53
|
+
["'", "'"],
|
|
54
|
+
["`", "`"],
|
|
55
|
+
];
|
|
56
|
+
for (const [open, close] of pairs) {
|
|
57
|
+
if (s.length >= 2 && s.startsWith(open) && s.endsWith(close)) {
|
|
58
|
+
s = s.slice(1, -1).trim();
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Collapse any internal whitespace/newlines to single spaces.
|
|
63
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
64
|
+
if (!s) return null;
|
|
65
|
+
if (s.length > TITLE_MAX_CHARS) {
|
|
66
|
+
const head = s.slice(0, TITLE_MAX_CHARS);
|
|
67
|
+
const lastSpace = head.lastIndexOf(" ");
|
|
68
|
+
// Truncate on a word boundary when one exists past the halfway point; else a hard cut.
|
|
69
|
+
s = (lastSpace > TITLE_MAX_CHARS / 2 ? head.slice(0, lastSpace) : head).trim();
|
|
70
|
+
}
|
|
71
|
+
return s || null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The deterministic offline gate. `PERK_NO_LLM` (set by the test harness, never by the production
|
|
76
|
+
* `perk` CLI) disables title generation so tests stay fully offline regardless of ambient API keys.
|
|
77
|
+
* Pure and unit-testable.
|
|
78
|
+
*/
|
|
79
|
+
export function llmTitlesEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
80
|
+
return !env.PERK_NO_LLM;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Best-effort: generate a GitHub issue title for `planMarkdown` via the session model, or `null` on
|
|
85
|
+
* any failure / when the gate is on. Never throws. Reports a genuine model error
|
|
86
|
+
* (`console.error`) but stays non-fatal — the save proceeds and the cold door derives the title.
|
|
87
|
+
*/
|
|
88
|
+
export async function generatePlanTitle(
|
|
89
|
+
ctx: ModelAuthContext,
|
|
90
|
+
planMarkdown: string,
|
|
91
|
+
signal?: AbortSignal,
|
|
92
|
+
): Promise<string | null> {
|
|
93
|
+
if (!llmTitlesEnabled()) return null;
|
|
94
|
+
|
|
95
|
+
let auth: Awaited<ReturnType<typeof resolveModelAuth>>;
|
|
96
|
+
try {
|
|
97
|
+
auth = await resolveModelAuth(ctx);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (!auth.ok) return null; // silent: no model / no auth configured (fail-safe).
|
|
102
|
+
|
|
103
|
+
const outcome = await completeStructured({
|
|
104
|
+
model: auth.model,
|
|
105
|
+
schema: PlanTitleSchema,
|
|
106
|
+
toolName: "set_plan_title",
|
|
107
|
+
toolDescription: "Provide the chosen title and category for the plan.",
|
|
108
|
+
system: "You write concise, descriptive GitHub issue titles for software engineering plans.",
|
|
109
|
+
instruction:
|
|
110
|
+
"Read this implementation plan and choose a title and category. The title must be a concise, " +
|
|
111
|
+
"imperative phrase, at most ~70 characters, with no trailing period and no markdown.",
|
|
112
|
+
input: planMarkdown.slice(0, TITLE_INPUT_CHAR_CAP),
|
|
113
|
+
apiKey: auth.apiKey,
|
|
114
|
+
headers: auth.headers,
|
|
115
|
+
signal,
|
|
116
|
+
});
|
|
117
|
+
if (!outcome.ok || !outcome.value) {
|
|
118
|
+
// Report (don't swallow) a genuine model error; the save still proceeds.
|
|
119
|
+
if (!outcome.ok) console.error(`perk: plan-title — ${outcome.error}`);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return sanitizeTitle(outcome.value.title);
|
|
123
|
+
}
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// perk Pi extension — the session *interior*.
|
|
2
|
+
//
|
|
3
|
+
// The tier-3 session-state mechanics (contracts.md §8.2/§8.3): claim PERK_RUN_ID on
|
|
4
|
+
// `session_start` (verified-linkage), rebuild `perk:workflow-state` on `session_start` AND
|
|
5
|
+
// `session_tree` (per-field LWW), and derive a child run_id on fork.
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { basename, join } from "node:path";
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { registerPlanAdapterPlannotator } from "./adapters/planAdapterPlannotator.ts";
|
|
11
|
+
import { registerPlanAdapterTombell } from "./adapters/planAdapterTombell.ts";
|
|
12
|
+
import { registerTodoAdapterJuicesharp } from "./adapters/todoAdapterJuicesharp.ts";
|
|
13
|
+
import { registerCheckpoints } from "./checkpoints/checkpoints.ts";
|
|
14
|
+
import { registerAddress } from "./doors/address.ts";
|
|
15
|
+
import { registerAskUser } from "./doors/askUser.ts";
|
|
16
|
+
import { registerCiExecutor } from "./doors/ciExecutor.ts";
|
|
17
|
+
import { registerLand } from "./doors/land.ts";
|
|
18
|
+
import { registerLearn } from "./doors/learn.ts";
|
|
19
|
+
import { registerLearnDocs } from "./doors/learnDocs.ts";
|
|
20
|
+
import { registerLifecycleGates } from "./doors/lifecycleGates.ts";
|
|
21
|
+
import { registerPrReview } from "./doors/prReview.ts";
|
|
22
|
+
import { registerReady } from "./doors/ready.ts";
|
|
23
|
+
import { registerSelfcheck } from "./doors/selfcheck.ts";
|
|
24
|
+
import { registerSubmit } from "./doors/submit.ts";
|
|
25
|
+
import { registerObjective } from "./factories/objective.ts";
|
|
26
|
+
import { registerObjectiveAuthor } from "./factories/objectiveAuthor.ts";
|
|
27
|
+
import { registerObjectiveDraft } from "./factories/objectiveDraft.ts";
|
|
28
|
+
import { registerObjectivePlan } from "./factories/objectivePlan.ts";
|
|
29
|
+
import { registerObjectiveSave } from "./factories/objectiveSave.ts";
|
|
30
|
+
import { registerPlanDraft } from "./factories/planDraft.ts";
|
|
31
|
+
import { registerPlanMode } from "./factories/planMode.ts";
|
|
32
|
+
import { registerPlanReview } from "./factories/planReview.ts";
|
|
33
|
+
import { registerPlanSave } from "./factories/planSave.ts";
|
|
34
|
+
import { registerBindingDelivery } from "./substrate/bindingDelivery.ts";
|
|
35
|
+
import {
|
|
36
|
+
ensureRunScratch,
|
|
37
|
+
markHandoffConsumed,
|
|
38
|
+
readHandoff,
|
|
39
|
+
readPlanRef,
|
|
40
|
+
setMarker,
|
|
41
|
+
} from "./substrate/cache.ts";
|
|
42
|
+
import { loadRegistry, type Registry, stageConsumesPlanRef } from "./substrate/registry.ts";
|
|
43
|
+
import { perkVersion, sharedDir } from "./substrate/resources.ts";
|
|
44
|
+
import { mintRunId } from "./substrate/runId.ts";
|
|
45
|
+
import { registerToolGating } from "./substrate/toolGating.ts";
|
|
46
|
+
import {
|
|
47
|
+
appendWorkflowState,
|
|
48
|
+
branchOf,
|
|
49
|
+
decideClaim,
|
|
50
|
+
planRefsEqual,
|
|
51
|
+
rebuildWorkflowState,
|
|
52
|
+
resolveRunStage,
|
|
53
|
+
WORKFLOW_STATE_TYPE,
|
|
54
|
+
type WorkflowState,
|
|
55
|
+
} from "./substrate/workflowState.ts";
|
|
56
|
+
import { isPerkFooterReferenceSelected } from "./surfaces/footerProvider.ts";
|
|
57
|
+
import { report } from "./surfaces/report.ts";
|
|
58
|
+
import { createPerkStatus, installPerkFooter } from "./surfaces/surfaces.ts";
|
|
59
|
+
import { registerBtw } from "./vendor/btw/btw.ts";
|
|
60
|
+
import { registerWhimsical } from "./vendor/whimsical/whimsical.ts";
|
|
61
|
+
|
|
62
|
+
// Cross-plane proof marker (TS writes via cache.ts; the Python helper reads it — gate check 3).
|
|
63
|
+
const T3_MARKER = "t3-extension-cache-write";
|
|
64
|
+
|
|
65
|
+
function writeT3Sentinel(
|
|
66
|
+
cwd: string,
|
|
67
|
+
source: string,
|
|
68
|
+
state: WorkflowState,
|
|
69
|
+
runMode: string | null,
|
|
70
|
+
): void {
|
|
71
|
+
try {
|
|
72
|
+
const dir = join(cwd, ".pi", "workflow");
|
|
73
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
74
|
+
writeFileSync(
|
|
75
|
+
join(dir, ".perk-t3.json"),
|
|
76
|
+
`${JSON.stringify({
|
|
77
|
+
source,
|
|
78
|
+
// The launch *workflow* mode (read-only/read-write) — drives tool gating.
|
|
79
|
+
run_id: state.run_id ?? null,
|
|
80
|
+
mode: state.mode ?? null,
|
|
81
|
+
// The Pi *run* mode (tui/rpc/json/print) — observability `hasUI` can't express. Distinct
|
|
82
|
+
// from the workflow `mode` above; recorded straight from `ctx.mode`.
|
|
83
|
+
run_mode: runMode,
|
|
84
|
+
predecessor: state.predecessor ?? null,
|
|
85
|
+
pi_session_id: state.pi_session_id ?? null,
|
|
86
|
+
active_plan_ref: state.active_plan_ref ?? null,
|
|
87
|
+
})}\n`,
|
|
88
|
+
"utf8",
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
// never throw from a probe
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default function (pi: ExtensionAPI) {
|
|
96
|
+
const version = perkVersion();
|
|
97
|
+
|
|
98
|
+
// The read-only tool-gating primitive. Attaches to perk:workflow-state.mode; synced on
|
|
99
|
+
// both session_start AND session_tree below. enter/exit are the surface the gated stages consume.
|
|
100
|
+
const gating = registerToolGating(pi);
|
|
101
|
+
|
|
102
|
+
// Vendored `btw`: a `/btw` human-only side-chat popover backed by an isolated in-memory
|
|
103
|
+
// AgentSession. Takes `gating` for the gate-mirror — its side-session toolset + cache key follow
|
|
104
|
+
// perk's read-only gate (`sideSessionTools`), so the isolated session never bypasses the read-only
|
|
105
|
+
// guarantee. Its `ctx.ui.custom` overlay is the ONE sanctioned charter exception (§6 D6): human-
|
|
106
|
+
// invoked only, `hasUI`-gated, no model tool, not a stage/door — never machine-reachable.
|
|
107
|
+
registerBtw(pi, gating);
|
|
108
|
+
|
|
109
|
+
// Vendored `whimsical`: flavors pi's default working-message label with a random phrase per
|
|
110
|
+
// turn, via the headless-no-op `setWorkingMessage` surfaces seam. Always on, no config toggle.
|
|
111
|
+
registerWhimsical(pi);
|
|
112
|
+
|
|
113
|
+
// perk-owned plan mode: the `/plan` + Ctrl+Alt+P + `--plan` toggle surface over the
|
|
114
|
+
// read-only gate, plus the plan-authoring context injection. perk owns plan mode end-to-end now (the
|
|
115
|
+
// borrowed `@tombell/pi-plan` is retired).
|
|
116
|
+
registerPlanMode(pi, gating);
|
|
117
|
+
|
|
118
|
+
// The first 3rd-party plan adapter: a perk-owned, injection-only bridge that re-enables
|
|
119
|
+
// `@tombell/pi-plan` as a real plan provider. Always registered, but INERT unless
|
|
120
|
+
// `[providers] plan = "tombell-plan"`; it directs the foreign free-form prose `/plan` surface into
|
|
121
|
+
// perk's canonical `plan_save` → `cache.plan-ref` contract. It needs no `gating` (Invariant 1: the
|
|
122
|
+
// read-only gate stays perk's, engaged by the cold-door launch — the shim never arbitrates tools).
|
|
123
|
+
registerPlanAdapterTombell(pi);
|
|
124
|
+
|
|
125
|
+
// The second 3rd-party plan adapter — AUGMENT posture: `@plannotator/pi-extension` contributes
|
|
126
|
+
// its browser plan-review UI while perk's plan surface + gate stay (planMode skips only
|
|
127
|
+
// `--plan`/`Ctrl+Alt+P` under this selection). Always registered, but INERT unless
|
|
128
|
+
// `[providers] plan = "plannotator-plan"`. Injection-only — the `plan_review`
|
|
129
|
+
// tool moved to planReview.ts (below), which dispatches to this adapter's event-bus bridge
|
|
130
|
+
// when plannotator is selected.
|
|
131
|
+
registerPlanAdapterPlannotator(pi);
|
|
132
|
+
|
|
133
|
+
// `plan_review`, perk's UNIVERSAL review door: plannotator-selected → the event-bus
|
|
134
|
+
// bridge; ANY other selection → the first-party in-TUI editor review. It takes `gating` only to
|
|
135
|
+
// COMPOSE the approvalSave seam on an APPROVED review (auto-save → D1a gate exit) — Invariant 1
|
|
136
|
+
// holds: the door composes the gate through the seam, never owns it.
|
|
137
|
+
registerPlanReview(pi, gating);
|
|
138
|
+
|
|
139
|
+
// Objective-author context injection (the objective mirror of plan mode's authoring
|
|
140
|
+
// half). Keyed off (read-only gate AND stage === objective-author); planMode defers to it.
|
|
141
|
+
registerObjectiveAuthor(pi, gating);
|
|
142
|
+
let sharedOk = false;
|
|
143
|
+
try {
|
|
144
|
+
sharedDir();
|
|
145
|
+
sharedOk = true;
|
|
146
|
+
} catch {
|
|
147
|
+
sharedOk = false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let registry: Registry | null = null;
|
|
151
|
+
let registryStages = -1;
|
|
152
|
+
try {
|
|
153
|
+
registry = loadRegistry();
|
|
154
|
+
registryStages = registry.stages.length;
|
|
155
|
+
} catch {
|
|
156
|
+
registry = null;
|
|
157
|
+
registryStages = -1;
|
|
158
|
+
}
|
|
159
|
+
const registryOk = registryStages > 0;
|
|
160
|
+
|
|
161
|
+
// The composed `perk` status handle (charter D2): one slot, ordered objective →
|
|
162
|
+
// checkpoints segments. Created once here (no hidden module state) and threaded into the two
|
|
163
|
+
// segment publishers below; the footer reads it back via get/subscribe.
|
|
164
|
+
const perkStatus = createPerkStatus();
|
|
165
|
+
|
|
166
|
+
// Install the perk-owned footer once per session (charter D2/D7). Once-only: pi's
|
|
167
|
+
// dispose contract for a REPLACED footer factory is unverified, so re-installing on every
|
|
168
|
+
// session_start (reload) could leak the previous handle subscription.
|
|
169
|
+
let footerInstalled = false;
|
|
170
|
+
|
|
171
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
172
|
+
const branchEntries = () => branchOf(ctx);
|
|
173
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
174
|
+
const currentSessionId = sessionFile ? basename(sessionFile) : null;
|
|
175
|
+
|
|
176
|
+
// Headless-safe linkage failure: loud (notify if UI + stderr), non-fatal, leaves unclaimed.
|
|
177
|
+
const reportError = (message: string) => {
|
|
178
|
+
report(ctx, "workflow-state linkage error", "error", message, { alsoLog: true });
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const decision = decideClaim({
|
|
182
|
+
state: rebuildWorkflowState(branchEntries()),
|
|
183
|
+
currentSessionId,
|
|
184
|
+
envRunId: process.env.PERK_RUN_ID ?? null,
|
|
185
|
+
cwd: ctx.cwd,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
let resolved: WorkflowState = decision.action === "claim" ? {} : decision.state;
|
|
189
|
+
let minted = false;
|
|
190
|
+
|
|
191
|
+
if (decision.action === "claim") {
|
|
192
|
+
// Cold claim — establish before consume (strict).
|
|
193
|
+
const handoff = readHandoff(ctx.cwd, decision.runId);
|
|
194
|
+
if (handoff === null || handoff.run_id !== decision.runId) {
|
|
195
|
+
reportError(`handoff missing or mismatched for run ${decision.runId}`);
|
|
196
|
+
} else {
|
|
197
|
+
const data: WorkflowState = {
|
|
198
|
+
run_id: decision.runId,
|
|
199
|
+
pi_session_id: currentSessionId ?? undefined,
|
|
200
|
+
mode: handoff.mode,
|
|
201
|
+
// Record the launched stage so the interior can tell e.g. objective-author from plan
|
|
202
|
+
// (both are read-only) and inject the right authoring context (planMode vs objectiveAuthor).
|
|
203
|
+
stage: handoff.stage,
|
|
204
|
+
};
|
|
205
|
+
const okAppend = appendWorkflowState(pi, ctx, {
|
|
206
|
+
data,
|
|
207
|
+
field: "run_id",
|
|
208
|
+
expected: decision.runId,
|
|
209
|
+
scope: "workflow-state linkage error",
|
|
210
|
+
failure: `read-back failed for run ${decision.runId}`,
|
|
211
|
+
});
|
|
212
|
+
if (!okAppend) {
|
|
213
|
+
// do NOT consume
|
|
214
|
+
} else {
|
|
215
|
+
markHandoffConsumed(ctx.cwd, decision.runId, {
|
|
216
|
+
piSessionId: currentSessionId ?? undefined,
|
|
217
|
+
});
|
|
218
|
+
resolved = data;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} else if (decision.action === "fork") {
|
|
222
|
+
// Inherited a run_id from a different session file → isolate the child's scratch.
|
|
223
|
+
ensureRunScratch(ctx.cwd, decision.childRunId);
|
|
224
|
+
const data: WorkflowState = {
|
|
225
|
+
run_id: decision.childRunId,
|
|
226
|
+
pi_session_id: currentSessionId ?? undefined,
|
|
227
|
+
predecessor: decision.parentRunId,
|
|
228
|
+
mode: decision.state.mode,
|
|
229
|
+
};
|
|
230
|
+
pi.appendEntry(WORKFLOW_STATE_TYPE, data);
|
|
231
|
+
resolved = data;
|
|
232
|
+
} else if (decision.action === "none") {
|
|
233
|
+
// A warm session with no identity mints its own run_id so
|
|
234
|
+
// per-run state (the session data dir) can key off it. No disk artifacts —
|
|
235
|
+
// dirs are the accessor's job; provenance is recorded separately. A failed cold claim above never
|
|
236
|
+
// falls here (claim stays a loud unclaimed error).
|
|
237
|
+
const runId = mintRunId();
|
|
238
|
+
const data: WorkflowState = { run_id: runId, pi_session_id: currentSessionId ?? undefined };
|
|
239
|
+
const okAppend = appendWorkflowState(pi, ctx, {
|
|
240
|
+
data,
|
|
241
|
+
field: "run_id",
|
|
242
|
+
expected: runId,
|
|
243
|
+
scope: "workflow-state linkage error",
|
|
244
|
+
failure: `read-back failed for minted run ${runId}`,
|
|
245
|
+
});
|
|
246
|
+
if (okAppend) {
|
|
247
|
+
resolved = { ...decision.state, ...data };
|
|
248
|
+
minted = true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Plan-ref linkage (turn-2b §6, stage-gated): reconcile the cache.plan-ref file into
|
|
253
|
+
// active_plan_ref — but ONLY when the launched stage *consumes* the ref (its registry
|
|
254
|
+
// `requires`/`reads` list `cache.plan-ref`). That is the worktree binding stages
|
|
255
|
+
// (implement/submit/address/land/learn); the root `worktree: none` stages
|
|
256
|
+
// (plan/objective-plan/save) must NOT inherit the root *selector* into a fresh planning
|
|
257
|
+
// session. Idempotent by (provider, pr_id), strict read-back, headless-safe. Runs after the
|
|
258
|
+
// run_id claim so the run is settled first; the two append independent LWW fields.
|
|
259
|
+
// Reload/fork/tree (no launched stage) rely on the LWW rebuild — never re-read the file.
|
|
260
|
+
const linked = rebuildWorkflowState(branchEntries()).active_plan_ref ?? null;
|
|
261
|
+
const runStage = resolveRunStage(decision, ctx.cwd);
|
|
262
|
+
// Registry-missing is permissive when a stage is present, to preserve implement linkage.
|
|
263
|
+
const consumesPlanRef =
|
|
264
|
+
runStage !== null && (registry === null || stageConsumesPlanRef(registry, runStage));
|
|
265
|
+
if (consumesPlanRef) {
|
|
266
|
+
const cachedRef = readPlanRef(ctx.cwd);
|
|
267
|
+
if (cachedRef !== null) {
|
|
268
|
+
if (planRefsEqual(linked, cachedRef)) {
|
|
269
|
+
resolved = { ...resolved, active_plan_ref: linked };
|
|
270
|
+
} else {
|
|
271
|
+
if (
|
|
272
|
+
appendWorkflowState(pi, ctx, {
|
|
273
|
+
data: { active_plan_ref: cachedRef },
|
|
274
|
+
field: "active_plan_ref",
|
|
275
|
+
expected: cachedRef,
|
|
276
|
+
scope: "workflow-state linkage error",
|
|
277
|
+
failure: `plan-ref read-back failed for ${cachedRef.provider}:${cachedRef.pr_id}`,
|
|
278
|
+
equals: planRefsEqual,
|
|
279
|
+
})
|
|
280
|
+
) {
|
|
281
|
+
resolved = { ...resolved, active_plan_ref: cachedRef };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} else if (linked !== null) {
|
|
285
|
+
resolved = { ...resolved, active_plan_ref: linked };
|
|
286
|
+
}
|
|
287
|
+
} else if (linked !== null) {
|
|
288
|
+
// Non-consuming stage (or no launched stage): preserve any already-linked ref via LWW,
|
|
289
|
+
// but NEVER read the cache file — the root selector must not leak in.
|
|
290
|
+
resolved = { ...resolved, active_plan_ref: linked };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Reapply the read-only allowlist from the resolved mode. Fail-closed: if the sync throws,
|
|
294
|
+
// leave the gate as-is (a failed sync never opens it).
|
|
295
|
+
try {
|
|
296
|
+
gating.syncFromState(resolved.mode);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error(`perk: tool-gating sync failed on session_start — ${error}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Soft version-parity drift signal: pi can lazy-install / load a stale `npm:@mgiles/perk`, so the
|
|
302
|
+
// extension actually running may differ from the `perk` CLI that launched it. The local launch
|
|
303
|
+
// seam injects PERK_CLI_VERSION; compare it against this extension's own `perkVersion()`. Soft +
|
|
304
|
+
// non-fatal (warning), headless-safe via report(). No once-guard — may re-emit on reload, fine
|
|
305
|
+
// for a soft warning. Silent for ad-hoc `pi` (no env) and the self-repo (versions equal).
|
|
306
|
+
const cliVersion = (process.env.PERK_CLI_VERSION ?? "").trim();
|
|
307
|
+
if (cliVersion && version && cliVersion !== version) {
|
|
308
|
+
report(
|
|
309
|
+
ctx,
|
|
310
|
+
"version parity",
|
|
311
|
+
"warning",
|
|
312
|
+
`the loaded @mgiles/perk extension (v${version}) differs from the running perk CLI ` +
|
|
313
|
+
`(v${cliVersion}) — run 'perk doctor --fix' to reinstall the pinned version`,
|
|
314
|
+
{ alsoLog: true },
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Charter D7: perk identity is standing footer state, not a transition — the
|
|
319
|
+
// `v<version> loaded` toast (and its headless stderr mirror) is retired. D5 is rescinded:
|
|
320
|
+
// perk keeps pi's default working indicator (no setWorkingIndicator call anywhere).
|
|
321
|
+
// Footer-seam install-site vacating: under a foreign `[providers] footer` selection perk does
|
|
322
|
+
// NOT install its own footer, leaving the foreign footer (`pi-powerline-footer` / `pi-bar`) as
|
|
323
|
+
// the sole footer surface. perk's objective/checkpoints progress still reaches it via the
|
|
324
|
+
// composed `perk` setStatus slot. Fail-safe: any config-read error resolves to install.
|
|
325
|
+
if (ctx.hasUI && !footerInstalled && isPerkFooterReferenceSelected(ctx.cwd)) {
|
|
326
|
+
installPerkFooter(ctx, {
|
|
327
|
+
identity: `perk v${version}`,
|
|
328
|
+
status: perkStatus,
|
|
329
|
+
getModelId: () => ctx.model?.id ?? null,
|
|
330
|
+
getContext: () => {
|
|
331
|
+
const usage = ctx.getContextUsage();
|
|
332
|
+
return usage ? { percent: usage.percent, contextWindow: usage.contextWindow } : null;
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
footerInstalled = true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (process.env.PERK_SELFCHECK) {
|
|
339
|
+
try {
|
|
340
|
+
const dir = join(ctx.cwd, ".pi", "workflow");
|
|
341
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
342
|
+
// The gate sentinel (unchanged — those gates parse this line).
|
|
343
|
+
writeFileSync(
|
|
344
|
+
join(dir, ".perk-loaded"),
|
|
345
|
+
`perk ${version} loaded; shared=${sharedOk ? "ok" : "miss"}; ` +
|
|
346
|
+
`registry=${registryOk ? "ok" : "miss"} stages=${registryStages}; hasUI=${ctx.hasUI}\n`,
|
|
347
|
+
);
|
|
348
|
+
writeT3Sentinel(ctx.cwd, minted ? "mint" : decision.source, resolved, ctx.mode ?? null);
|
|
349
|
+
setMarker(ctx.cwd, T3_MARKER); // cross-plane cache write (gate check 3)
|
|
350
|
+
} catch {
|
|
351
|
+
// never throw from a load probe
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Non-negotiable: rebuild on branch navigation too, or state goes stale after /tree (§8.3).
|
|
357
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
358
|
+
const state = rebuildWorkflowState(branchOf(ctx));
|
|
359
|
+
// Non-negotiable: re-sync the gate on tree navigation too (mode is per-field LWW). Fail-closed.
|
|
360
|
+
try {
|
|
361
|
+
gating.syncFromState(state.mode);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
console.error(`perk: tool-gating sync failed on session_tree — ${error}`);
|
|
364
|
+
}
|
|
365
|
+
if (process.env.PERK_SELFCHECK) {
|
|
366
|
+
writeT3Sentinel(ctx.cwd, "tree", state, ctx.mode ?? null);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Warm door: the `plan_save` tool + `/plan-save` command (turn-3). Takes `gating` for D1a:
|
|
371
|
+
// a successful command-path save exits read-only mode (the read-only → read-write boundary).
|
|
372
|
+
registerPlanSave(pi, gating);
|
|
373
|
+
|
|
374
|
+
// The `plan_draft` working-draft file tool. Registered in the factory so it
|
|
375
|
+
// exists before the gate snapshots tools; its name is in READ_ONLY_TOOLS (the structural
|
|
376
|
+
// session-data carve-out), so it survives plan mode.
|
|
377
|
+
registerPlanDraft(pi);
|
|
378
|
+
|
|
379
|
+
// The `objective_draft` working-objective file tool (the plan_draft twin).
|
|
380
|
+
registerObjectiveDraft(pi);
|
|
381
|
+
|
|
382
|
+
// The universal `ask_user_question` tool: lets a model interactively ask the human a
|
|
383
|
+
// clarifying question (free-text or multiple-choice). Registered in the factory so it exists
|
|
384
|
+
// before the gate snapshots tools; its name is in READ_ONLY_TOOLS so it survives plan mode.
|
|
385
|
+
registerAskUser(pi);
|
|
386
|
+
|
|
387
|
+
// Lifecycle gates: the dirty-repo switch/fork guard + the guard-only `/implement` (turn-4b).
|
|
388
|
+
registerLifecycleGates(pi);
|
|
389
|
+
|
|
390
|
+
// Warm door: the `submit` tool + `/submit` command (turn-5a).
|
|
391
|
+
registerSubmit(pi);
|
|
392
|
+
|
|
393
|
+
// The warm `ready` door: the deliberate draft→ready review gate (submit keeps draft).
|
|
394
|
+
registerReady(pi);
|
|
395
|
+
|
|
396
|
+
// Warm doors: `land` (turn-5b) merges + sets pending-learn; `learn` clears it (TS-only).
|
|
397
|
+
registerLand(pi);
|
|
398
|
+
registerLearn(pi);
|
|
399
|
+
|
|
400
|
+
// The warm `/address` review loop: the `resolve_review_threads` tool + `/address`
|
|
401
|
+
// command. Classify-then-act (the verbose feedback fetch + classification runs in an isolated
|
|
402
|
+
// spawned child; the parent fixes actionable items and batch-resolves the threads).
|
|
403
|
+
registerAddress(pi);
|
|
404
|
+
|
|
405
|
+
// The warm `/pr-review` door: automated code review in a FRESH, isolated subagent that
|
|
406
|
+
// POSTS its review to the PR (the deliberate departure from /address's read-only-child rule).
|
|
407
|
+
registerPrReview(pi);
|
|
408
|
+
|
|
409
|
+
// The read-only CI executor: the `run_ci` tool + `/ci` command + `--allow-project-ci`
|
|
410
|
+
// flag. Runs the project's `[ci]` named checks deterministically and reports (never fixes/loops).
|
|
411
|
+
registerCiExecutor(pi);
|
|
412
|
+
|
|
413
|
+
// perk-owned checkpoints: seed from the plan body's `## Steps`, advance on `[DONE:n]`.
|
|
414
|
+
// Inert when no step list is present (perk plans are prose). Own `session_start`/`session_tree`/
|
|
415
|
+
// `turn_end` handlers (coexist with the others; pi.on supports multiple handlers per event).
|
|
416
|
+
// Todo-seam deferral: perk is the reference todo provider (`perk-checkpoints`); these
|
|
417
|
+
// runtime surfaces step aside when a foreign `[providers] todo` is selected (the todo-seam mirror
|
|
418
|
+
// of planMode's plan-seam deferral) — silent on the event handlers, announced on `/checkpoints`.
|
|
419
|
+
registerCheckpoints(pi, perkStatus);
|
|
420
|
+
|
|
421
|
+
// The FIRST 3rd-party todo adapter (the todo-seam mirror of registerPlanAdapterTombell).
|
|
422
|
+
// Injection-only: inert unless `[providers] todo = "juicesharp-todo"` is selected AND the session
|
|
423
|
+
// is an active workflow. It carries perk's implement-progress discipline onto `@juicesharp/rpiv-
|
|
424
|
+
// todo`'s checklist overlay (perk's own checkpoints deferred). No `gating` argument —
|
|
425
|
+
// the shim NEVER arbitrates tools (Invariant 1); no registration-time vacating (no command-name
|
|
426
|
+
// collision on the todo seam, unlike the plan seam); never writes `perk:checkpoint`.
|
|
427
|
+
registerTodoAdapterJuicesharp(pi);
|
|
428
|
+
|
|
429
|
+
// The objective substrate: `/objective` set/clear, budget accounting, threshold
|
|
430
|
+
// compaction, all keyed off the now-live `active_objective`. Inert when no objective is active.
|
|
431
|
+
// (The deterministic objective mechanics live in the Python plane: `perk objective …`.)
|
|
432
|
+
registerObjective(pi, perkStatus);
|
|
433
|
+
|
|
434
|
+
// The warm `objective_save` door: the `objective_save` tool + `/objective-save` command
|
|
435
|
+
// (the objective mirror of plan-save). Takes `gating` for the read-only → read-write boundary.
|
|
436
|
+
registerObjectiveSave(pi, gating);
|
|
437
|
+
|
|
438
|
+
// The objective plan factory's warm transition surface: the `objective_node` bounded
|
|
439
|
+
// tool (delegates to the Python cold door; `status:"done"` requires a completion audit) + the
|
|
440
|
+
// `/objective-plan` command (select the next node and author a bounded plan). The command now
|
|
441
|
+
// enters the read-only gate on invocation (parity with the cold door's `mode: read-only`
|
|
442
|
+
// handoff; exit stays with plan_save / `/plan` off) — hence `gating`.
|
|
443
|
+
registerObjectivePlan(pi, gating);
|
|
444
|
+
|
|
445
|
+
// The learned-docs plan factory's warm surface: the `/learn-docs` command gathers open
|
|
446
|
+
// perk:learn issues into an inbox (via the `perk learn docs --gather` cold door) and injects the
|
|
447
|
+
// factory guidance so the model authors a docs/learned consolidation plan (no model tool).
|
|
448
|
+
registerLearnDocs(pi);
|
|
449
|
+
|
|
450
|
+
// Warm-door skill-binding delivery: Mechanism A's `before_agent_start` injection of
|
|
451
|
+
// the launched stage's user-originated bindings (+ the stale-context strip). Mechanism B (the
|
|
452
|
+
// `command:<id>` suffix) is wired into the `/objective-reconcile` + `/learn-docs` guidance.
|
|
453
|
+
registerBindingDelivery(pi);
|
|
454
|
+
|
|
455
|
+
// `/perk-selfcheck` — the session-wiring verifier (turned from a liveness ping into a real check
|
|
456
|
+
// that the converged ambient index reached `appendSystemPrompt` and the managed `AGENTS.md` block
|
|
457
|
+
// reached `contextFiles`). doctor checks disk; selfcheck checks the prompt.
|
|
458
|
+
registerSelfcheck(pi, { version, sharedOk });
|
|
459
|
+
}
|