@mgiles/perk 3.2.0 → 3.3.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/README.md +5 -0
- package/extension/authoring/gist/draft.ts +198 -0
- package/extension/authoring/gist/prose.ts +46 -0
- package/extension/authoring/gist/review.ts +133 -0
- package/extension/authoring/gist/save.ts +118 -0
- package/extension/authoring/objective/draft.ts +345 -0
- package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
- package/extension/authoring/objective/planning.ts +124 -0
- package/extension/authoring/objective/prose.ts +103 -0
- package/extension/authoring/objective/review.ts +128 -0
- package/extension/authoring/objective/save.ts +224 -0
- package/extension/authoring/plan/draft.ts +84 -0
- package/extension/authoring/plan/prose.ts +41 -0
- package/extension/authoring/plan/review.ts +269 -0
- package/extension/authoring/plan/save.ts +256 -0
- package/extension/authoring/plan/source.ts +82 -0
- package/extension/authoring/refinement/context.ts +468 -0
- package/extension/authoring/refinement/draft.ts +261 -0
- package/extension/authoring/refinement/prose.ts +79 -0
- package/extension/authoring/refinement/review.ts +111 -0
- package/extension/authoring/refinement/save.ts +119 -0
- package/extension/authoring/review/approvalGate.ts +34 -0
- package/extension/authoring/review/draftContext.ts +68 -0
- package/extension/codeReview/automated.ts +352 -0
- package/extension/codeReview/submission.ts +229 -0
- package/extension/delivery/address.ts +295 -0
- package/extension/delivery/ci.ts +355 -0
- package/extension/delivery/commitCompact.ts +93 -0
- package/extension/delivery/conflictResolution.ts +247 -0
- package/extension/delivery/ready.ts +193 -0
- package/extension/delivery/stackConflict.ts +361 -0
- package/extension/delivery/stackObjective.ts +16 -0
- package/extension/delivery/stackReconcile.ts +165 -0
- package/extension/delivery/submit.ts +171 -0
- package/extension/index.ts +365 -380
- package/extension/learning/analystWave.ts +324 -0
- package/extension/learning/audit.ts +667 -0
- package/extension/learning/capture.ts +92 -0
- package/extension/learning/containment.ts +104 -0
- package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
- package/extension/learning/dreamAnalysis.ts +435 -0
- package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
- package/extension/{waves → learning}/dreamReport.ts +35 -31
- package/extension/learning/harvest.ts +491 -0
- package/extension/learning/prose.ts +66 -0
- package/extension/learning/routing.ts +79 -0
- package/extension/pi/v1/bashScanTimeout.ts +64 -0
- package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -311
- package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +53 -33
- package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
- package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +146 -114
- package/extension/{doors/stackReviewBrowser.ts → pi/v1/codeReview/stack.ts} +62 -29
- package/extension/pi/v1/codeReview/submit.ts +354 -0
- package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
- package/extension/pi/v1/contextEvidence.ts +80 -0
- package/extension/pi/v1/contextInjection.ts +207 -0
- package/extension/{doors → pi/v1/delivery}/address.ts +154 -267
- package/extension/pi/v1/delivery/ci.ts +570 -0
- package/extension/pi/v1/delivery/commitCompact.ts +201 -0
- package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
- package/extension/{doors → pi/v1/delivery}/land.ts +123 -61
- package/extension/pi/v1/delivery/ready.ts +322 -0
- package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
- package/extension/pi/v1/delivery/stackDrive.ts +120 -0
- package/extension/pi/v1/delivery/stackLand.ts +223 -0
- package/extension/pi/v1/delivery/stackRecover.ts +265 -0
- package/extension/pi/v1/delivery/stackStatus.ts +237 -0
- package/extension/pi/v1/delivery/stackSync.ts +658 -0
- package/extension/pi/v1/delivery/submit.ts +389 -0
- package/extension/pi/v1/delivery/submitConflict.ts +186 -0
- package/extension/pi/v1/draftReview.ts +431 -0
- package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
- package/extension/pi/v1/gist.ts +794 -0
- package/extension/pi/v1/learning/audit.ts +186 -0
- package/extension/pi/v1/learning/dream.ts +207 -0
- package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
- package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
- package/extension/pi/v1/learning/learn.ts +585 -0
- package/extension/pi/v1/lifecycleGates.ts +127 -0
- package/extension/{factories → pi/v1}/objective.ts +53 -33
- package/extension/pi/v1/objectiveAuthoring.ts +672 -0
- package/extension/pi/v1/objectiveDreamGate.ts +160 -0
- package/extension/{factories/objectivePlan.ts → pi/v1/objectivePlanning.ts} +328 -533
- package/extension/pi/v1/objectiveRefinement.ts +1320 -0
- package/extension/pi/v1/objectiveReview.ts +451 -0
- package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
- package/extension/pi/v1/plan.ts +812 -0
- package/extension/pi/v1/planReview.ts +820 -0
- package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
- package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
- package/extension/pi/v1/providers/plannotator.ts +487 -0
- package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +73 -27
- package/extension/pi/v1/providers/selection.ts +43 -0
- package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
- package/extension/pi/v1/review.ts +538 -0
- package/extension/pi/v1/reviewOutcome.ts +9 -0
- package/extension/pi/v1/scoutWave.ts +318 -0
- package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
- package/extension/session/branchWorkflowSession.ts +60 -0
- package/extension/session/lifecycle.ts +644 -0
- package/extension/session/lifecycleGates.ts +64 -0
- package/extension/session/saveDestination.ts +87 -0
- package/extension/session/workflowSession.ts +971 -0
- package/extension/substrate/agentScratch.ts +27 -54
- package/extension/substrate/bashScanTimeout.ts +181 -0
- package/extension/substrate/bindingDelivery.ts +38 -30
- package/extension/substrate/bindings.ts +4 -5
- package/extension/substrate/cache.ts +64 -12
- package/extension/substrate/childRestrictions.ts +39 -0
- package/extension/substrate/coldDoor.ts +17 -1
- package/extension/substrate/config.ts +157 -21
- package/extension/substrate/git.ts +88 -6
- package/extension/substrate/modelVisible.ts +53 -0
- package/extension/substrate/prompts.ts +22 -0
- package/extension/substrate/registry.ts +2 -0
- package/extension/substrate/resolverLease.ts +5 -4
- package/extension/substrate/sessionData.ts +85 -152
- package/extension/substrate/toolGating.ts +263 -84
- package/extension/substrate/unifiedDiff.ts +1 -1
- package/extension/substrate/workflowState.ts +178 -163
- package/extension/substrate/worktreeResolverLock.ts +261 -0
- package/extension/surfaces/surfaces.ts +79 -27
- package/extension/waves/adversarialReviewWave.ts +87 -46
- package/extension/waves/blockedReports.ts +59 -0
- package/extension/waves/draftReviewWave.ts +42 -42
- package/extension/waves/laneIdentity.ts +77 -0
- package/extension/waves/objectiveExplorerWave.ts +24 -24
- package/extension/waves/prReviewWave.ts +89 -77
- package/extension/waves/reportWave.ts +438 -578
- package/extension/waves/reviewClassifierWave.ts +22 -22
- package/extension/waves/rpcAdapter.ts +100 -15
- package/extension/waves/scoutWave.ts +192 -0
- package/extension/waves/transport.ts +480 -0
- package/extension/worker/sdkAdapter.ts +494 -0
- package/extension/worker/stageExecution.ts +679 -0
- package/extension/workerMain.ts +18 -19
- package/package.json +6 -4
- package/prompts/_fixtures/live.yaml +43 -18
- package/prompts/contexts/adapters/plannotator-gist.md +6 -0
- package/prompts/contexts/adapters/plannotator-objective.md +6 -0
- package/prompts/contexts/adapters/plannotator-plan.md +8 -1
- package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
- package/prompts/contexts/objective-refinement.md +17 -0
- package/prompts/contexts/read-only.md +1 -1
- package/prompts/stages/conflict-resolution-continuation.md +9 -6
- package/prompts/stages/conflict-resolution.md +4 -4
- package/prompts/stages/objective-plan/guidance.md +2 -2
- package/prompts/stages/objective-plan/seed.md +9 -1
- package/prompts/stages/objective-reconcile-ready.md +1 -1
- package/prompts/stages/objective-reconcile.md +1 -1
- package/prompts/stages/objective-refine/seed.md +18 -0
- package/prompts/stages/objective-review-browser.md +4 -4
- package/prompts/stages/objective-sync.md +1 -1
- package/prompts/stages/plan-review-browser.md +4 -4
- package/prompts/stages/pr-review-browser/active.md +3 -4
- package/prompts/stages/pr-review-browser/foreign.md +3 -4
- package/prompts/stages/pr-review-terminal/active.md +3 -3
- package/prompts/stages/pr-review-terminal/foreign.md +3 -3
- package/prompts/stages/pr-review.md +3 -3
- package/prompts/stages/stack-review-browser/stack.md +5 -6
- package/shared/README.md +8 -0
- package/shared/bindings.yaml +3 -3
- package/shared/contracts.md +2601 -506
- package/shared/fixtures/issues-table.json +130 -0
- package/shared/registry.yaml +13 -0
- package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
- package/shared/schemas/outputs/objective-stack-status.schema.json +6 -1
- package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
- package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
- package/extension/adapters/planAdapterPlannotator.ts +0 -362
- package/extension/doors/auditWaveTools.ts +0 -352
- package/extension/doors/ciExecutor.ts +0 -756
- package/extension/doors/commitCompact.ts +0 -251
- package/extension/doors/dreamWaveTools.ts +0 -489
- package/extension/doors/learn.ts +0 -668
- package/extension/doors/lifecycleGates.ts +0 -207
- package/extension/doors/objectiveStack.ts +0 -1543
- package/extension/doors/prReviewDynamic.ts +0 -276
- package/extension/doors/ready.ts +0 -279
- package/extension/doors/submit.ts +0 -373
- package/extension/doors/submitPrReview.ts +0 -505
- package/extension/factories/gistAuthor.ts +0 -94
- package/extension/factories/gistDraft.ts +0 -265
- package/extension/factories/gistSave.ts +0 -251
- package/extension/factories/implementHere.ts +0 -116
- package/extension/factories/objectiveAuthor.ts +0 -98
- package/extension/factories/objectiveDraft.ts +0 -466
- package/extension/factories/objectiveSave.ts +0 -366
- package/extension/factories/planDraft.ts +0 -140
- package/extension/factories/planMode.ts +0 -205
- package/extension/factories/planReview.ts +0 -1237
- package/extension/factories/planSave.ts +0 -604
- package/extension/factories/planTitle.ts +0 -141
- package/extension/substrate/structuredOutput.ts +0 -202
- package/extension/waves/auditWave.ts +0 -312
- package/extension/waves/harvestWave.ts +0 -399
- package/extension/waves/learnWave.ts +0 -155
- package/extension/waves/memoryAdapter.ts +0 -139
- package/extension/waves/prReviewDynamicWave.ts +0 -777
- package/extension/worker/readOnlySession.ts +0 -294
- package/extension/worker/worker.ts +0 -899
- package/prompts/stages/pr-review-dynamic.md +0 -7
|
@@ -5,38 +5,23 @@
|
|
|
5
5
|
// path after the confined directory has been established. The context filter removes inherited or
|
|
6
6
|
// stale direct scratch custom blocks. A compaction summary may quote old prose/path text; that is
|
|
7
7
|
// not a live guidance delivery or authoritative provenance, and is deliberately left intact.
|
|
8
|
+
//
|
|
9
|
+
// Delivery dedup reads Pi's OWN live context projection (`pi/v1/contextEvidence.ts`) and requires
|
|
10
|
+
// EXACT identity: a `custom` message of this customType whose string content equals the current
|
|
11
|
+
// run's rendered block byte-for-byte. Nothing looser counts — not a text-part array, a user quote,
|
|
12
|
+
// a marker-only match, changed bytes, a parent run's block, or plain `custom` state (`data.content`
|
|
13
|
+
// is state, never model delivery). A projection read failure escapes the hook to Pi's hook-error
|
|
14
|
+
// reporting; no guessed copy is injected.
|
|
8
15
|
|
|
9
16
|
import { relative, sep } from "node:path";
|
|
10
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { activeContextMessages, type ContextMessage } from "../pi/v1/contextEvidence.ts";
|
|
11
19
|
import { type ReportTarget, report } from "../surfaces/report.ts";
|
|
12
20
|
import { agentScratchDir, ensureAgentScratch } from "./cache.ts";
|
|
13
21
|
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
|
|
|
22
23
|
export const AGENT_SCRATCH_CONTEXT_TYPE = "perk:agent-scratch";
|
|
23
24
|
|
|
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
25
|
export interface AgentScratchBlock {
|
|
41
26
|
runId: string;
|
|
42
27
|
/** Repository-relative POSIX-style path carried in model context. */
|
|
@@ -59,22 +44,6 @@ export function renderAgentScratchBlock(cwd: string, runId: string): AgentScratc
|
|
|
59
44
|
return { runId, path, marker, content };
|
|
60
45
|
}
|
|
61
46
|
|
|
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
47
|
export interface AgentScratchProvisioner {
|
|
79
48
|
resolve(ctx: AgentScratchContext): AgentScratchBlock | null;
|
|
80
49
|
}
|
|
@@ -122,29 +91,34 @@ export function createAgentScratchProvisioner(
|
|
|
122
91
|
};
|
|
123
92
|
}
|
|
124
93
|
|
|
125
|
-
/** Whether this exact current-run block
|
|
126
|
-
function
|
|
127
|
-
return
|
|
128
|
-
(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
94
|
+
/** Whether this exact current-run block is still directly delivered in Pi's live projection. */
|
|
95
|
+
function contextHasBlock(messages: readonly ContextMessage[], block: AgentScratchBlock): boolean {
|
|
96
|
+
return messages.some(
|
|
97
|
+
(message) =>
|
|
98
|
+
message.role === "custom" &&
|
|
99
|
+
message.customType === AGENT_SCRATCH_CONTEXT_TYPE &&
|
|
100
|
+
message.content === block.content,
|
|
132
101
|
);
|
|
133
102
|
}
|
|
134
103
|
|
|
135
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Register eligible-turn delivery and direct scratch-custom context hygiene. `eligible` is the
|
|
106
|
+
* composition root's `!gate && !runner` — a runner child (every perk report child) never
|
|
107
|
+
* provisions scratch; the module knows nothing about agent names.
|
|
108
|
+
*/
|
|
136
109
|
export function registerAgentScratch(
|
|
137
110
|
pi: ExtensionAPI,
|
|
138
|
-
provisioner: AgentScratchProvisioner
|
|
111
|
+
provisioner: AgentScratchProvisioner,
|
|
112
|
+
eligible: () => boolean,
|
|
139
113
|
): void {
|
|
140
114
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
141
|
-
if (!
|
|
115
|
+
if (!eligible()) return;
|
|
142
116
|
|
|
143
|
-
// Provision before dedup: an externally deleted directory is repaired even while
|
|
144
|
-
//
|
|
117
|
+
// Provision before dedup: an externally deleted directory is repaired even while live
|
|
118
|
+
// context still carries this run's exact guidance block (and before any projection read).
|
|
145
119
|
const block = provisioner.resolve(ctx);
|
|
146
120
|
if (block === null) return;
|
|
147
|
-
if (
|
|
121
|
+
if (contextHasBlock(activeContextMessages(ctx), block)) return;
|
|
148
122
|
return {
|
|
149
123
|
message: {
|
|
150
124
|
customType: AGENT_SCRATCH_CONTEXT_TYPE,
|
|
@@ -155,8 +129,7 @@ export function registerAgentScratch(
|
|
|
155
129
|
});
|
|
156
130
|
|
|
157
131
|
pi.on("context", async (event, ctx) => {
|
|
158
|
-
const
|
|
159
|
-
const block = eligible ? provisioner.resolve(ctx) : null;
|
|
132
|
+
const block = eligible() ? provisioner.resolve(ctx) : null;
|
|
160
133
|
let keptCurrent = false;
|
|
161
134
|
return {
|
|
162
135
|
messages: event.messages.filter((message) => {
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// The bash scan-timeout guard's pure policy: the default `timeout` for gitignore-blind scans issued
|
|
2
|
+
// through Pi's `bash` tool, the command classifier, and the expiry steer. Recursive `grep -r…` and
|
|
3
|
+
// `find` without `-maxdepth` ignore `.gitignore`, so from a checkout carrying `node_modules/`,
|
|
4
|
+
// `.venv/`, `.worktrees/` and the like they walk everything — observed running for minutes to the
|
|
5
|
+
// better part of an hour with no `timeout` on the call, so nothing stopped them. Every
|
|
6
|
+
// legitimately scoped scan finished in single-digit seconds. The hooks that apply this policy
|
|
7
|
+
// (the `tool_call` injection + the `tool_result` note) live in the Pi adapter home,
|
|
8
|
+
// `pi/v1/bashScanTimeout.ts`; the contract is contracts.md §8.69.
|
|
9
|
+
//
|
|
10
|
+
// The override contract: an explicit `timeout` of ANY value on the call is the model's override —
|
|
11
|
+
// never rewritten or capped. Expiry is detected only from Pi's own terminal `Command timed out
|
|
12
|
+
// after N seconds` status line, never from a line the command printed.
|
|
13
|
+
//
|
|
14
|
+
// Classifier design: regex-only over each physical line's quote-aware top-level segments (the
|
|
15
|
+
// gate's splitter, reused so both hooks agree on what a segment is and a flag in a LATER pipeline
|
|
16
|
+
// stage — `grep -n foo f | sort -r` — is never attributed to the grep). The command word is
|
|
17
|
+
// matched at ANY command boundary inside a segment rather than as the segment's leading word, so
|
|
18
|
+
// wrapper prefixes (`env`/`nice`/`time`/`nohup`/`xargs`), nested shells (`sh -c '…'`) and `$(…)`
|
|
19
|
+
// substitutions are covered without enumerating wrappers. A grep's recursion flag is searched over
|
|
20
|
+
// its full tail (over-matches only); a find's `-maxdepth` exemption is searched only in the find's
|
|
21
|
+
// OWN window — up to the next command word or a quoted-in sequencing operator, quoted spans blanked
|
|
22
|
+
// — so neither a later bounded find nor a quoted argument ever exempts an unbounded one.
|
|
23
|
+
// Over-matching is tolerated by design — a spurious 30s cap on a fast command is harmless, a
|
|
24
|
+
// missed scan is the bug. The accepted over-matches (pinned as such in the tests, so any future
|
|
25
|
+
// tightening is deliberate): `echo grep -r`, `git grep -rn foo`, `rg -n 'grep -rn foo' src/`,
|
|
26
|
+
// `grep -n "x -r y" f`.
|
|
27
|
+
// Incidental precision, not a goal: a quote-adjacent cluster (`"-r"`, `'grep -r'`) is not a flag
|
|
28
|
+
// position, so quoted flags do not match.
|
|
29
|
+
|
|
30
|
+
import { splitTopLevelSegments } from "./toolGating.ts";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The injected default, in seconds — the ONE source of truth for the number. The managed
|
|
34
|
+
* `AGENTS.md` bullet the Python plane renders (`_agents_inner()`) mirrors it verbatim and a Python
|
|
35
|
+
* parity test pins the mirror; change both in the same turn.
|
|
36
|
+
*/
|
|
37
|
+
export const SCAN_TIMEOUT_SECONDS = 30;
|
|
38
|
+
|
|
39
|
+
export type ScanKind = "recursive-grep" | "unbounded-find";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A grep-family or `find` command word at a command boundary: segment start, whitespace, a quote,
|
|
43
|
+
* a backtick, `(` (as in `$(`) or a `\` alias-bypass, with an optional `dir/` path prefix, followed
|
|
44
|
+
* by whitespace or end. `ast-grep` never matches (`-` precedes `grep`); `docs/find.md` and
|
|
45
|
+
* `findings/` never match (`.`/`i` follow `find`).
|
|
46
|
+
*/
|
|
47
|
+
const COMMAND_WORD =
|
|
48
|
+
/(?:^|[\s'"`(\\])(?:[\w./-]*\/)?(grep|egrep|fgrep|zgrep|zegrep|zfgrep|bzgrep|xzgrep|rgrep|find)(?=\s|$)/g;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A recursion flag in a grep's tail: a whitespace-delimited short cluster containing `r`/`R`
|
|
52
|
+
* (`-r`, `-rn`, `-rniE`, `-Rl`, `-nr`, `-rnA3`) or one of the explicit long forms. Other long
|
|
53
|
+
* options (`--color=never`, `--exclude-dir=…`) never match — the cluster arm cannot consume the
|
|
54
|
+
* second `-`.
|
|
55
|
+
*/
|
|
56
|
+
const RECURSIVE_FLAG =
|
|
57
|
+
/(?:^|\s)(?:--recursive|--dereference-recursive|--directories=recurse|(?:-d|--directories)\s+recurse|-[A-Za-z0-9]*[rR][A-Za-z0-9]*)(?=\s|$)/;
|
|
58
|
+
|
|
59
|
+
/** A depth bound in a find's own window — the only thing that exempts a `find` (`-prune`/`-not -path` still walk). */
|
|
60
|
+
const MAXDEPTH_FLAG = /(?:^|\s)-maxdepth(?=\s|$)/;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A sequencing operator that survived the top-level split — i.e. one inside a quoted nested shell
|
|
64
|
+
* (`sh -c 'find . -type f; find . -maxdepth 1'`) or a lone `&`. It ends a `find`'s exemption
|
|
65
|
+
* window (see `findWindow`).
|
|
66
|
+
*/
|
|
67
|
+
const INNER_OPERATOR = /[;|&]/;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pi's terminal status line for an expired bash call. No `m` flag — `$` is end-of-string, so the
|
|
71
|
+
* line must be the LAST thing in the text (Pi's `appendStatus` always places the status last), never
|
|
72
|
+
* a line the command itself printed mid-output.
|
|
73
|
+
*/
|
|
74
|
+
const TIMEOUT_STATUS = /(?:^|\n)Command timed out after (\S+) seconds$/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Classify a bash command as a gitignore-blind scan, or `null`. Pure and offline-testable. Per
|
|
78
|
+
* physical line, per quote-aware top-level segment, in order: the first segment that classifies
|
|
79
|
+
* decides. Fast non-scans (`rg`, `fd`, `ast-grep`, `grep -n foo file`) are `null`.
|
|
80
|
+
*/
|
|
81
|
+
export function classifyScanCommand(command: string): ScanKind | null {
|
|
82
|
+
for (const line of command.split("\n")) {
|
|
83
|
+
for (const segment of splitTopLevelSegments(line)) {
|
|
84
|
+
const kind = classifySegment(segment);
|
|
85
|
+
if (kind !== null) return kind;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One command-word occurrence: the word, where its tail starts, and where the next occurrence begins. */
|
|
92
|
+
type Occurrence = { word: string; tailStart: number; nextStart: number };
|
|
93
|
+
|
|
94
|
+
function occurrences(segment: string): Occurrence[] {
|
|
95
|
+
// A fresh matcher per segment: the shared regex is global (stateful `lastIndex`).
|
|
96
|
+
const words = new RegExp(COMMAND_WORD.source, "g");
|
|
97
|
+
const found: Occurrence[] = [];
|
|
98
|
+
for (const match of segment.matchAll(words)) {
|
|
99
|
+
const word = match[1];
|
|
100
|
+
if (word === undefined) continue;
|
|
101
|
+
const previous = found.at(-1);
|
|
102
|
+
if (previous !== undefined) previous.nextStart = match.index;
|
|
103
|
+
found.push({ word, tailStart: match.index + match[0].length, nextStart: segment.length });
|
|
104
|
+
}
|
|
105
|
+
return found;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `text` with every single-/double-quoted span replaced by ONE space (token boundaries kept,
|
|
110
|
+
* quoted content gone). The same quote model as the segment splitter (no backslash-escape
|
|
111
|
+
* handling; an unterminated quote runs to the end).
|
|
112
|
+
*/
|
|
113
|
+
function blankQuoted(text: string): string {
|
|
114
|
+
let out = "";
|
|
115
|
+
let quote: '"' | "'" | null = null;
|
|
116
|
+
for (const ch of text) {
|
|
117
|
+
if (quote !== null) {
|
|
118
|
+
if (ch === quote) quote = null;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (ch === '"' || ch === "'") {
|
|
122
|
+
quote = ch;
|
|
123
|
+
out += " ";
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
out += ch;
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The text in which THIS `find`'s `-maxdepth` may appear: its tail up to the next command word,
|
|
133
|
+
* with quoted spans blanked, then cut at the first sequencing operator that survived the
|
|
134
|
+
* top-level split (a quoted nested shell's `;`/`|`/`&`). Blanking first means only an UNQUOTED
|
|
135
|
+
* standalone `-maxdepth` option counts (a `-printf 'x -maxdepth y'` format or a `-name
|
|
136
|
+
* "-maxdepth"` operand never exempts) and a quoted `;` inside a `-name` pattern never ends the
|
|
137
|
+
* window. The exemption is the inverse of a match — it REMOVES a cap — so its window must be
|
|
138
|
+
* tight: a later bounded find (`sh -c 'find . -type f; find . -maxdepth 1'`) or another command
|
|
139
|
+
* after a quoted-in operator must never exempt an earlier unbounded one; shrinking the window can
|
|
140
|
+
* only add caps. Residual: an UNQUOTED `-maxdepth` operand value (`-name -maxdepth`) still
|
|
141
|
+
* exempts — not a real-world shape.
|
|
142
|
+
*/
|
|
143
|
+
function findWindow(segment: string, occurrence: Occurrence): string {
|
|
144
|
+
const tail = blankQuoted(segment.slice(occurrence.tailStart, occurrence.nextStart));
|
|
145
|
+
const operator = INNER_OPERATOR.exec(tail);
|
|
146
|
+
return operator === null ? tail : tail.slice(0, operator.index);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function classifySegment(segment: string): ScanKind | null {
|
|
150
|
+
for (const occurrence of occurrences(segment)) {
|
|
151
|
+
if (occurrence.word === "rgrep") return "recursive-grep";
|
|
152
|
+
if (occurrence.word === "find") {
|
|
153
|
+
if (!MAXDEPTH_FLAG.test(findWindow(segment, occurrence))) return "unbounded-find";
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// A grep's recursion flag is searched over its FULL tail (not a window): a permuted `-r` may
|
|
157
|
+
// follow a quoted pattern that itself contains a command word (`grep -n "find . -maxdepth 1"
|
|
158
|
+
// -r .`), and for a grep the full tail can only over-match — never miss.
|
|
159
|
+
if (RECURSIVE_FLAG.test(segment.slice(occurrence.tailStart))) return "recursive-grep";
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The seconds from Pi's terminal timeout status when `text` (a bash error result's last text
|
|
166
|
+
* block) ENDS with it; `null` otherwise — including when the literal appears mid-output.
|
|
167
|
+
*/
|
|
168
|
+
export function expiredAfterSeconds(text: string): string | null {
|
|
169
|
+
return TIMEOUT_STATUS.exec(text)?.[1] ?? null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The steer appended to an expired scan's result (`seconds` as parsed from Pi's status line). */
|
|
173
|
+
export function scanTimeoutNote(kind: ScanKind, seconds: string): string {
|
|
174
|
+
const what = kind === "recursive-grep" ? "recursive grep" : "unbounded find";
|
|
175
|
+
return (
|
|
176
|
+
`perk: this ${what} hit the ${seconds}s timeout. Recursive \`grep -r…\` and \`find\` without ` +
|
|
177
|
+
"`-maxdepth` ignore `.gitignore` (they walk `node_modules`, `.venv`, `.worktrees`, …). Prefer " +
|
|
178
|
+
"the `grep`/`find` tools or `rg`/`fd`, which honor `.gitignore`; to run this command anyway, " +
|
|
179
|
+
"pass a larger explicit `timeout` (seconds) on the bash call."
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -14,29 +14,29 @@
|
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
17
|
+
// injection carry it, so Mechanism A injects ONLY when neither the submitting turn's prompt NOR
|
|
18
|
+
// Pi's live context projection (`pi/v1/contextEvidence.ts` — the header as persisted USER content
|
|
19
|
+
// or as an owned `perk:binding-context` custom) already carries the header (idempotent across
|
|
20
|
+
// turns/reloads; after compaction drops the original from model context it re-delivers, and a
|
|
21
|
+
// summary quoting the header never suppresses). The prompt scan is load-bearing on the launch
|
|
22
|
+
// turn: at `before_agent_start` the just-submitted prompt is NOT yet persisted, so the projection
|
|
23
|
+
// alone would miss a cold seed's binding suffix and double-deliver. The full branch is read only
|
|
24
|
+
// for the stage (`activeStageRender`) — eligibility survives compaction; delivery evidence is
|
|
25
|
+
// Pi's. A projection read failure escapes the hook to Pi's hook-error reporting rather than
|
|
26
|
+
// injecting a guessed copy.
|
|
23
27
|
//
|
|
24
|
-
// LBYL
|
|
25
|
-
// loud-but-non-fatal warning, never throws, never blocks a turn
|
|
26
|
-
//
|
|
28
|
+
// LBYL on the render path: a missing/unreadable transclude target degrades to the nudge pointer
|
|
29
|
+
// with a loud-but-non-fatal warning, never throws, never blocks a turn (only a session read
|
|
30
|
+
// failure — branch or projection — escapes the hooks). Resolver shape `issues` are NOT surfaced
|
|
31
|
+
// warm (the cold launch + doctor own them); only the transclude `warnings` are.
|
|
27
32
|
|
|
28
33
|
import { existsSync, readFileSync } from "node:fs";
|
|
29
34
|
import { join } from "node:path";
|
|
30
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
35
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
36
|
+
import { activeContextMessages, contextCarriesMarker } from "../pi/v1/contextEvidence.ts";
|
|
31
37
|
import { loadDefaultBindings, resolveBindings, type SkillBinding } from "./bindings.ts";
|
|
32
38
|
import { loadPerkConfig } from "./config.ts";
|
|
33
|
-
import {
|
|
34
|
-
activeContextWindow,
|
|
35
|
-
type BranchEntry,
|
|
36
|
-
branchCarries,
|
|
37
|
-
branchOf,
|
|
38
|
-
rebuildWorkflowState,
|
|
39
|
-
} from "./workflowState.ts";
|
|
39
|
+
import { type BranchEntry, branchOf, rebuildWorkflowState } from "./workflowState.ts";
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* The cross-plane dedup marker AND render header. MUST stay byte-identical to the Python cold
|
|
@@ -156,9 +156,16 @@ function stripFrontmatter(text: string): string {
|
|
|
156
156
|
return text; // no closing delimiter — leave the text unchanged
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
/**
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Whether a persisted cold prompt (user content) or a prior warm injection (the owned custom)
|
|
161
|
+
* still live in Pi's context projection carries the header. Throws when the projection read
|
|
162
|
+
* fails — the hook boundary owns that.
|
|
163
|
+
*/
|
|
164
|
+
function contextHasHeader(ctx: ExtensionContext): boolean {
|
|
165
|
+
return contextCarriesMarker(activeContextMessages(ctx), {
|
|
166
|
+
customType: BINDING_CONTEXT_TYPE,
|
|
167
|
+
marker: BINDING_HEADER,
|
|
168
|
+
});
|
|
162
169
|
}
|
|
163
170
|
|
|
164
171
|
/** The launched stage's `stage:<id>` render, or `null` when there is no stage / nothing matches. */
|
|
@@ -171,22 +178,22 @@ function activeStageRender(cwd: string, branch: readonly BranchEntry[]): Binding
|
|
|
171
178
|
/**
|
|
172
179
|
* Register warm-door binding delivery: Mechanism A's dedup-guarded `before_agent_start` injection
|
|
173
180
|
* plus a `context` strip mirroring planMode.ts / objectiveAuthor.ts (keep while the stage's
|
|
174
|
-
* bindings are live; strip the stale custom otherwise). Inert when nothing matches the stage;
|
|
175
|
-
* never throws
|
|
181
|
+
* bindings are live; strip the stale custom otherwise). Inert when nothing matches the stage; the
|
|
182
|
+
* render path never throws (only a failed session read escapes to Pi's hook-error reporting).
|
|
183
|
+
* Mechanism B (`bindingSuffix`) is wired by the command modules themselves.
|
|
176
184
|
*/
|
|
177
185
|
export function registerBindingDelivery(pi: ExtensionAPI): void {
|
|
178
186
|
// Mechanism A — inject the launched stage's resolved bindings as a hidden context message,
|
|
179
|
-
// but ONLY when
|
|
180
|
-
//
|
|
187
|
+
// but ONLY when neither the submitting turn's prompt NOR Pi's live context projection already
|
|
188
|
+
// carries BINDING_HEADER (the cold door's initial prompt or a prior warm inject) — the cold↔warm
|
|
181
189
|
// idempotency guard. The `event.prompt` scan covers the launch turn, where the just-submitted
|
|
182
|
-
// prompt is not yet
|
|
183
|
-
//
|
|
190
|
+
// prompt is not yet persisted; a worker prompt carries no header, so Mechanism A still fires
|
|
191
|
+
// there (contracts.md §8.38). Render-before-dedup: an inert stage never reads the projection.
|
|
184
192
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
185
|
-
const
|
|
186
|
-
const rendered = activeStageRender(ctx.cwd, branch);
|
|
193
|
+
const rendered = activeStageRender(ctx.cwd, branchOf(ctx));
|
|
187
194
|
if (rendered === null || rendered.text === null) return;
|
|
188
|
-
if (branchHasHeader(branch)) return;
|
|
189
195
|
if (event.prompt.includes(BINDING_HEADER)) return;
|
|
196
|
+
if (contextHasHeader(ctx)) return;
|
|
190
197
|
for (const warning of rendered.warnings) console.error(`perk: ${warning}`);
|
|
191
198
|
return {
|
|
192
199
|
message: {
|
|
@@ -204,7 +211,8 @@ export function registerBindingDelivery(pi: ExtensionAPI): void {
|
|
|
204
211
|
//
|
|
205
212
|
// Deliberately NARROWER than planMode: it strips ONLY the BINDING_CONTEXT_TYPE custom, never a
|
|
206
213
|
// user message carrying the header — a cold launch's initial prompt legitimately carries
|
|
207
|
-
// BINDING_HEADER and must survive in context.
|
|
214
|
+
// BINDING_HEADER and must survive in context, even after the stage stops binding. The strip
|
|
215
|
+
// never reads the projection (it filters the messages Pi hands it).
|
|
208
216
|
pi.on("context", async (event, ctx) => {
|
|
209
217
|
const branch = branchOf(ctx);
|
|
210
218
|
const rendered = activeStageRender(ctx.cwd, branch);
|
|
@@ -12,8 +12,6 @@
|
|
|
12
12
|
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
// Type-only import (config.ts value-imports this module; a value import here would cycle).
|
|
16
|
-
import type { TomlScalar } from "./config.ts";
|
|
17
15
|
import { parse } from "./miniYaml.ts";
|
|
18
16
|
import { sharedDir } from "./resources.ts";
|
|
19
17
|
|
|
@@ -76,10 +74,11 @@ export function loadDefaultBindings(): SkillBinding[] {
|
|
|
76
74
|
}
|
|
77
75
|
|
|
78
76
|
/**
|
|
79
|
-
* Parse `.perk/config.toml` `[[bindings]]` rows
|
|
80
|
-
* the YAML reader: absent/ill-typed fields become empty
|
|
77
|
+
* Parse `.perk/config.toml` `[[bindings]]` rows into `SkillBinding`s. Rows are tolerant
|
|
78
|
+
* unknown-valued records (like the YAML reader's entries): absent/ill-typed fields become empty
|
|
79
|
+
* strings so the *resolver* reports them.
|
|
81
80
|
*/
|
|
82
|
-
export function parseUserBindings(rows: Array<Record<string,
|
|
81
|
+
export function parseUserBindings(rows: Array<Record<string, unknown>>): SkillBinding[] {
|
|
83
82
|
return rows.map((row) => {
|
|
84
83
|
const trigger = typeof row.trigger === "string" ? row.trigger : "";
|
|
85
84
|
const [kind, targetId] = splitTrigger(trigger);
|
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
// `atomicWriteFileSync` (temp file in the same directory + atomic rename) so a concurrent
|
|
16
16
|
// writer can never tear a file — a reader sees either the old bytes or the new bytes, never a
|
|
17
17
|
// mix (guard-tested by writeGuard.test.ts). The exemptions are the append-only NDJSON streams
|
|
18
|
-
// — the worker's `events.ndjson` (worker/
|
|
18
|
+
// — the worker's `events.ndjson` (worker/stageExecution.ts) and the §8.58 hunk-watch `outbox.ndjson` /
|
|
19
19
|
// `delivered.ndjson` (hunkFeedback/perkFeedback.ts / hunkFeedback/store.ts) — where O_APPEND
|
|
20
20
|
// appends cannot truncate-tear and whole-file replace would introduce a read-modify-write race
|
|
21
|
-
// between independent processes.
|
|
22
|
-
//
|
|
21
|
+
// between independent processes. The §8.3 worktree resolver lock (`worktreeResolverLock.ts`)
|
|
22
|
+
// separately writes/fsyncs only freshly wx-created descriptors: atomic replacement would destroy
|
|
23
|
+
// exclusion. Atomicity alone is not mutual exclusion — ordinary artifact writes remain
|
|
24
|
+
// whole-file last-writer-wins.
|
|
23
25
|
|
|
24
26
|
import { randomBytes } from "node:crypto";
|
|
25
27
|
import {
|
|
@@ -34,7 +36,7 @@ import {
|
|
|
34
36
|
rmSync,
|
|
35
37
|
writeFileSync,
|
|
36
38
|
} from "node:fs";
|
|
37
|
-
import { join, relative } from "node:path";
|
|
39
|
+
import { join, relative, sep } from "node:path";
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* Atomically replace `path` with `content` (the interior atomic-write seam).
|
|
@@ -147,16 +149,28 @@ export function sessionDataDir(cwd: string, runId: string): string {
|
|
|
147
149
|
return join(runScratchDir(cwd, runId), "data");
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Whether a run id can only select one child of the shared runs directory: non-empty, not
|
|
154
|
+
* `.`/`..`, and free of `/`, `\`, and NUL. The one run-id trust predicate — the write path
|
|
155
|
+
* throws through it (`assertSafeRunId`); the read-path identity seam
|
|
156
|
+
* (`sessionData.activeSessionRunId`) narrows through it so a hostile rebuilt run_id degrades to
|
|
157
|
+
* no-identity before any path derivation. Legitimate ids (ULID mints, `<parent>.<n>` fork
|
|
158
|
+
* derivations) all pass.
|
|
159
|
+
*/
|
|
160
|
+
export function isSafeRunId(runId: string): boolean {
|
|
161
|
+
return (
|
|
162
|
+
runId.length > 0 &&
|
|
163
|
+
runId !== "." &&
|
|
164
|
+
runId !== ".." &&
|
|
165
|
+
!runId.includes("/") &&
|
|
166
|
+
!runId.includes("\\") &&
|
|
167
|
+
!runId.includes("\0")
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
150
171
|
/** Reject run ids that could select anything except one child of the shared runs directory. */
|
|
151
172
|
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
|
-
) {
|
|
173
|
+
if (!isSafeRunId(runId)) {
|
|
160
174
|
throw new Error(`refusing unsafe run id ${JSON.stringify(runId)}`);
|
|
161
175
|
}
|
|
162
176
|
}
|
|
@@ -215,6 +229,44 @@ export function ensureRunScratch(cwd: string, runId: string): string {
|
|
|
215
229
|
return dir;
|
|
216
230
|
}
|
|
217
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Strict current-run data namespace for exclusion and provenance reads. Aliased checkout roots
|
|
234
|
+
* canonicalize together; redirects within checkout-owned components refuse. Reads never create
|
|
235
|
+
* directories. A missing component is absent, not an I/O-error sentinel.
|
|
236
|
+
*/
|
|
237
|
+
export function canonicalSessionDataDir(
|
|
238
|
+
cwd: string,
|
|
239
|
+
runId: string,
|
|
240
|
+
opts: { create: boolean },
|
|
241
|
+
): string | null {
|
|
242
|
+
assertSafeRunId(runId);
|
|
243
|
+
const root = realpathSync(cwd);
|
|
244
|
+
const dir = sessionDataDir(root, runId);
|
|
245
|
+
let component = root;
|
|
246
|
+
for (const segment of relative(root, dir).split(sep)) {
|
|
247
|
+
component = join(component, segment);
|
|
248
|
+
if (opts.create) {
|
|
249
|
+
ensureUnredirectedDirectory(component, {
|
|
250
|
+
createMode: 0o755,
|
|
251
|
+
rejectGroupWorldWrite: true,
|
|
252
|
+
});
|
|
253
|
+
} else {
|
|
254
|
+
let stat: ReturnType<typeof lstatSync>;
|
|
255
|
+
try {
|
|
256
|
+
stat = lstatSync(component);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0) {
|
|
262
|
+
throw new Error(`refusing an unsafe session-data namespace: ${component}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (realpathSync(dir) !== dir) throw new Error("refusing redirected session data");
|
|
267
|
+
return dir;
|
|
268
|
+
}
|
|
269
|
+
|
|
218
270
|
/**
|
|
219
271
|
* Create the private run-owned agent directory as 0700 from the outset, then re-apply that mode on
|
|
220
272
|
* reuse. The mode protects against other OS users; it is not a sandbox from another process running
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The two native-child booleans (contracts.md §8.3): the pi-subagents runner bit and perk's
|
|
2
|
+
// report restriction packet. Neither is identity; the floor is the only thing that restricts.
|
|
3
|
+
|
|
4
|
+
const FAMILY = "perk.parent-restrictions/";
|
|
5
|
+
const NAMESPACE = `${FAMILY}1`;
|
|
6
|
+
|
|
7
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The runner stamp pi-subagents sets on every background (runner-hosted) child. */
|
|
12
|
+
export function isRunnerChild(env: NodeJS.ProcessEnv): boolean {
|
|
13
|
+
return env.PI_SUBAGENT_CHILD === "1";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runner bit AND perk's packet ⇒ floor. No packet (the delegation-dispatched writer) ⇒ no floor.
|
|
18
|
+
* A present packet must be exactly `{readOnly: boolean}` under perk's v1 namespace; any other
|
|
19
|
+
* `perk.parent-restrictions/…` key (an unsupported version — producer/consumer skew) or any
|
|
20
|
+
* other shape fails closed. Unrelated namespaces beside it are opaque.
|
|
21
|
+
*/
|
|
22
|
+
export function decodeReadOnlyFloor(runner: boolean, raw: string | undefined): boolean {
|
|
23
|
+
if (!runner || raw === undefined) return false;
|
|
24
|
+
let envelope: unknown;
|
|
25
|
+
try {
|
|
26
|
+
envelope = JSON.parse(raw);
|
|
27
|
+
} catch {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
if (!isRecord(envelope)) return true;
|
|
31
|
+
if (Object.keys(envelope).some((key) => key.startsWith(FAMILY) && key !== NAMESPACE)) return true;
|
|
32
|
+
if (!Object.hasOwn(envelope, NAMESPACE)) return false;
|
|
33
|
+
const value = envelope[NAMESPACE];
|
|
34
|
+
// Own-key check: a polluted `Object.prototype.readOnly` must never un-floor a malformed value.
|
|
35
|
+
if (!isRecord(value) || !Object.hasOwn(value, "readOnly") || Object.keys(value).length !== 1) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return typeof value.readOnly === "boolean" ? value.readOnly : true;
|
|
39
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// A door consumes it as:
|
|
10
10
|
//
|
|
11
|
-
// const r = await runColdDoor<
|
|
11
|
+
// const r = await runColdDoor<PublishedChange>(pi, ctx, ["pr", "submit", "--json"], {
|
|
12
12
|
// label: "perk pr submit",
|
|
13
13
|
// decode,
|
|
14
14
|
// });
|
|
@@ -99,6 +99,22 @@ export function objectField(payload: ColdJson, key: string): ColdJson | undefine
|
|
|
99
99
|
return value as ColdJson;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/** Lenient object-list field: a non-array (or any non-object element) contributes nothing. */
|
|
103
|
+
export function objectListField(payload: ColdJson, key: string): ColdJson[] {
|
|
104
|
+
const value = payload[key];
|
|
105
|
+
if (!Array.isArray(value)) return [];
|
|
106
|
+
return value.filter(
|
|
107
|
+
(item): item is ColdJson => typeof item === "object" && item !== null && !Array.isArray(item),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Lenient string-list field: non-string elements are dropped. */
|
|
112
|
+
export function stringListField(payload: ColdJson, key: string): string[] {
|
|
113
|
+
const value = payload[key];
|
|
114
|
+
if (!Array.isArray(value)) return [];
|
|
115
|
+
return value.filter((item): item is string => typeof item === "string");
|
|
116
|
+
}
|
|
117
|
+
|
|
102
118
|
/** Best-effort parse of stdout into a plain object; null on anything else. */
|
|
103
119
|
function parseObject(stdout: string): ColdJson | null {
|
|
104
120
|
let parsed: unknown;
|