@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,233 @@
|
|
|
1
|
+
// The `perk:workflow-state` session tier (contracts.md §8.3) — rebuild, claim, fork-derive,
|
|
2
|
+
// and the strict-append seam (appendWorkflowState).
|
|
3
|
+
//
|
|
4
|
+
// Mostly pure, fs-light logic kept separate from the `pi`/`ctx` effects (in index.ts); the
|
|
5
|
+
// strict-append seam touches effects only through structural slices (`EntrySink`, `BranchSource`,
|
|
6
|
+
// `ReportTarget`), so the whole module stays unit-testable under `node --test` with fakes. The
|
|
7
|
+
// reconstruction discipline (scan getBranch on session_start AND session_tree, per-field LWW)
|
|
8
|
+
// and the verified-linkage claim live here.
|
|
9
|
+
|
|
10
|
+
import { type ReportTarget, report } from "../surfaces/report.ts";
|
|
11
|
+
import { listRunIds, type PlanRef, readHandoff } from "./cache.ts";
|
|
12
|
+
|
|
13
|
+
export const WORKFLOW_STATE_TYPE = "perk:workflow-state";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A session-artifact provenance pointer (contracts §8.3): the session tier's proof
|
|
17
|
+
* that a `scratch/runs/<run_id>/data/` file is current for THIS run. Reads validate the
|
|
18
|
+
* on-disk file against the rebuilt pointer (run_id match + digest match) and refuse otherwise.
|
|
19
|
+
*/
|
|
20
|
+
export interface SessionArtifactPointer {
|
|
21
|
+
run_id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
/** Repo-relative, informational only — validation always re-derives via the seam. */
|
|
24
|
+
path: string;
|
|
25
|
+
/** `sha256:<hex>` of the file bytes as read back from disk. */
|
|
26
|
+
digest: string;
|
|
27
|
+
at: string; // ISO timestamp
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WorkflowState {
|
|
31
|
+
run_id?: string;
|
|
32
|
+
pi_session_id?: string;
|
|
33
|
+
mode?: string;
|
|
34
|
+
/** The registry stage id this run is acting on (recorded at cold claim from the handoff). */
|
|
35
|
+
stage?: string;
|
|
36
|
+
predecessor?: string;
|
|
37
|
+
active_plan_ref?: PlanRef | null;
|
|
38
|
+
active_objective?: string | null;
|
|
39
|
+
last_review_batch?: unknown;
|
|
40
|
+
/**
|
|
41
|
+
* The last `/pr-review` outcome posted via the `post_pr_review` warm tool (§8.3):
|
|
42
|
+
* `{pr, verdict, angles, comment_count, mode, at}`. Best-effort tier (per-field LWW in
|
|
43
|
+
* `rebuildWorkflowState`, no rebuild change). The PR comment stays canonical.
|
|
44
|
+
*/
|
|
45
|
+
last_pr_review?: unknown;
|
|
46
|
+
/** Session-artifact provenance pointers, keyed by artifact name (§8.3). */
|
|
47
|
+
session_artifacts?: Record<string, SessionArtifactPointer> | null;
|
|
48
|
+
/**
|
|
49
|
+
* The objective node this session has claimed `planning` (§8.3) — the warm
|
|
50
|
+
* node-link carrier an approval-triggered save recovers from. Written by the `objective_node`
|
|
51
|
+
* tool on a successful `planning` transition; cleared on a non-planning transition for the same
|
|
52
|
+
* node and after a successful node-linked plan save. Best-effort tier.
|
|
53
|
+
*/
|
|
54
|
+
objective_node_claim?: { objective: string; node: string } | null;
|
|
55
|
+
/**
|
|
56
|
+
* The bounded conflict-resolution re-drive counter (§8.3). Incremented each time
|
|
57
|
+
* `/submit` drives the `perk.conflict-resolver` subagent on a definitively-unmergeable PR;
|
|
58
|
+
* reset to 0 on a clean submit. Best-effort tier (cheaply reconstructable). Per-field LWW in
|
|
59
|
+
* `rebuildWorkflowState` handles it with no rebuild change.
|
|
60
|
+
*/
|
|
61
|
+
conflict_resolution_attempts?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The structural slice of a session entry that the rebuild cares about. */
|
|
65
|
+
export interface BranchEntry {
|
|
66
|
+
type: string;
|
|
67
|
+
customType?: string;
|
|
68
|
+
data?: Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The minimal read-only session surface the branch accessor needs. */
|
|
72
|
+
export interface BranchSource {
|
|
73
|
+
sessionManager: { getBranch(): unknown[] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The one typed seam over `sessionManager.getBranch()`. Centralizes the single unavoidable
|
|
78
|
+
* assertion from the SDK's `SessionEntry[]` (surfaced here as `unknown[]`) to perk's structural
|
|
79
|
+
* `BranchEntry[]`. The SDK union (whose `CustomEntry.data` is `unknown`) is not assignable to the
|
|
80
|
+
* structural slice, so the assertion is irreducible — do not "fix" it into a type error.
|
|
81
|
+
* `ExtensionContext` and the test harness `session` both satisfy `BranchSource`.
|
|
82
|
+
*/
|
|
83
|
+
export function branchOf(source: BranchSource): BranchEntry[] {
|
|
84
|
+
return source.sessionManager.getBranch() as BranchEntry[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Per-field last-write-wins over the `perk:workflow-state` custom entries on a branch.
|
|
89
|
+
* Non-perk entries are ignored; `undefined` fields never clobber (but explicit `null` does).
|
|
90
|
+
*/
|
|
91
|
+
export function rebuildWorkflowState(entries: readonly BranchEntry[]): WorkflowState {
|
|
92
|
+
const state: Record<string, unknown> = {};
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (entry.type !== "custom" || entry.customType !== WORKFLOW_STATE_TYPE) continue;
|
|
95
|
+
for (const [key, value] of Object.entries(entry.data ?? {})) {
|
|
96
|
+
if (value !== undefined) state[key] = value;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return state as WorkflowState;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The structural append surface the strict-append seam needs; ExtensionAPI satisfies it. */
|
|
103
|
+
export interface EntrySink {
|
|
104
|
+
appendEntry(customType: string, data?: unknown): void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The one strict-append seam over `perk:workflow-state` (contracts §8.3 verified-linkage tier):
|
|
109
|
+
* append → rebuild → compare → report. Loud-but-non-fatal and NEVER throws: a mismatch or a
|
|
110
|
+
* throwing append/rebuild is reported via the report() seam ({ alsoLog: true }) and returns
|
|
111
|
+
* false. Idempotence pre-checks ("append iff the rebuilt value differs") stay at call sites.
|
|
112
|
+
*/
|
|
113
|
+
export function appendWorkflowState<K extends keyof WorkflowState>(
|
|
114
|
+
sink: EntrySink,
|
|
115
|
+
source: BranchSource & ReportTarget,
|
|
116
|
+
opts: {
|
|
117
|
+
/** The entry payload — may carry extra fields beyond the verified one (the claim record). */
|
|
118
|
+
data: WorkflowState;
|
|
119
|
+
/** The field verified on read-back. */
|
|
120
|
+
field: K;
|
|
121
|
+
/** The value the rebuilt field must equal. */
|
|
122
|
+
expected: WorkflowState[K];
|
|
123
|
+
/** report() scope, e.g. "plan-save", "workflow-state linkage error". */
|
|
124
|
+
scope: string;
|
|
125
|
+
/** The mismatch message (byte-preserved per site). */
|
|
126
|
+
failure: string;
|
|
127
|
+
/** Comparator; default: (a, b) => Object.is(a ?? null, b ?? null). */
|
|
128
|
+
equals?: (rebuilt: WorkflowState[K] | undefined, expected: WorkflowState[K]) => boolean;
|
|
129
|
+
},
|
|
130
|
+
): boolean {
|
|
131
|
+
const equals =
|
|
132
|
+
opts.equals ??
|
|
133
|
+
((a: WorkflowState[K] | undefined, b: WorkflowState[K]) => Object.is(a ?? null, b ?? null));
|
|
134
|
+
try {
|
|
135
|
+
sink.appendEntry(WORKFLOW_STATE_TYPE, opts.data);
|
|
136
|
+
const rebuilt = rebuildWorkflowState(branchOf(source))[opts.field];
|
|
137
|
+
if (equals(rebuilt, opts.expected)) return true;
|
|
138
|
+
report(source, opts.scope, "error", opts.failure, { alsoLog: true });
|
|
139
|
+
return false;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
report(source, opts.scope, "error", `${String(opts.field)} append threw — ${String(error)}`, {
|
|
142
|
+
alsoLog: true,
|
|
143
|
+
});
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Equality by identity (provider + pr_id) — the plan-ref dedup key. Two refs to
|
|
150
|
+
* the same plan are equal even if other fields drift; absent compares equal only to absent.
|
|
151
|
+
*/
|
|
152
|
+
export function planRefsEqual(
|
|
153
|
+
a: PlanRef | null | undefined,
|
|
154
|
+
b: PlanRef | null | undefined,
|
|
155
|
+
): boolean {
|
|
156
|
+
if (a === null || a === undefined || b === null || b === undefined) {
|
|
157
|
+
return (a === null || a === undefined) && (b === null || b === undefined);
|
|
158
|
+
}
|
|
159
|
+
return a.provider === b.provider && a.pr_id === b.pr_id;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Derive a fork-child run_id: `<parent>.<n>` where `n` is the max existing sibling + 1
|
|
164
|
+
* (scanning `scratch/runs/`), else 1.
|
|
165
|
+
*/
|
|
166
|
+
export function deriveForkRunId(parentRunId: string, cwd: string): string {
|
|
167
|
+
const prefix = `${parentRunId}.`;
|
|
168
|
+
let max = 0;
|
|
169
|
+
for (const id of listRunIds(cwd)) {
|
|
170
|
+
if (!id.startsWith(prefix)) continue;
|
|
171
|
+
const segment = id.slice(prefix.length).split(".")[0] ?? "";
|
|
172
|
+
const n = Number.parseInt(segment, 10);
|
|
173
|
+
if (Number.isInteger(n) && n > max) max = n;
|
|
174
|
+
}
|
|
175
|
+
return `${parentRunId}.${max + 1}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export type ClaimDecision =
|
|
179
|
+
| { action: "keep"; source: "session"; state: WorkflowState }
|
|
180
|
+
| {
|
|
181
|
+
action: "fork";
|
|
182
|
+
source: "fork";
|
|
183
|
+
childRunId: string;
|
|
184
|
+
parentRunId: string;
|
|
185
|
+
state: WorkflowState;
|
|
186
|
+
}
|
|
187
|
+
| { action: "claim"; source: "env"; runId: string }
|
|
188
|
+
| { action: "none"; source: "none"; state: WorkflowState };
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Decide what `session_start` should do, from the rebuilt state + the current session handle
|
|
192
|
+
* + the launch env. Reload vs fork is distinguished by the `run_id ↔ pi_session_id` mapping
|
|
193
|
+
* (NOT `event.reason`, which is "startup" for a headless `pi --fork`): if the branch already
|
|
194
|
+
* carries a `run_id` whose recorded `pi_session_id` differs from the current session, the id
|
|
195
|
+
* was inherited across a fork → derive a child; if it matches (or is absent), it's a reload.
|
|
196
|
+
*/
|
|
197
|
+
/**
|
|
198
|
+
* The registry stage id the launched run is acting on, read from its handoff blob, or null.
|
|
199
|
+
* Only `claim` (cold) and `keep` (reload) sessions have a settled run whose handoff records a
|
|
200
|
+
* `stage`; `fork` and `none` carry no launched stage (LWW restores their state instead). The
|
|
201
|
+
* stage gates whether `session_start` reconciles `cache.plan-ref` into `active_plan_ref`.
|
|
202
|
+
*/
|
|
203
|
+
export function resolveRunStage(decision: ClaimDecision, cwd: string): string | null {
|
|
204
|
+
const runId =
|
|
205
|
+
decision.action === "claim"
|
|
206
|
+
? decision.runId
|
|
207
|
+
: decision.action === "keep"
|
|
208
|
+
? decision.state.run_id
|
|
209
|
+
: null;
|
|
210
|
+
if (runId === undefined || runId === null) return null;
|
|
211
|
+
const stage = readHandoff(cwd, runId)?.stage;
|
|
212
|
+
return typeof stage === "string" && stage !== "" ? stage : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function decideClaim(args: {
|
|
216
|
+
state: WorkflowState;
|
|
217
|
+
currentSessionId: string | null;
|
|
218
|
+
envRunId: string | null;
|
|
219
|
+
cwd: string;
|
|
220
|
+
}): ClaimDecision {
|
|
221
|
+
const { state, currentSessionId, envRunId, cwd } = args;
|
|
222
|
+
if (state.run_id !== undefined) {
|
|
223
|
+
if (state.pi_session_id === undefined || state.pi_session_id === currentSessionId) {
|
|
224
|
+
return { action: "keep", source: "session", state };
|
|
225
|
+
}
|
|
226
|
+
const childRunId = deriveForkRunId(state.run_id, cwd);
|
|
227
|
+
return { action: "fork", source: "fork", childRunId, parentRunId: state.run_id, state };
|
|
228
|
+
}
|
|
229
|
+
if (envRunId !== null && envRunId !== "") {
|
|
230
|
+
return { action: "claim", source: "env", runId: envRunId };
|
|
231
|
+
}
|
|
232
|
+
return { action: "none", source: "none", state };
|
|
233
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The footer provider seam (INSTALL-SITE / runtime vacating). The footer is the SECOND interface
|
|
2
|
+
// seam (no durable artifact to bridge), mirroring askuser but with a different vacating mechanism:
|
|
3
|
+
// perk installs its own footer (`installPerkFooter`) inside `index.ts`'s `session_start` event
|
|
4
|
+
// handler, so under a foreign `[providers] footer` selection perk simply does NOT call
|
|
5
|
+
// `installPerkFooter` — a runtime guard at that single install site, keyed off `ctx.cwd`. That
|
|
6
|
+
// leaves the foreign footer (`pi-powerline-footer` or `pi-bar`) as the sole footer surface;
|
|
7
|
+
// perk's objective/checkpoints progress still reaches it automatically via the already-publishing
|
|
8
|
+
// composed `perk` `setStatus` slot (both foreign footers render extension statuses), so the
|
|
9
|
+
// adapter is vacate-only (`adapter: null`, no shim, no injected context).
|
|
10
|
+
//
|
|
11
|
+
// This module reads config (via `resolveProviders`/`loadPerkConfig`) so it does NOT belong in the
|
|
12
|
+
// dependency-free `surfaces.ts`; it contains no rich-UI calls, so `surfacesGuard.test.ts` passes
|
|
13
|
+
// it cleanly. Mirror of `askUser.ts`'s `resolvedAskUserProviderId` / `isPerkAskUserReferenceSelected`.
|
|
14
|
+
|
|
15
|
+
import { loadPerkConfig } from "../substrate/config.ts";
|
|
16
|
+
import {
|
|
17
|
+
loadProviders,
|
|
18
|
+
PERK_FOOTER_PROVIDER_ID,
|
|
19
|
+
resolveProviders,
|
|
20
|
+
} from "../substrate/providers.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The resolved `[providers] footer` selection id for `cwd`. Fail-safe to the perk-footer
|
|
24
|
+
* reference: any load/resolution failure (corrupt bundled set, etc.) returns the reference id so
|
|
25
|
+
* perk keeps installing its own footer — the default path is the hard guarantee. Mirror of
|
|
26
|
+
* `resolvedAskUserProviderId`.
|
|
27
|
+
*/
|
|
28
|
+
export function resolvedFooterProviderId(cwd: string): string {
|
|
29
|
+
try {
|
|
30
|
+
return resolveProviders(loadPerkConfig(cwd).providers, loadProviders()).footer.id;
|
|
31
|
+
} catch {
|
|
32
|
+
return PERK_FOOTER_PROVIDER_ID;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether perk's own footer reference is the selected footer provider for `cwd`. When a foreign
|
|
38
|
+
* footer provider is selected via `[providers] footer`, perk vacates `installPerkFooter` so the
|
|
39
|
+
* foreign footer is the sole footer surface.
|
|
40
|
+
*/
|
|
41
|
+
export function isPerkFooterReferenceSelected(cwd: string): boolean {
|
|
42
|
+
return resolvedFooterProviderId(cwd) === PERK_FOOTER_PROVIDER_ID;
|
|
43
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The one headless-safe report seam — owns the `perk: <scope> — <message>` prefix, the severity,
|
|
2
|
+
// and the `hasUI ? notify : console.error` routing so callers inherit the headless-fail-safe
|
|
3
|
+
// invariant for free (cf. the `branchOf`/`BranchSource` seam in workflowState.ts).
|
|
4
|
+
|
|
5
|
+
export type Severity = "info" | "warning" | "error";
|
|
6
|
+
|
|
7
|
+
/** The minimal headless-aware surface report() needs. `ExtensionContext` satisfies it; tests fake it. */
|
|
8
|
+
export interface ReportTarget {
|
|
9
|
+
hasUI: boolean;
|
|
10
|
+
ui: { notify(message: string, type?: Severity): void };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The single headless-safe report seam. Builds `perk: <scope> — <message>`, notifies the UI when
|
|
15
|
+
* present and otherwise writes to stderr (the headless-fail-safe invariant — callers inherit it for
|
|
16
|
+
* free). Pass { alsoLog: true } to ALSO write to stderr when headful (cold-door failures land in run
|
|
17
|
+
* logs even in a TUI). Returns the prefixed string for reuse (tool-result text, etc.).
|
|
18
|
+
*/
|
|
19
|
+
export function report(
|
|
20
|
+
target: ReportTarget,
|
|
21
|
+
scope: string,
|
|
22
|
+
severity: Severity,
|
|
23
|
+
message: string,
|
|
24
|
+
opts?: { alsoLog?: boolean },
|
|
25
|
+
): string {
|
|
26
|
+
const full = `perk: ${scope} — ${message}`;
|
|
27
|
+
if (target.hasUI) {
|
|
28
|
+
target.ui.notify(full, severity);
|
|
29
|
+
if (opts?.alsoLog) console.error(full);
|
|
30
|
+
} else {
|
|
31
|
+
console.error(full);
|
|
32
|
+
}
|
|
33
|
+
return full;
|
|
34
|
+
}
|