@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,155 @@
|
|
|
1
|
+
// `/perk-selfcheck` — the session-wiring verifier.
|
|
2
|
+
//
|
|
3
|
+
// perk converges two pieces of session context onto disk and trusts Pi to splice them into the
|
|
4
|
+
// model's system prompt:
|
|
5
|
+
//
|
|
6
|
+
// 1. `.pi/APPEND_SYSTEM.md` — the COMPRESSED ambient routing index (maintained by `/learn-docs`,
|
|
7
|
+
// NOT `perk init`). Pi loads it verbatim and joins it into `appendSystemPrompt`.
|
|
8
|
+
// 2. the `<!-- BEGIN perk managed -->` block in `AGENTS.md` — written by `perk init` (the Python
|
|
9
|
+
// plane). Pi loads `AGENTS.md` into `contextFiles` as `{ path, content }`.
|
|
10
|
+
//
|
|
11
|
+
// `perk doctor` checks the DISK side (the files converged). This selfcheck checks the PROMPT side:
|
|
12
|
+
// it reads the live `getSystemPromptOptions()` (only available on a command context) and confirms
|
|
13
|
+
// the converged content actually *reached* the prompt. That closes perk's two-plane blind spot —
|
|
14
|
+
// doctor checks disk, selfcheck checks the prompt.
|
|
15
|
+
//
|
|
16
|
+
// Sensitivity: `getSystemPromptOptions()` exposes the full system-prompt construction inputs. This
|
|
17
|
+
// module logs ONLY derived booleans/counts (never the raw prompt text). See docs/learned/pi/.
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { report as reportTo } from "../surfaces/report.ts";
|
|
23
|
+
|
|
24
|
+
/** Project-scoped ambient routing index, relative to the repo root. */
|
|
25
|
+
export const AMBIENT_INDEX_REL_PATH = join(".pi", "APPEND_SYSTEM.md");
|
|
26
|
+
|
|
27
|
+
/** The managed `AGENTS.md` block marker (cross-plane: written by `perk init`, read here). */
|
|
28
|
+
export const MANAGED_AGENTS_MARKER = "<!-- BEGIN perk managed -->";
|
|
29
|
+
|
|
30
|
+
/** The narrow slice of `BuildSystemPromptOptions` the verifier probes. */
|
|
31
|
+
export interface SystemPromptProbeInput {
|
|
32
|
+
appendSystemPrompt?: string;
|
|
33
|
+
contextFiles?: { path: string; content: string }[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Read the on-disk ambient routing index (`.pi/APPEND_SYSTEM.md`), or `null` if absent. */
|
|
37
|
+
export function readAmbientIndex(cwd: string): string | null {
|
|
38
|
+
const path = join(cwd, AMBIENT_INDEX_REL_PATH);
|
|
39
|
+
if (!existsSync(path)) return null;
|
|
40
|
+
try {
|
|
41
|
+
return readFileSync(path, "utf8");
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Derived booleans/counts for the ambient routing index — never the raw text. */
|
|
48
|
+
export interface AmbientIndexProbe {
|
|
49
|
+
/** The `.pi/APPEND_SYSTEM.md` file exists on disk. */
|
|
50
|
+
onDisk: boolean;
|
|
51
|
+
/** Its verbatim content is present in `appendSystemPrompt`. */
|
|
52
|
+
reachedPrompt: boolean;
|
|
53
|
+
/** Either nothing to wire (no on-disk index) or it reached the prompt. */
|
|
54
|
+
wired: boolean;
|
|
55
|
+
/** Length of `appendSystemPrompt` (count only — never the content). */
|
|
56
|
+
promptChars: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Probe whether the on-disk ambient index reached `appendSystemPrompt`. Pi loads the file verbatim
|
|
61
|
+
* and joins it with `\n\n`, so a trimmed substring match is the faithful "reached the prompt" test.
|
|
62
|
+
* An absent on-disk index is `wired` (a fresh consumer repo has none until `/learn-docs` lands one).
|
|
63
|
+
*/
|
|
64
|
+
export function ambientIndexProbe(
|
|
65
|
+
onDiskIndex: string | null,
|
|
66
|
+
appendSystemPrompt: string | undefined,
|
|
67
|
+
): AmbientIndexProbe {
|
|
68
|
+
const append = appendSystemPrompt ?? "";
|
|
69
|
+
const onDisk = onDiskIndex !== null;
|
|
70
|
+
const trimmed = (onDiskIndex ?? "").trim();
|
|
71
|
+
const reachedPrompt = onDisk && trimmed.length > 0 && append.includes(trimmed);
|
|
72
|
+
return {
|
|
73
|
+
onDisk,
|
|
74
|
+
reachedPrompt,
|
|
75
|
+
wired: !onDisk || reachedPrompt,
|
|
76
|
+
promptChars: append.length,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Derived booleans/counts for the managed `AGENTS.md` block — never the raw text. */
|
|
81
|
+
export interface ManagedAgentsProbe {
|
|
82
|
+
/** Number of context files spliced into the prompt. */
|
|
83
|
+
contextFileCount: number;
|
|
84
|
+
/** Some context file carries the `<!-- BEGIN perk managed -->` marker. */
|
|
85
|
+
reachedPrompt: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Probe whether the managed `AGENTS.md` block reached `contextFiles`. */
|
|
89
|
+
export function managedAgentsProbe(
|
|
90
|
+
contextFiles: { path: string; content: string }[] | undefined,
|
|
91
|
+
): ManagedAgentsProbe {
|
|
92
|
+
const files = contextFiles ?? [];
|
|
93
|
+
return {
|
|
94
|
+
contextFileCount: files.length,
|
|
95
|
+
reachedPrompt: files.some((f) => f.content.includes(MANAGED_AGENTS_MARKER)),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The full derived selfcheck report (booleans/counts + a one-line summary, no raw content). */
|
|
100
|
+
export interface SelfcheckReport {
|
|
101
|
+
version: string;
|
|
102
|
+
sharedOk: boolean;
|
|
103
|
+
ambient: AmbientIndexProbe;
|
|
104
|
+
agents: ManagedAgentsProbe;
|
|
105
|
+
/** Everything the session needs is wired through to the prompt. */
|
|
106
|
+
ok: boolean;
|
|
107
|
+
/** One-line, content-free summary (safe to surface in UI/logs). */
|
|
108
|
+
summary: string;
|
|
109
|
+
level: "info" | "warning";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Build the derived selfcheck report from the version, shared-dir state, and the live prompt. */
|
|
113
|
+
export function buildSelfcheckReport(input: {
|
|
114
|
+
version: string;
|
|
115
|
+
sharedOk: boolean;
|
|
116
|
+
onDiskIndex: string | null;
|
|
117
|
+
options: SystemPromptProbeInput | undefined;
|
|
118
|
+
}): SelfcheckReport {
|
|
119
|
+
const { version, sharedOk, onDiskIndex, options } = input;
|
|
120
|
+
const ambient = ambientIndexProbe(onDiskIndex, options?.appendSystemPrompt);
|
|
121
|
+
const agents = managedAgentsProbe(options?.contextFiles);
|
|
122
|
+
const ok = sharedOk && ambient.wired && agents.reachedPrompt;
|
|
123
|
+
const summary =
|
|
124
|
+
`${version}: ${ok ? "ok" : "WIRING GAP"}; ` +
|
|
125
|
+
`shared=${sharedOk ? "ok" : "miss"}; ` +
|
|
126
|
+
`ambient=${ambient.onDisk ? (ambient.reachedPrompt ? "reached" : "MISSING") : "none"} ` +
|
|
127
|
+
`(append=${ambient.promptChars}c); ` +
|
|
128
|
+
`agents=${agents.reachedPrompt ? "reached" : "MISSING"} (files=${agents.contextFileCount})`;
|
|
129
|
+
return { version, sharedOk, ambient, agents, ok, summary, level: ok ? "info" : "warning" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Register `/perk-selfcheck`: the session-wiring verifier. Runs on a command context (the only
|
|
134
|
+
* context exposing `getSystemPromptOptions()`), so it sees the live splice — not just disk.
|
|
135
|
+
*/
|
|
136
|
+
export function registerSelfcheck(
|
|
137
|
+
pi: ExtensionAPI,
|
|
138
|
+
opts: { version: string; sharedOk: boolean },
|
|
139
|
+
): void {
|
|
140
|
+
pi.registerCommand("perk-selfcheck", {
|
|
141
|
+
description:
|
|
142
|
+
"Verify perk's session wiring: the ambient index + managed AGENTS block reached the prompt.",
|
|
143
|
+
handler: async (_args, ctx) => {
|
|
144
|
+
const options = ctx.getSystemPromptOptions() as SystemPromptProbeInput;
|
|
145
|
+
const report = buildSelfcheckReport({
|
|
146
|
+
version: opts.version,
|
|
147
|
+
sharedOk: opts.sharedOk,
|
|
148
|
+
onDiskIndex: readAmbientIndex(ctx.cwd),
|
|
149
|
+
options,
|
|
150
|
+
});
|
|
151
|
+
// Headless-safe: report() surfaces the derived booleans/counts (never raw prompt content).
|
|
152
|
+
reportTo(ctx, "selfcheck", report.level, report.summary);
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// The warm `/submit` door. The in-session twin of the Python cold door
|
|
2
|
+
// (`perk pr submit`): a deterministic, terminating tool + command that DELEGATE the GitHub write —
|
|
3
|
+
// they do NOT reimplement it (GitHub mutations are canonical in the Python gateway). Mirrors
|
|
4
|
+
// `planSave.ts`: write nothing, delegate to `perk pr submit --json` via `pi.exec`, surface the
|
|
5
|
+
// structured result, never throw (failures are loud-but-non-fatal via `details.ok = false`).
|
|
6
|
+
//
|
|
7
|
+
// Mergeability gate: `perk pr submit` probes the PR's mergeability against the base branch
|
|
8
|
+
// (a deterministic local `git merge-tree` probe). When it reports a definitively-unmergeable PR
|
|
9
|
+
// (`mergeable === false` + conflicts), the warm door drives a fresh-context, write-capable
|
|
10
|
+
// `perk.conflict-resolver` subagent (mirroring `/pr-review`'s dispatch and `/land`'s reconcile
|
|
11
|
+
// drive) to rebase + resolve + push, then asks the model to re-`/submit` to confirm. Bounded by
|
|
12
|
+
// `CONFLICT_RESOLUTION_ATTEMPT_CAP` via the `conflict_resolution_attempts` workflow-state field.
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { bindingSuffix } from "../substrate/bindingDelivery.ts";
|
|
16
|
+
import {
|
|
17
|
+
booleanField,
|
|
18
|
+
type ColdJson,
|
|
19
|
+
numberField,
|
|
20
|
+
objectField,
|
|
21
|
+
runColdDoor,
|
|
22
|
+
stringField,
|
|
23
|
+
} from "../substrate/coldDoor.ts";
|
|
24
|
+
import { loadPerkConfig } from "../substrate/config.ts";
|
|
25
|
+
import { failFor, type OkDetails, ok, type Result } from "../substrate/result.ts";
|
|
26
|
+
import { appendWorkflowState, branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
27
|
+
import { report } from "../surfaces/report.ts";
|
|
28
|
+
|
|
29
|
+
/** The bounded conflict-resolution re-drive cap: drive the resolver at most this many times. */
|
|
30
|
+
export const CONFLICT_RESOLUTION_ATTEMPT_CAP = 2;
|
|
31
|
+
|
|
32
|
+
/** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state. */
|
|
33
|
+
export interface SubmitOk {
|
|
34
|
+
pr: { number: number; url: string; is_draft: boolean; existed: boolean };
|
|
35
|
+
branch?: string;
|
|
36
|
+
issue?: number;
|
|
37
|
+
plan_embedded?: boolean;
|
|
38
|
+
/** The target branch the PR merges into (the conflict-resolver rebases onto it). */
|
|
39
|
+
base?: string;
|
|
40
|
+
/** Tri-state mergeability from the Python `git merge-tree` probe: true/false/null/absent. */
|
|
41
|
+
mergeable?: boolean | null;
|
|
42
|
+
/** The conflicted paths when `mergeable === false`; `[]` otherwise (advisory). */
|
|
43
|
+
conflicts?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type SubmitResult = Result<SubmitOk>;
|
|
47
|
+
export type SubmitDetails = SubmitResult["details"];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A tri-state read of the advisory `mergeable` field: `true`/`false`/`null` pass through;
|
|
51
|
+
* anything else (absent, mistyped) → `undefined`. Kept lenient so a malformed value never sinks
|
|
52
|
+
* an otherwise-successful submit decode.
|
|
53
|
+
*/
|
|
54
|
+
function mergeableField(payload: ColdJson): boolean | null | undefined {
|
|
55
|
+
const value = payload.mergeable;
|
|
56
|
+
if (value === true || value === false || value === null) return value;
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A lenient string-array read for the advisory `conflicts` field; malformed → `[]`. */
|
|
61
|
+
function conflictsField(payload: ColdJson): string[] {
|
|
62
|
+
const value = payload.conflicts;
|
|
63
|
+
if (Array.isArray(value) && value.every((p) => typeof p === "string")) return value as string[];
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Narrow the `perk pr submit --json` success payload; strict on `pr`, lenient on the rest. The
|
|
69
|
+
* `base`/`mergeable`/`conflicts` mergeability fields are advisory (mirror land.ts's lenient
|
|
70
|
+
* sub-fields): a malformed value must NOT make a successful submit decode to `null`.
|
|
71
|
+
*/
|
|
72
|
+
function decodeSubmit(payload: ColdJson): SubmitOk | null {
|
|
73
|
+
const pr = objectField(payload, "pr");
|
|
74
|
+
if (pr === undefined) return null;
|
|
75
|
+
const number = numberField(pr, "number");
|
|
76
|
+
const url = stringField(pr, "url");
|
|
77
|
+
const isDraft = booleanField(pr, "is_draft");
|
|
78
|
+
const existed = booleanField(pr, "existed");
|
|
79
|
+
if (number === undefined || url === undefined || isDraft === undefined || existed === undefined) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
pr: { number, url, is_draft: isDraft, existed },
|
|
84
|
+
branch: stringField(payload, "branch"),
|
|
85
|
+
issue: numberField(payload, "issue"),
|
|
86
|
+
plan_embedded: booleanField(payload, "plan_embedded"),
|
|
87
|
+
base: stringField(payload, "base"),
|
|
88
|
+
mergeable: mergeableField(payload),
|
|
89
|
+
conflicts: conflictsField(payload),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A definitively-unmergeable submit: the gate fires only on a *definitive* `false` + conflicts. */
|
|
94
|
+
function isUnmergeable(details: SubmitDetails): details is OkDetails<SubmitOk> {
|
|
95
|
+
return details.ok && details.mergeable === false && (details.conflicts?.length ?? 0) > 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The single submit implementation both surfaces call. Delegates to the Python cold door; returns a
|
|
100
|
+
* soft result (never throws) — failures set `details.ok = false`. On a clean submit
|
|
101
|
+
* (`mergeable !== false`) it resets the bounded conflict-resolution counter so a later independent
|
|
102
|
+
* conflict starts fresh; on conflicts the success message reflects the in-flight resolution.
|
|
103
|
+
*/
|
|
104
|
+
export async function submitPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<SubmitResult> {
|
|
105
|
+
const fail = failFor(ctx, "submit");
|
|
106
|
+
|
|
107
|
+
const r = await runColdDoor<SubmitOk>(pi, ctx, ["pr", "submit", "--json"], {
|
|
108
|
+
label: "perk pr submit",
|
|
109
|
+
decode: decodeSubmit,
|
|
110
|
+
});
|
|
111
|
+
if (!r.ok) return fail(r.message, r.errorType);
|
|
112
|
+
|
|
113
|
+
const verb = r.data.pr.existed ? "Found existing" : "Opened draft";
|
|
114
|
+
const conflicted = r.data.mergeable === false && (r.data.conflicts?.length ?? 0) > 0;
|
|
115
|
+
// Reset the counter on every clean (or undetermined) submit — idempotent; keeps a later
|
|
116
|
+
// independent conflict bounded fresh.
|
|
117
|
+
if (r.data.mergeable !== false) resetConflictAttempts(pi, ctx);
|
|
118
|
+
const message = conflicted
|
|
119
|
+
? `${verb} PR #${r.data.pr.number} → ${r.data.pr.url} — merge conflicts detected; resolving`
|
|
120
|
+
: `${verb} PR #${r.data.pr.number} → ${r.data.pr.url} (${
|
|
121
|
+
r.data.plan_embedded ? "plan embedded" : "no plan embed"
|
|
122
|
+
})`;
|
|
123
|
+
return ok(message, r.data, { terminate: true });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Reset `conflict_resolution_attempts` to 0 (idempotent: a no-op when already 0/absent). */
|
|
127
|
+
function resetConflictAttempts(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
128
|
+
const attempts = rebuildWorkflowState(branchOf(ctx)).conflict_resolution_attempts ?? 0;
|
|
129
|
+
if (attempts === 0) return;
|
|
130
|
+
appendWorkflowState(pi, ctx, {
|
|
131
|
+
data: { conflict_resolution_attempts: 0 },
|
|
132
|
+
field: "conflict_resolution_attempts",
|
|
133
|
+
expected: 0,
|
|
134
|
+
scope: "submit",
|
|
135
|
+
failure: "conflict_resolution_attempts reset read-back failed (expected 0)",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The follow-up guidance the warm `/submit` injects to spawn the conflict-resolver (modeled on
|
|
141
|
+
* `prReviewGuidance`). Pure + exported for offline tests. When `model` is set, the spawn carries an
|
|
142
|
+
* inline `model` override; otherwise the agent's default model is used.
|
|
143
|
+
*/
|
|
144
|
+
export function conflictResolutionGuidance(
|
|
145
|
+
base: string,
|
|
146
|
+
attempt: number,
|
|
147
|
+
cap: number,
|
|
148
|
+
model?: string,
|
|
149
|
+
): string {
|
|
150
|
+
const modelClause = model
|
|
151
|
+
? `, and pass \`model: "${model}"\` on that call (the configured [subagents] conflict-resolver model)`
|
|
152
|
+
: " (no model override — the agent's default model is used)";
|
|
153
|
+
return [
|
|
154
|
+
`perk /submit — your PR has merge conflicts against \`${base}\`; resolve them before the work ` +
|
|
155
|
+
"is submitted for review. This is attempt " +
|
|
156
|
+
`${attempt} of ${cap}.`,
|
|
157
|
+
`1. Spawn the \`perk.conflict-resolver\` agent via the \`subagent\` tool with \`context: "fresh"\`${modelClause}. ` +
|
|
158
|
+
"A fresh context keeps this implementation session's history from biasing the resolution.",
|
|
159
|
+
`2. Tell it: rebase the PR branch onto \`${base}\` and **carefully** resolve all merge ` +
|
|
160
|
+
"conflicts so the resulting diff is **clean** (no stray markers, no unrelated churn) and " +
|
|
161
|
+
"**correct** (preserve the change's intent on both sides). The child reads its own plan + PR " +
|
|
162
|
+
"diff context first (it runs `perk pr review-context`) so it resolves with the change's " +
|
|
163
|
+
"intent in hand, verifies, and force-pushes — the raw diff never enters this session.",
|
|
164
|
+
"3. After the child reports success, call `/submit` again to re-verify mergeability. Do NOT " +
|
|
165
|
+
"edit or resolve conflicts yourself here — the child owns the rebase/resolve/push.",
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* After a submit that opened a PR with detected merge conflicts, drive the session into the
|
|
171
|
+
* conflict-resolution pass by injecting `conflictResolutionGuidance` (warm-door driving pattern,
|
|
172
|
+
* modeled on `driveReconcileAfterLand`). The terminating `submit` tool stays terminating — a
|
|
173
|
+
* `followUp` user message is a separate deliberate new turn. Short-circuits (sends nothing) unless
|
|
174
|
+
* the submit succeeded with a definitively-unmergeable PR. Bounded by
|
|
175
|
+
* `CONFLICT_RESOLUTION_ATTEMPT_CAP` via the `conflict_resolution_attempts` field: past the cap it
|
|
176
|
+
* surfaces the unresolved conflict loudly instead of looping.
|
|
177
|
+
*/
|
|
178
|
+
export function driveConflictResolution(
|
|
179
|
+
pi: ExtensionAPI,
|
|
180
|
+
ctx: ExtensionContext,
|
|
181
|
+
details: SubmitDetails,
|
|
182
|
+
): void {
|
|
183
|
+
if (!isUnmergeable(details)) return;
|
|
184
|
+
const base = details.base ?? "";
|
|
185
|
+
const attempts = rebuildWorkflowState(branchOf(ctx)).conflict_resolution_attempts ?? 0;
|
|
186
|
+
if (attempts >= CONFLICT_RESOLUTION_ATTEMPT_CAP) {
|
|
187
|
+
report(
|
|
188
|
+
ctx,
|
|
189
|
+
"submit",
|
|
190
|
+
"error",
|
|
191
|
+
`merge conflicts persist after ${attempts} resolution attempt(s) — resolve manually ` +
|
|
192
|
+
`(rebase onto \`${base}\` and push), then re-run /submit.`,
|
|
193
|
+
{ alsoLog: true },
|
|
194
|
+
);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const next = attempts + 1;
|
|
198
|
+
appendWorkflowState(pi, ctx, {
|
|
199
|
+
data: { conflict_resolution_attempts: next },
|
|
200
|
+
field: "conflict_resolution_attempts",
|
|
201
|
+
expected: next,
|
|
202
|
+
scope: "submit",
|
|
203
|
+
failure: `conflict_resolution_attempts read-back failed (expected ${next})`,
|
|
204
|
+
});
|
|
205
|
+
const model = loadPerkConfig(ctx.cwd).subagents["conflict-resolver"];
|
|
206
|
+
const message =
|
|
207
|
+
conflictResolutionGuidance(base, next, CONFLICT_RESOLUTION_ATTEMPT_CAP, model) +
|
|
208
|
+
bindingSuffix(ctx.cwd, "command:submit");
|
|
209
|
+
if (ctx.isIdle()) {
|
|
210
|
+
// The `/submit` command path (idle): inject an immediate turn.
|
|
211
|
+
pi.sendUserMessage(message);
|
|
212
|
+
} else {
|
|
213
|
+
// The `submit` tool path (streaming): deliver after the terminating submit batch.
|
|
214
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const TOOL_GUIDELINES = [
|
|
219
|
+
"Call submit only after the implementation is committed in this worktree; it pushes the branch and opens the draft PR, then ends the turn.",
|
|
220
|
+
"submit operates on the active plan's worktree — it takes no arguments; the branch and plan come from the local plan-ref.",
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
/** Register the warm door: the `submit` terminating tool (canonical) + the `/submit` command twin. */
|
|
224
|
+
export function registerSubmit(pi: ExtensionAPI): void {
|
|
225
|
+
pi.registerTool({
|
|
226
|
+
name: "submit",
|
|
227
|
+
label: "Submit PR",
|
|
228
|
+
description:
|
|
229
|
+
"Push the current plan's branch and open a draft pull request linking the plan. " +
|
|
230
|
+
"Terminating: ends the turn on submit. Call only after the implementation is committed.",
|
|
231
|
+
promptSnippet: "Open the draft PR for the committed implementation (terminates the turn)",
|
|
232
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
233
|
+
executionMode: "sequential",
|
|
234
|
+
parameters: { type: "object", additionalProperties: false, properties: {} },
|
|
235
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
236
|
+
const result = await submitPr(pi, ctx);
|
|
237
|
+
driveConflictResolution(pi, ctx, result.details);
|
|
238
|
+
return result;
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
pi.registerCommand("submit", {
|
|
243
|
+
description: "Push the branch and open a draft PR for the active plan (implement → submit).",
|
|
244
|
+
handler: async (_args, ctx) => {
|
|
245
|
+
const result = await submitPr(pi, ctx);
|
|
246
|
+
// Failure already reported loudly via failFor (the single error surface) — success only.
|
|
247
|
+
if (result.details.ok) {
|
|
248
|
+
report(ctx, "submit", "info", result.content[0]?.text ?? "submit done");
|
|
249
|
+
}
|
|
250
|
+
driveConflictResolution(pi, ctx, result.details);
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// The objective TS substrate: `active_objective` live use + budget accounting + threshold
|
|
2
|
+
// compaction. The deterministic mechanics (storage, roadmap, next-node) live in the Python plane
|
|
3
|
+
// (`perk objective …`); this is the in-session interior keyed off the now-live `active_objective`
|
|
4
|
+
// workflow-state field.
|
|
5
|
+
//
|
|
6
|
+
// NO model-facing bounded transition tools here — the `objective-plan` stage, the plan factory, and
|
|
7
|
+
// the "fire only when…" tools live elsewhere. This module ships three pieces, all inert when no
|
|
8
|
+
// objective is active:
|
|
9
|
+
// 1. `/objective [<id>|clear]` — set/clear `active_objective` (LWW field) + seed a dedicated
|
|
10
|
+
// `perk:objective-budget` activation marker (high-churn budget data kept OFF the shared record,
|
|
11
|
+
// mirroring checkpoints' dedicated entry).
|
|
12
|
+
// 2. Budget accounting — stateless rebuild (the goal.ts pattern): sum assistant-message tokens
|
|
13
|
+
// AFTER the latest activation marker; surface via ctx.ui guarded by ctx.hasUI; rebuilt on
|
|
14
|
+
// session_start AND session_tree AND agent_end (survives reload/branch/compaction for free).
|
|
15
|
+
// 3. Threshold-triggered compaction (the trigger-compact.ts pattern): on turn_end, only when an
|
|
16
|
+
// objective is active, compact when context usage crosses a configurable threshold.
|
|
17
|
+
//
|
|
18
|
+
// Headless-fail-safe: every UI call is ctx.hasUI-guarded; budget accounting + compaction are
|
|
19
|
+
// best-effort and never throw (logged-not-thrown, like checkpoints).
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { loadPerkConfig } from "../substrate/config.ts";
|
|
23
|
+
import { branchOf, rebuildWorkflowState, WORKFLOW_STATE_TYPE } from "../substrate/workflowState.ts";
|
|
24
|
+
import {
|
|
25
|
+
formatBudgetLine,
|
|
26
|
+
MARK_OBJECTIVE,
|
|
27
|
+
type PerkStatusHandle,
|
|
28
|
+
report,
|
|
29
|
+
} from "../surfaces/surfaces.ts";
|
|
30
|
+
|
|
31
|
+
/** The dedicated budget/activation session entry type (kept off `perk:workflow-state`). */
|
|
32
|
+
export const OBJECTIVE_BUDGET_TYPE = "perk:objective-budget";
|
|
33
|
+
|
|
34
|
+
/** The default context-usage fraction that triggers threshold compaction (overridable via config). */
|
|
35
|
+
export const DEFAULT_COMPACT_THRESHOLD = 0.8;
|
|
36
|
+
|
|
37
|
+
/** A branch entry as seen by the budget scan: a custom marker OR an assistant message. */
|
|
38
|
+
interface ScanEntry {
|
|
39
|
+
type: string;
|
|
40
|
+
customType?: string;
|
|
41
|
+
data?: { objective_id?: string; activated_at?: string };
|
|
42
|
+
message?: { role?: string; usage?: { input?: number; output?: number } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BudgetMarker {
|
|
46
|
+
index: number;
|
|
47
|
+
objectiveId: string;
|
|
48
|
+
activatedAt: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BudgetState {
|
|
52
|
+
objectiveId: string | null;
|
|
53
|
+
tokens: number;
|
|
54
|
+
elapsedMs: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- pure helpers (offline-testable) ------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/** The latest `perk:objective-budget` activation marker on the branch, or null. */
|
|
60
|
+
export function findBudgetMarker(branch: readonly ScanEntry[]): BudgetMarker | null {
|
|
61
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
62
|
+
const e = branch[i];
|
|
63
|
+
if (e?.type === "custom" && e.customType === OBJECTIVE_BUDGET_TYPE) {
|
|
64
|
+
const objectiveId = e.data?.objective_id;
|
|
65
|
+
const activatedAt = e.data?.activated_at;
|
|
66
|
+
if (typeof objectiveId === "string" && typeof activatedAt === "string") {
|
|
67
|
+
return { index: i, objectiveId, activatedAt };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sum assistant-message tokens (`max(0,input) + max(0,output)`) over branch entries AFTER
|
|
76
|
+
* `afterIdx`. Non-assistant / marker entries are ignored; negatives are clamped to 0.
|
|
77
|
+
*/
|
|
78
|
+
export function sumAssistantTokens(branch: readonly ScanEntry[], afterIdx: number): number {
|
|
79
|
+
let total = 0;
|
|
80
|
+
for (let i = afterIdx + 1; i < branch.length; i++) {
|
|
81
|
+
const e = branch[i];
|
|
82
|
+
if (e?.type !== "message" || e.message?.role !== "assistant") continue;
|
|
83
|
+
const usage = e.message.usage;
|
|
84
|
+
if (!usage) continue;
|
|
85
|
+
total += Math.max(0, usage.input ?? 0) + Math.max(0, usage.output ?? 0);
|
|
86
|
+
}
|
|
87
|
+
return total;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Rebuild the budget state from a branch (stateless; survives reload/branch/compaction). */
|
|
91
|
+
export function rebuildBudget(branch: readonly ScanEntry[], now: number): BudgetState {
|
|
92
|
+
const marker = findBudgetMarker(branch);
|
|
93
|
+
if (marker === null) return { objectiveId: null, tokens: 0, elapsedMs: 0 };
|
|
94
|
+
const activatedMs = Date.parse(marker.activatedAt);
|
|
95
|
+
const elapsedMs = Number.isFinite(activatedMs) ? Math.max(0, now - activatedMs) : 0;
|
|
96
|
+
return {
|
|
97
|
+
objectiveId: marker.objectiveId,
|
|
98
|
+
tokens: sumAssistantTokens(branch, marker.index),
|
|
99
|
+
elapsedMs,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True when context usage has crossed the compaction threshold (pure; tolerant of unknowns). */
|
|
104
|
+
export function shouldCompact(
|
|
105
|
+
usage: { percent: number | null; tokens: number | null } | undefined,
|
|
106
|
+
threshold: number,
|
|
107
|
+
): boolean {
|
|
108
|
+
if (!usage || usage.percent === null) return false;
|
|
109
|
+
return usage.percent / 100 >= threshold;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- the controller -----------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function scanBranchOf(ctx: ExtensionContext): ScanEntry[] {
|
|
115
|
+
return ctx.sessionManager.getBranch() as unknown as ScanEntry[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function activeObjective(ctx: ExtensionContext): string | null {
|
|
119
|
+
try {
|
|
120
|
+
const state = rebuildWorkflowState(branchOf(ctx));
|
|
121
|
+
return state.active_objective ?? null;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Surface the budget as the objective segment of the composed `perk` status (the `perk-objective`
|
|
129
|
+
* widget is retired; the segment carries id + tokens + elapsed). Headless-safe:
|
|
130
|
+
* the handle no-ops without UI.
|
|
131
|
+
*/
|
|
132
|
+
function renderStatus(ctx: ExtensionContext, status: PerkStatusHandle): void {
|
|
133
|
+
if (!ctx.hasUI) return;
|
|
134
|
+
try {
|
|
135
|
+
const active = activeObjective(ctx);
|
|
136
|
+
if (active === null) {
|
|
137
|
+
status.set(ctx, "objective", undefined);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const budget = rebuildBudget(scanBranchOf(ctx), Date.now());
|
|
141
|
+
status.set(ctx, "objective", `${MARK_OBJECTIVE} ${active} · ${formatBudgetLine(budget)}`);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.error(`perk: objective status render failed — ${error}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function reportError(ctx: ExtensionContext, message: string): void {
|
|
148
|
+
report(ctx, "objective", "error", message);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The `/objective [<id>|clear]` handler (set/clear active_objective + seed/clear the marker). */
|
|
152
|
+
function objectiveCommand(
|
|
153
|
+
pi: ExtensionAPI,
|
|
154
|
+
ctx: ExtensionContext,
|
|
155
|
+
args: string,
|
|
156
|
+
status: PerkStatusHandle,
|
|
157
|
+
): void {
|
|
158
|
+
const arg = args.trim();
|
|
159
|
+
try {
|
|
160
|
+
if (arg === "") {
|
|
161
|
+
const active = activeObjective(ctx);
|
|
162
|
+
const budget = rebuildBudget(scanBranchOf(ctx), Date.now());
|
|
163
|
+
const message =
|
|
164
|
+
active === null
|
|
165
|
+
? "no active objective. Use `/objective <id>` to activate one."
|
|
166
|
+
: `active objective ${active} · ${formatBudgetLine(budget)}`;
|
|
167
|
+
report(ctx, "objective", "info", message);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (arg === "clear") {
|
|
172
|
+
pi.appendEntry(WORKFLOW_STATE_TYPE, { active_objective: null });
|
|
173
|
+
renderStatus(ctx, status);
|
|
174
|
+
report(ctx, "objective", "info", "cleared the active objective.");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Activate: set the LWW field + seed a fresh budget activation marker.
|
|
179
|
+
pi.appendEntry(WORKFLOW_STATE_TYPE, { active_objective: arg });
|
|
180
|
+
pi.appendEntry(OBJECTIVE_BUDGET_TYPE, {
|
|
181
|
+
objective_id: arg,
|
|
182
|
+
activated_at: new Date().toISOString(),
|
|
183
|
+
});
|
|
184
|
+
renderStatus(ctx, status);
|
|
185
|
+
report(ctx, "objective", "info", `activated objective ${arg} (budget tracking started).`);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
reportError(ctx, `command failed: ${error}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Register the objective substrate: `/objective` command, budget accounting (session_start /
|
|
193
|
+
* session_tree / agent_end), and threshold compaction (turn_end). All inert when no objective is
|
|
194
|
+
* active; never throws.
|
|
195
|
+
*/
|
|
196
|
+
export function registerObjective(pi: ExtensionAPI, status: PerkStatusHandle): void {
|
|
197
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
198
|
+
renderStatus(ctx, status);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
202
|
+
renderStatus(ctx, status);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
206
|
+
// Recompute the budget after each agent loop (stateless rebuild from the branch).
|
|
207
|
+
renderStatus(ctx, status);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
211
|
+
try {
|
|
212
|
+
if (activeObjective(ctx) === null) return; // inert unless an objective is active
|
|
213
|
+
const threshold =
|
|
214
|
+
loadPerkConfig(ctx.cwd).objectiveCompactThreshold ?? DEFAULT_COMPACT_THRESHOLD;
|
|
215
|
+
const usage = ctx.getContextUsage();
|
|
216
|
+
if (!shouldCompact(usage, threshold)) return;
|
|
217
|
+
const active = activeObjective(ctx);
|
|
218
|
+
ctx.compact({
|
|
219
|
+
customInstructions:
|
|
220
|
+
`Preserve the active perk objective (${active}) and its budget context. ` +
|
|
221
|
+
"Keep the roadmap progress and the current node's intent in the summary.",
|
|
222
|
+
onError: (error) => {
|
|
223
|
+
console.error(`perk: objective compaction failed — ${error}`);
|
|
224
|
+
},
|
|
225
|
+
onComplete: () => {
|
|
226
|
+
renderStatus(ctx, status);
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.error(`perk: objective compaction trigger failed on turn_end — ${error}`);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
pi.registerCommand("objective", {
|
|
235
|
+
description: "Show, set (`<id>`), or clear (`clear`) the active perk objective + budget.",
|
|
236
|
+
handler: async (args, ctx) => {
|
|
237
|
+
objectiveCommand(pi, ctx, args, status);
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|