@mgiles/perk 3.0.0 → 3.2.0
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/extension/adapters/planAdapterPlannotator.ts +12 -9
- package/extension/doors/address.ts +11 -0
- package/extension/doors/commitCompact.ts +98 -10
- package/extension/doors/draftReviewWaveTools.ts +43 -15
- package/extension/doors/dreamWaveTools.ts +489 -0
- package/extension/doors/land.ts +6 -0
- package/extension/doors/learn.ts +16 -3
- package/extension/doors/lifecycleGates.ts +36 -1
- package/extension/doors/objectiveReviewBrowser.ts +36 -13
- package/extension/doors/objectiveStack.ts +424 -24
- package/extension/doors/planReviewBrowser.ts +30 -8
- package/extension/doors/plannotatorHandoff.ts +80 -8
- package/extension/doors/prReview.ts +158 -50
- package/extension/doors/prReviewBrowser.ts +75 -27
- package/extension/doors/prReviewDynamic.ts +33 -13
- package/extension/doors/ready.ts +209 -17
- package/extension/doors/reviewWaveTools.ts +61 -17
- package/extension/doors/stackReviewBrowser.ts +573 -0
- package/extension/doors/submit.ts +36 -10
- package/extension/doors/submitPrReview.ts +116 -19
- package/extension/factories/objectiveDraft.ts +95 -27
- package/extension/factories/objectiveDreamReport.ts +347 -0
- package/extension/factories/objectivePlan.ts +12 -6
- package/extension/factories/objectiveSave.ts +77 -1
- package/extension/factories/planReview.ts +173 -10
- package/extension/index.ts +88 -16
- package/extension/substrate/agentScratch.ts +171 -0
- package/extension/substrate/bindingDelivery.ts +9 -11
- package/extension/substrate/cache.ts +92 -2
- package/extension/substrate/command.ts +9 -6
- package/extension/substrate/config.ts +10 -3
- package/extension/substrate/git.ts +85 -2
- package/extension/substrate/paths.ts +2 -7
- package/extension/substrate/resolverLease.ts +363 -0
- package/extension/substrate/result.ts +3 -2
- package/extension/substrate/sessionData.ts +6 -4
- package/extension/substrate/sessionPointers.ts +3 -4
- package/extension/substrate/toolGating.ts +25 -0
- package/extension/substrate/workflowState.ts +57 -5
- package/extension/surfaces/report.ts +38 -12
- package/extension/surfaces/surfaces.ts +129 -7
- package/extension/vendor/btw/btw.ts +38 -6
- package/extension/waves/adversarialReviewWave.ts +34 -3
- package/extension/waves/draftReviewWave.ts +17 -1
- package/extension/waves/dreamReducerWave.ts +700 -0
- package/extension/waves/dreamReport.ts +1494 -0
- package/extension/waves/dreamWave.ts +927 -0
- package/extension/waves/harvestWave.ts +1 -1
- package/extension/waves/ponytail.ts +104 -0
- package/extension/waves/prReviewDynamicWave.ts +115 -34
- package/extension/waves/prReviewWave.ts +122 -17
- package/extension/waves/reportWave.ts +103 -7
- package/extension/worker/readOnlySession.ts +2 -3
- package/package.json +6 -3
- package/prompts/_fixtures/live.yaml +112 -0
- package/prompts/commit-and-compact-continuation.md +13 -0
- package/prompts/contexts/adapters/plannotator-objective.md +7 -1
- package/prompts/contexts/adapters/plannotator-plan.md +7 -1
- package/prompts/contexts/adapters/tombell-plan.md +4 -0
- package/prompts/contexts/plan-authoring.md +6 -5
- package/prompts/stages/conflict-resolution-continuation.md +6 -0
- package/prompts/stages/conflict-resolution.md +2 -2
- package/prompts/stages/learn-dream.md +10 -0
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-author/seed.md +1 -1
- package/prompts/stages/objective-reconcile-ready.md +7 -0
- package/prompts/stages/objective-review-browser.md +1 -1
- package/prompts/stages/objective-sync.md +1 -1
- package/prompts/stages/plan-review-browser.md +1 -1
- package/prompts/stages/pr-review-browser/active.md +1 -1
- package/prompts/stages/pr-review-browser/foreign.md +1 -1
- package/prompts/stages/pr-review-dynamic.md +5 -5
- package/prompts/stages/pr-review-terminal/active.md +1 -1
- package/prompts/stages/pr-review-terminal/foreign.md +1 -1
- package/prompts/stages/pr-review-terminal/local.md +1 -1
- package/prompts/stages/pr-review.md +5 -5
- package/prompts/stages/stack-review/cold.md +1 -0
- package/prompts/stages/stack-review-browser/stack.md +23 -0
- package/shared/README.md +0 -3
- package/shared/bindings.yaml +6 -0
- package/shared/contracts.md +3784 -1851
- package/shared/registry.yaml +28 -13
- package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
- package/shared/schemas/outputs/objective-doctor.schema.json +39 -1
- package/shared/schemas/outputs/objective-stack-status.schema.json +172 -1
- package/shared/schemas/outputs/pr-land.schema.json +3 -3
- package/shared/schemas/outputs/pr-ready.schema.json +110 -2
- package/shared/contracts-history.md +0 -605
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Run-owned agent scratch provisioning and hidden model guidance (contracts.md §8.1).
|
|
2
|
+
//
|
|
3
|
+
// This module is guidance, not enforcement: it registers no tool and changes no process-global
|
|
4
|
+
// temp environment. Eligible write-capable model turns receive the repository-relative current-run
|
|
5
|
+
// path after the confined directory has been established. The context filter removes inherited or
|
|
6
|
+
// stale direct scratch custom blocks. A compaction summary may quote old prose/path text; that is
|
|
7
|
+
// not a live guidance delivery or authoritative provenance, and is deliberately left intact.
|
|
8
|
+
|
|
9
|
+
import { relative, sep } from "node:path";
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { type ReportTarget, report } from "../surfaces/report.ts";
|
|
12
|
+
import { agentScratchDir, ensureAgentScratch } from "./cache.ts";
|
|
13
|
+
import { activeSessionRunId, type SessionDataCtx } from "./sessionData.ts";
|
|
14
|
+
import {
|
|
15
|
+
activeContextWindow,
|
|
16
|
+
type BranchEntry,
|
|
17
|
+
type BranchSource,
|
|
18
|
+
branchOf,
|
|
19
|
+
rebuildWorkflowState,
|
|
20
|
+
} from "./workflowState.ts";
|
|
21
|
+
|
|
22
|
+
export const AGENT_SCRATCH_CONTEXT_TYPE = "perk:agent-scratch";
|
|
23
|
+
|
|
24
|
+
/** Perk-owned children whose canonical definitions are report-only. */
|
|
25
|
+
export const REPORT_ONLY_CHILD_AGENTS = [
|
|
26
|
+
"perk.adversarial-reviewer",
|
|
27
|
+
"perk.draft-reviewer",
|
|
28
|
+
"perk.dream-analyst",
|
|
29
|
+
"perk.dream-reducer",
|
|
30
|
+
"perk.harvest-analyst",
|
|
31
|
+
"perk.learn-analyst",
|
|
32
|
+
"perk.objective-explorer",
|
|
33
|
+
"perk.pr-reviewer",
|
|
34
|
+
"perk.review-angle-selector",
|
|
35
|
+
"perk.review-classifier",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
const REPORT_ONLY_CHILD_SET = new Set<string>(REPORT_ONLY_CHILD_AGENTS);
|
|
39
|
+
|
|
40
|
+
export interface AgentScratchBlock {
|
|
41
|
+
runId: string;
|
|
42
|
+
/** Repository-relative POSIX-style path carried in model context. */
|
|
43
|
+
path: string;
|
|
44
|
+
marker: string;
|
|
45
|
+
content: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type AgentScratchContext = SessionDataCtx & ReportTarget;
|
|
49
|
+
|
|
50
|
+
/** Render the exact run-aware hidden block; provisioning stays in the resolver below. */
|
|
51
|
+
export function renderAgentScratchBlock(cwd: string, runId: string): AgentScratchBlock {
|
|
52
|
+
const path = relative(cwd, agentScratchDir(cwd, runId)).split(sep).join("/");
|
|
53
|
+
const marker = `[PERK AGENT SCRATCH run=${runId} path=${path}]`;
|
|
54
|
+
const content = [
|
|
55
|
+
marker,
|
|
56
|
+
`Put disposable command/model intermediate files for this run in \`${path}/\` instead of shared \`/tmp\`.`,
|
|
57
|
+
"Use descriptive, non-colliding names. These files are non-authoritative: re-read canonical repository or backend sources before making durable decisions.",
|
|
58
|
+
].join("\n");
|
|
59
|
+
return { runId, path, marker, content };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Eligibility follows the branch-LWW workflow mode and the locally exposed pi-subagents child
|
|
64
|
+
* identity. Unknown/custom children remain eligible because no generic report-only metadata exists.
|
|
65
|
+
*/
|
|
66
|
+
export function isAgentScratchEligible(
|
|
67
|
+
ctx: BranchSource,
|
|
68
|
+
childAgent: string | undefined = process.env.PI_SUBAGENT_CHILD_AGENT,
|
|
69
|
+
): boolean {
|
|
70
|
+
try {
|
|
71
|
+
if (rebuildWorkflowState(branchOf(ctx)).mode === "read-only") return false;
|
|
72
|
+
} catch {
|
|
73
|
+
// No rebuilt explicit read-only mode means the parent posture remains the fallback.
|
|
74
|
+
}
|
|
75
|
+
return childAgent === undefined || !REPORT_ONLY_CHILD_SET.has(childAgent);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AgentScratchProvisioner {
|
|
79
|
+
resolve(ctx: AgentScratchContext): AgentScratchBlock | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build one extension-activation-scoped resolver. Failures warn once per run but are retried on
|
|
84
|
+
* every call; one success clears suppression so a later regression is reported again.
|
|
85
|
+
*/
|
|
86
|
+
export function createAgentScratchProvisioner(
|
|
87
|
+
deps: {
|
|
88
|
+
ensure?: typeof ensureAgentScratch;
|
|
89
|
+
warn?: (ctx: AgentScratchContext, runId: string, error: unknown) => void;
|
|
90
|
+
} = {},
|
|
91
|
+
): AgentScratchProvisioner {
|
|
92
|
+
const ensure = deps.ensure ?? ensureAgentScratch;
|
|
93
|
+
const warn =
|
|
94
|
+
deps.warn ??
|
|
95
|
+
((ctx: AgentScratchContext, runId: string, error: unknown) => {
|
|
96
|
+
report(
|
|
97
|
+
ctx,
|
|
98
|
+
"agent scratch",
|
|
99
|
+
"warning",
|
|
100
|
+
`could not provision scratch for run ${runId}: ${String(error)}`,
|
|
101
|
+
{ alsoLog: true },
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
const suppressedRuns = new Set<string>();
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
resolve(ctx): AgentScratchBlock | null {
|
|
108
|
+
const runId = activeSessionRunId(ctx);
|
|
109
|
+
if (runId === null) return null;
|
|
110
|
+
try {
|
|
111
|
+
ensure(ctx.cwd, runId);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (!suppressedRuns.has(runId)) {
|
|
114
|
+
suppressedRuns.add(runId);
|
|
115
|
+
warn(ctx, runId, error);
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
suppressedRuns.delete(runId);
|
|
120
|
+
return renderAgentScratchBlock(ctx.cwd, runId);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Whether this exact current-run block remains directly represented after compaction. */
|
|
126
|
+
function branchHasBlock(branch: readonly BranchEntry[], block: AgentScratchBlock): boolean {
|
|
127
|
+
return activeContextWindow(branch).some(
|
|
128
|
+
(entry) =>
|
|
129
|
+
entry.customType === AGENT_SCRATCH_CONTEXT_TYPE &&
|
|
130
|
+
((entry.type === "custom_message" && entry.content === block.content) ||
|
|
131
|
+
(entry.type === "custom" && entry.data?.content === block.content)),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Register eligible-turn delivery and direct scratch-custom context hygiene. */
|
|
136
|
+
export function registerAgentScratch(
|
|
137
|
+
pi: ExtensionAPI,
|
|
138
|
+
provisioner: AgentScratchProvisioner = createAgentScratchProvisioner(),
|
|
139
|
+
): void {
|
|
140
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
141
|
+
if (!isAgentScratchEligible(ctx)) return;
|
|
142
|
+
|
|
143
|
+
// Provision before dedup: an externally deleted directory is repaired even while the live
|
|
144
|
+
// branch still carries this run's exact guidance block.
|
|
145
|
+
const block = provisioner.resolve(ctx);
|
|
146
|
+
if (block === null) return;
|
|
147
|
+
if (branchHasBlock(branchOf(ctx), block)) return;
|
|
148
|
+
return {
|
|
149
|
+
message: {
|
|
150
|
+
customType: AGENT_SCRATCH_CONTEXT_TYPE,
|
|
151
|
+
content: block.content,
|
|
152
|
+
display: false,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
pi.on("context", async (event, ctx) => {
|
|
158
|
+
const eligible = isAgentScratchEligible(ctx);
|
|
159
|
+
const block = eligible ? provisioner.resolve(ctx) : null;
|
|
160
|
+
let keptCurrent = false;
|
|
161
|
+
return {
|
|
162
|
+
messages: event.messages.filter((message) => {
|
|
163
|
+
const candidate = message as { customType?: string; content?: unknown };
|
|
164
|
+
if (candidate.customType !== AGENT_SCRATCH_CONTEXT_TYPE) return true;
|
|
165
|
+
if (block === null || candidate.content !== block.content || keptCurrent) return false;
|
|
166
|
+
keptCurrent = true;
|
|
167
|
+
return true;
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
//
|
|
15
15
|
// This is the SINGLE delivery path for perk's own nudges. Delivery NEVER double-delivers: the
|
|
16
16
|
// cold↔warm dedup marker is `BINDING_HEADER` itself — the cold door's initial prompt and every warm
|
|
17
|
-
// injection carry it, so Mechanism A injects ONLY when neither the branch
|
|
18
|
-
// turn's prompt already carries the header (idempotent across turns/reloads;
|
|
19
|
-
// drops the original it re-delivers). The prompt scan is
|
|
17
|
+
// injection carry it, so Mechanism A injects ONLY when neither the compaction-active branch window
|
|
18
|
+
// NOR the submitting turn's prompt already carries the header (idempotent across turns/reloads;
|
|
19
|
+
// after compaction drops the original from model context it re-delivers). The prompt scan is
|
|
20
|
+
// load-bearing on the launch turn: at
|
|
20
21
|
// `before_agent_start` the just-submitted prompt is NOT yet on the branch, so the branch scan
|
|
21
22
|
// alone would miss a cold seed's binding suffix and double-deliver.
|
|
22
23
|
//
|
|
@@ -30,6 +31,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
30
31
|
import { loadDefaultBindings, resolveBindings, type SkillBinding } from "./bindings.ts";
|
|
31
32
|
import { loadPerkConfig } from "./config.ts";
|
|
32
33
|
import {
|
|
34
|
+
activeContextWindow,
|
|
33
35
|
type BranchEntry,
|
|
34
36
|
branchCarries,
|
|
35
37
|
branchOf,
|
|
@@ -154,13 +156,9 @@ function stripFrontmatter(text: string): string {
|
|
|
154
156
|
return text; // no closing delimiter — leave the text unchanged
|
|
155
157
|
}
|
|
156
158
|
|
|
157
|
-
/**
|
|
158
|
-
* Whether anything on the branch already carries `BINDING_HEADER` — the cold launch's initial
|
|
159
|
-
* prompt OR a prior warm injection. Serializing each entry is the robust, shape-agnostic scan: the
|
|
160
|
-
* header is a distinctive literal, so a substring hit means "already delivered on this branch".
|
|
161
|
-
*/
|
|
159
|
+
/** Whether a cold prompt or warm injection still active in model context carries the marker. */
|
|
162
160
|
function branchHasHeader(branch: readonly BranchEntry[]): boolean {
|
|
163
|
-
return branchCarries(branch, BINDING_HEADER);
|
|
161
|
+
return branchCarries(activeContextWindow(branch), BINDING_HEADER);
|
|
164
162
|
}
|
|
165
163
|
|
|
166
164
|
/** The launched stage's `stage:<id>` render, or `null` when there is no stage / nothing matches. */
|
|
@@ -178,8 +176,8 @@ function activeStageRender(cwd: string, branch: readonly BranchEntry[]): Binding
|
|
|
178
176
|
*/
|
|
179
177
|
export function registerBindingDelivery(pi: ExtensionAPI): void {
|
|
180
178
|
// Mechanism A — inject the launched stage's resolved bindings as a hidden context message,
|
|
181
|
-
// but ONLY when no entry
|
|
182
|
-
// BINDING_HEADER (the cold door's initial prompt or a prior warm inject) — the cold↔warm
|
|
179
|
+
// but ONLY when no entry in the compaction-active branch window AND not the submitting turn's
|
|
180
|
+
// prompt already carries BINDING_HEADER (the cold door's initial prompt or a prior warm inject) — the cold↔warm
|
|
183
181
|
// idempotency guard. The `event.prompt` scan covers the launch turn, where the just-submitted
|
|
184
182
|
// prompt is not yet on the branch; a worker prompt carries no header, so Mechanism A still
|
|
185
183
|
// fires there (contracts.md §8.38).
|
|
@@ -23,15 +23,18 @@
|
|
|
23
23
|
|
|
24
24
|
import { randomBytes } from "node:crypto";
|
|
25
25
|
import {
|
|
26
|
+
chmodSync,
|
|
26
27
|
existsSync,
|
|
28
|
+
lstatSync,
|
|
27
29
|
mkdirSync,
|
|
28
30
|
readdirSync,
|
|
29
31
|
readFileSync,
|
|
32
|
+
realpathSync,
|
|
30
33
|
renameSync,
|
|
31
34
|
rmSync,
|
|
32
35
|
writeFileSync,
|
|
33
36
|
} from "node:fs";
|
|
34
|
-
import { join } from "node:path";
|
|
37
|
+
import { join, relative } from "node:path";
|
|
35
38
|
|
|
36
39
|
/**
|
|
37
40
|
* Atomically replace `path` with `content` (the interior atomic-write seam).
|
|
@@ -129,6 +132,11 @@ export function runScratchDir(cwd: string, runId: string): string {
|
|
|
129
132
|
return join(scratchDir(cwd), "runs", runId);
|
|
130
133
|
}
|
|
131
134
|
|
|
135
|
+
/** The run-owned directory for disposable, non-authoritative model intermediates. */
|
|
136
|
+
export function agentScratchDir(cwd: string, runId: string): string {
|
|
137
|
+
return join(runScratchDir(cwd, runId), "agent");
|
|
138
|
+
}
|
|
139
|
+
|
|
132
140
|
/**
|
|
133
141
|
* The session data dir for a run — a dedicated `data/` subdir so
|
|
134
142
|
* run-scoped session artifacts never overlap perk machine records (dispatch.json,
|
|
@@ -139,9 +147,91 @@ export function sessionDataDir(cwd: string, runId: string): string {
|
|
|
139
147
|
return join(runScratchDir(cwd, runId), "data");
|
|
140
148
|
}
|
|
141
149
|
|
|
150
|
+
/** Reject run ids that could select anything except one child of the shared runs directory. */
|
|
151
|
+
function assertSafeRunId(runId: string): void {
|
|
152
|
+
if (
|
|
153
|
+
runId.length === 0 ||
|
|
154
|
+
runId === "." ||
|
|
155
|
+
runId === ".." ||
|
|
156
|
+
runId.includes("/") ||
|
|
157
|
+
runId.includes("\\") ||
|
|
158
|
+
runId.includes("\0")
|
|
159
|
+
) {
|
|
160
|
+
throw new Error(`refusing unsafe run id ${JSON.stringify(runId)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Ensure one checkout-owned path component is a real directory, never a static redirect.
|
|
166
|
+
* Check-before-create races against a same-UID process are intentionally out of scope.
|
|
167
|
+
*/
|
|
168
|
+
function ensureUnredirectedDirectory(
|
|
169
|
+
path: string,
|
|
170
|
+
opts: { createMode: number; rejectGroupWorldWrite: boolean },
|
|
171
|
+
): void {
|
|
172
|
+
let stat: ReturnType<typeof lstatSync>;
|
|
173
|
+
try {
|
|
174
|
+
stat = lstatSync(path);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
177
|
+
mkdirSync(path, { mode: opts.createMode });
|
|
178
|
+
stat = lstatSync(path);
|
|
179
|
+
}
|
|
180
|
+
if (stat.isSymbolicLink()) throw new Error(`refusing a symlinked run-scratch path: ${path}`);
|
|
181
|
+
if (!stat.isDirectory()) throw new Error(`refusing a non-directory run-scratch path: ${path}`);
|
|
182
|
+
if (opts.rejectGroupWorldWrite && (stat.mode & 0o022) !== 0) {
|
|
183
|
+
throw new Error(`refusing a group/world-writable run-scratch path: ${path}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Establish a run root beneath this checkout without following redirected checkout content.
|
|
189
|
+
* Symlinks above `cwd` remain legal; every existing component from `.perk` through the run root
|
|
190
|
+
* must be a real directory without group/world write permission, and missing components are
|
|
191
|
+
* created no broader than 0755 even under a permissive umask. Run-id validation happens before
|
|
192
|
+
* the first filesystem write.
|
|
193
|
+
*/
|
|
142
194
|
export function ensureRunScratch(cwd: string, runId: string): string {
|
|
195
|
+
assertSafeRunId(runId);
|
|
143
196
|
const dir = runScratchDir(cwd, runId);
|
|
144
|
-
|
|
197
|
+
const components = [
|
|
198
|
+
join(cwd, ".perk"),
|
|
199
|
+
join(cwd, ".perk", "workflow"),
|
|
200
|
+
scratchDir(cwd),
|
|
201
|
+
join(scratchDir(cwd), "runs"),
|
|
202
|
+
dir,
|
|
203
|
+
];
|
|
204
|
+
for (const component of components) {
|
|
205
|
+
ensureUnredirectedDirectory(component, {
|
|
206
|
+
createMode: 0o755,
|
|
207
|
+
rejectGroupWorldWrite: true,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const expected = join(realpathSync(cwd), relative(cwd, dir));
|
|
212
|
+
if (realpathSync(dir) !== expected) {
|
|
213
|
+
throw new Error(`refusing a redirected run-scratch dir: ${dir}`);
|
|
214
|
+
}
|
|
215
|
+
return dir;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Create the private run-owned agent directory as 0700 from the outset, then re-apply that mode on
|
|
220
|
+
* reuse. The mode protects against other OS users; it is not a sandbox from another process running
|
|
221
|
+
* as the same user.
|
|
222
|
+
*/
|
|
223
|
+
export function ensureAgentScratch(cwd: string, runId: string): string {
|
|
224
|
+
const runDir = ensureRunScratch(cwd, runId);
|
|
225
|
+
const dir = agentScratchDir(cwd, runId);
|
|
226
|
+
ensureUnredirectedDirectory(dir, {
|
|
227
|
+
createMode: 0o700,
|
|
228
|
+
rejectGroupWorldWrite: false,
|
|
229
|
+
});
|
|
230
|
+
const expected = join(realpathSync(runDir), "agent");
|
|
231
|
+
if (realpathSync(dir) !== expected) {
|
|
232
|
+
throw new Error(`refusing a redirected agent scratch dir: ${dir}`);
|
|
233
|
+
}
|
|
234
|
+
chmodSync(dir, 0o700);
|
|
145
235
|
return dir;
|
|
146
236
|
}
|
|
147
237
|
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// The single chokepoint that gives every perk command a uniform, immediate "running…"
|
|
2
2
|
// acknowledgement at entry. pi does not echo the invoked command and handlers only report() at the
|
|
3
3
|
// end, so without this a command's async work (cold-door subprocess calls, GitHub round-trips) is
|
|
4
|
-
// dead air between Enter and completion. registerPerkCommand
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// dead air between Enter and completion. registerPerkCommand attaches the command's durable report-
|
|
5
|
+
// detail sink to the exact context object, then emits one transient entry toast through report()
|
|
6
|
+
// before awaiting the original handler. The toast fires synchronously before the first await, so it
|
|
7
|
+
// lands before any cold-door work, sendUserMessage drive, or gate transition; the wrapper does not
|
|
8
|
+
// try/catch, so errors propagate exactly as before. The WeakMap attachment deliberately survives the
|
|
9
|
+
// handler for background work launched with the same context.
|
|
9
10
|
|
|
10
11
|
import type { ExtensionAPI, RegisteredCommand } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { report } from "../surfaces/report.ts";
|
|
12
|
+
import { attachReportDetailSink, report } from "../surfaces/report.ts";
|
|
13
|
+
import { createReportDetailSink } from "../surfaces/surfaces.ts";
|
|
12
14
|
|
|
13
15
|
export function registerPerkCommand(
|
|
14
16
|
pi: ExtensionAPI,
|
|
@@ -18,6 +20,7 @@ export function registerPerkCommand(
|
|
|
18
20
|
pi.registerCommand(name, {
|
|
19
21
|
...options,
|
|
20
22
|
handler: async (args, ctx) => {
|
|
23
|
+
attachReportDetailSink(ctx, createReportDetailSink(pi));
|
|
21
24
|
report(ctx, name, "info", "running…");
|
|
22
25
|
await options.handler(args, ctx);
|
|
23
26
|
},
|
|
@@ -49,7 +49,8 @@ export interface PerkConfig {
|
|
|
49
49
|
* The agent-keyed `[models.subagents]` table: a per-agent model override for each perk-owned
|
|
50
50
|
* project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`,
|
|
51
51
|
* `learn-analyst`, `adversarial-reviewer`, `review-angle-selector`, `draft-reviewer`,
|
|
52
|
-
* `harvest-analyst` — consumed by `run_harvest_wave` at execute time — and
|
|
52
|
+
* `harvest-analyst` — consumed by `run_harvest_wave` at execute time — `dream-analyst` and
|
|
53
|
+
* `dream-reducer` — consumed by `run_dream_wave` at execute time — and the
|
|
53
54
|
* dev-only `session-auditor`, whose def is repo-local to perk's own repository
|
|
54
55
|
* (`.pi/agents/perk-dev/session-auditor.md`, never delivered by `perk init`), so the key is
|
|
55
56
|
* dormant in consumer repos). Each configured
|
|
@@ -57,8 +58,10 @@ export interface PerkConfig {
|
|
|
57
58
|
* workflowScript call — a default flowing onto every lane, single-child runs included (as
|
|
58
59
|
* /pr-review does); when a key is absent the agent's frontmatter `model` (in
|
|
59
60
|
* `.pi/agents/perk/<name>.md`; the session-auditor's in its repo-local def) is the default.
|
|
60
|
-
* (`subagents.agentOverrides`
|
|
61
|
-
*
|
|
61
|
+
* (Since pi-subagents 0.52, `subagents.agentOverrides` also reaches custom/project agents —
|
|
62
|
+
* but only as a frontmatter-sensitive fill that never displaces a field the def's own
|
|
63
|
+
* frontmatter sets; every perk def pins `model:` in frontmatter, so this inline workflow-level
|
|
64
|
+
* injection remains the mechanism.)
|
|
62
65
|
* A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
|
|
63
66
|
* `"inherit"` sentinel (child inherits the parent session's model) — both resolved by
|
|
64
67
|
* pi-subagents on the injected value (the last-colon segment counts as thinking only when it
|
|
@@ -75,6 +78,8 @@ export interface PerkConfig {
|
|
|
75
78
|
"review-angle-selector"?: string;
|
|
76
79
|
"draft-reviewer"?: string;
|
|
77
80
|
"harvest-analyst"?: string;
|
|
81
|
+
"dream-analyst"?: string;
|
|
82
|
+
"dream-reducer"?: string;
|
|
78
83
|
"session-auditor"?: string;
|
|
79
84
|
};
|
|
80
85
|
/**
|
|
@@ -326,6 +331,8 @@ const SUBAGENT_KEYS = [
|
|
|
326
331
|
"review-angle-selector",
|
|
327
332
|
"draft-reviewer",
|
|
328
333
|
"harvest-analyst",
|
|
334
|
+
"dream-analyst",
|
|
335
|
+
"dream-reducer",
|
|
329
336
|
// Dev-only: the perk-dev session-audit judgment wave's auditor (dormant in consumer repos).
|
|
330
337
|
"session-auditor",
|
|
331
338
|
] as const;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// Node builtins only (so it loads cleanly under `node --test`); shells `git` via `execFileSync`,
|
|
4
4
|
// never with a shell. Fail-open by design: every failure degrades to the caller's `cwd` (or null
|
|
5
5
|
// where stated) rather than throwing — the carriers that use this must never wedge a session.
|
|
6
|
+
// The ONE deliberate fail-closed composition is `revalidationBracket` (documented there): a
|
|
7
|
+
// snapshot proof must treat an unprovable probe as drift, never as "unchanged".
|
|
6
8
|
|
|
7
9
|
import { execFileSync } from "node:child_process";
|
|
8
10
|
import { isAbsolute, resolve } from "node:path";
|
|
@@ -115,9 +117,90 @@ export function worktreeDirty(cwd: string): boolean | null {
|
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
/**
|
|
118
|
-
*
|
|
120
|
+
* Whether the index carries `assume-unchanged` (a lowercase `git ls-files -v` tag) or
|
|
121
|
+
* `skip-worktree` (`S`/`s` — sparse checkouts) entries. Either bit hides worktree edits from
|
|
122
|
+
* `git status --porcelain`, so a status-based cleanliness proof over a flagged index is not a
|
|
123
|
+
* proof. **Fail-open to null** on any failure (not a repo, git missing) — callers must NOT
|
|
124
|
+
* conflate null with "no flags". Own `execFileSync` rather than the `git()` helper: `git()`
|
|
125
|
+
* conflates empty output (an empty index — meaningful here) with failure.
|
|
126
|
+
*/
|
|
127
|
+
export function indexHidesChanges(cwd: string): boolean | null {
|
|
128
|
+
try {
|
|
129
|
+
const out = execFileSync("git", ["ls-files", "-v"], {
|
|
130
|
+
cwd,
|
|
131
|
+
encoding: "utf8",
|
|
132
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
133
|
+
});
|
|
134
|
+
return out.split("\n").some((line) => {
|
|
135
|
+
const tag = line[0];
|
|
136
|
+
return tag !== undefined && (tag === "S" || (tag >= "a" && tag <= "z"));
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The dream-snapshot revalidation bracket (contracts.md §8.65) — the module's ONE deliberately
|
|
145
|
+
* **fail-closed** composition (a documented exception to the fail-open charter above): it exists
|
|
146
|
+
* to PROVE the repository still matches a stamped snapshot, so an unprovable probe must read as
|
|
147
|
+
* drift, never as "unchanged". The claim is END-STATE equality only — HEAD unchanged, the
|
|
148
|
+
* working tree clean, and no assume-unchanged/skip-worktree index flags (which would hide edits
|
|
149
|
+
* from the status probe) at the moment of the check — never mid-window byte immutability (a
|
|
150
|
+
* transient modify-and-restore inside the window is invisible by design; §8.65's accepted
|
|
151
|
+
* residuals). `probes` defaults to the real `headSha`/`worktreeDirty`/`indexHidesChanges` and
|
|
152
|
+
* exists so tests can pin each fail-closed arm independently (from a non-repo fixture the HEAD
|
|
153
|
+
* arm returns first, making the later null arms reachable only through the seam).
|
|
154
|
+
*/
|
|
155
|
+
export function revalidationBracket(
|
|
156
|
+
cwd: string,
|
|
157
|
+
expectedSha: string,
|
|
158
|
+
probes?: {
|
|
159
|
+
head?: (cwd: string) => string | null;
|
|
160
|
+
dirty?: (cwd: string) => boolean | null;
|
|
161
|
+
flags?: (cwd: string) => boolean | null;
|
|
162
|
+
},
|
|
163
|
+
): { ok: boolean; detail: string | null } {
|
|
164
|
+
const head = probes?.head ?? headSha;
|
|
165
|
+
const dirty = probes?.dirty ?? worktreeDirty;
|
|
166
|
+
const flags = probes?.flags ?? indexHidesChanges;
|
|
167
|
+
const actual = head(cwd);
|
|
168
|
+
if (actual === null) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
detail: "HEAD could not be resolved — cannot prove the snapshot is unchanged",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (actual !== expectedSha) {
|
|
175
|
+
return { ok: false, detail: `HEAD moved from ${expectedSha} to ${actual}` };
|
|
176
|
+
}
|
|
177
|
+
const isDirty = dirty(cwd);
|
|
178
|
+
if (isDirty === null) {
|
|
179
|
+
return { ok: false, detail: "working-tree cleanliness could not be verified" };
|
|
180
|
+
}
|
|
181
|
+
if (isDirty) {
|
|
182
|
+
return { ok: false, detail: "the working tree is no longer clean" };
|
|
183
|
+
}
|
|
184
|
+
const hidden = flags(cwd);
|
|
185
|
+
if (hidden === null) {
|
|
186
|
+
return { ok: false, detail: "index flag state could not be verified" };
|
|
187
|
+
}
|
|
188
|
+
if (hidden) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
detail:
|
|
192
|
+
"the index carries assume-unchanged/skip-worktree flag(s) — worktree cleanliness " +
|
|
193
|
+
"cannot be proven against the snapshot",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return { ok: true, detail: null };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The `git log --oneline <fromSha>..HEAD` listing of commits now ahead of `fromSha` — or every
|
|
119
201
|
* commit (`git log --oneline HEAD`) when `fromSha` is null (HEAD was unborn at capture time).
|
|
120
|
-
*
|
|
202
|
+
* This is range evidence, not proof that this command created every listed commit. **Fail-open**:
|
|
203
|
+
* null on failure or when the range is empty.
|
|
121
204
|
*/
|
|
122
205
|
export function commitsSince(cwd: string, fromSha: string | null): string | null {
|
|
123
206
|
const range = fromSha === null ? "HEAD" : `${fromSha}..HEAD`;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// perk-owned dot-directory path construction — the TS twin of perk/substrate/paths.py
|
|
2
2
|
// (contracts.md §8.1).
|
|
3
3
|
//
|
|
4
|
-
// The sole construction site for the perk-owned config family (`config.toml`/`local.toml`)
|
|
5
|
-
//
|
|
4
|
+
// The sole construction site for the perk-owned config family (`config.toml`/`local.toml`).
|
|
5
|
+
// The config family now lives at `.perk/` (TS reads the target only — the legacy
|
|
6
6
|
// `.pi/perk.toml` migration is Python-side). The workflow family lives in the established cache
|
|
7
7
|
// seam (substrate/cache.ts's `workflowDir`); together these two modules own every perk-owned
|
|
8
8
|
// dot-path on this plane.
|
|
@@ -17,11 +17,6 @@ import { join } from "node:path";
|
|
|
17
17
|
export const CONFIG_FILENAME = "config.toml";
|
|
18
18
|
export const LOCAL_CONFIG_FILENAME = "local.toml";
|
|
19
19
|
|
|
20
|
-
/** The perk-owned dot-dir root (shared with Pi today). */
|
|
21
|
-
export function perkDir(cwd: string): string {
|
|
22
|
-
return join(cwd, ".pi");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
20
|
/** The single config-family redirection point (the file helpers derive from it). */
|
|
26
21
|
export function configDir(cwd: string): string {
|
|
27
22
|
return join(cwd, ".perk");
|