diff --git a/.codex-marketplace-install.json b/.codex-marketplace-install.json new file mode 100644 index 0000000..f0db2f9 --- /dev/null +++ b/.codex-marketplace-install.json @@ -0,0 +1,7 @@ +{ + "source_type": "git", + "source": "https://github.com/Team-Volt/voltflow.git", + "ref_name": null, + "sparse_paths": [], + "revision": "f6ac8e442b0897af3e4de3c2598eaf22107b112b" +} \ No newline at end of file diff --git a/README.md b/README.md index 8d4801b..68e6ba2 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,14 @@ The bootstrap router uses five profiles: | --- | --- | | Discovery, tests, mechanical edits | Luna high | | Standard implementation | Terra max | -| Planning, integration, composite review | Sol medium | -| Routine adversarial or correctness review | Sol high | +| Planning and integration | Sol medium | +| Routine adversarial or correctness review | Terra high | | Security, architecture, migrations, difficult ambiguity | Sol max | Direct `model` and `reasoning_effort` overrides are used when the active spawn schema supports them. Otherwise VoltFlow selects a matching agent profile when possible; if neither route exists, it inherits the parent and reports `routing degraded` instead of pretending the requested profile ran. +Routine review-only agents are cost-controlled: they must explicitly use Terra high. Sol is reserved for a named authorization, private-data, payment, booking, destructive/data-integrity migration, or cross-cutting-architecture risk, or a direct user request. + This table starts from the community [Codex quota frontier analysis](https://www.reddit.com/r/codex/comments/1ut3bnp/the_codex_pareto_frontier_luna_high_terra_max_sol/), checked against the different [Artificial Analysis API-cost frontier](https://artificialanalysis.ai/articles/gpt-5-6-intelligence-vs-cost-across-sol-terra-luna). It is a prior, not a permanent benchmark result. OpenAI's [GPT-5.6 prompt guidance](https://developers.openai.com/api/docs/guides/prompt-guidance-gpt-5p6) supplies the prompt shape: one outcome, explicit constraints, evidence, and a stopping condition, with each rule stated once. ## Multi-agent compatibility @@ -88,6 +90,12 @@ node /scripts/voltflow.mjs validate \ --data-dir --session \ --evidence "npm test" +node /scripts/voltflow.mjs cost \ + --data-dir --session + +node /scripts/voltflow.mjs report \ + --data-dir --session + node /scripts/voltflow.mjs review \ --data-dir --session \ --lane composite @@ -100,7 +108,11 @@ node /scripts/voltflow.mjs gate \ --data-dir --session ``` -Give the token returned by `review` to that reviewer. Its final line must be `VOLTFLOW_REVIEW: PASS ` or `VOLTFLOW_REVIEW: FAIL `. +Give the token returned by `review` to that reviewer and include `VOLTFLOW_REVIEW_ASSIGNMENT: ` in that spawn prompt. VoltFlow binds the successful spawn's agent ID, requested model, and reasoning effort to the assignment; only that agent's matching final line is accepted: `VOLTFLOW_REVIEW: PASS ` or `VOLTFLOW_REVIEW: FAIL `. + +`cost` reports the planned routine-review profile, current review-round pressure, and likely usage drivers. `report` adds whole-session proxies: observed parent model metadata, visible prompt size, tool input/output byte totals grouped by category, requested subagent profiles and handoff size, test/validation activity, and review waves. Run it after discovery, before review, and at finish. + +Neither command reports exact tokens, billing, quota, or hidden reasoning usage because the Codex session telemetry does not expose those values to the plugin. VoltFlow persists counts and byte sizes only; it does not persist prompt, tool, or reviewer content for this report. Put the final `gate` command immediately before the provider's deploy command in the same local session or deploy wrapper. Plugin hooks are useful guardrails, but `PreToolUse` cannot intercept every possible side effect. VoltFlow does not yet provide a portable signed receipt for a separate remote CI machine, so do not describe the local state file as an authoritative remote boundary. diff --git a/hooks/hooks.json b/hooks/hooks.json index 69f7780..93379d1 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -29,7 +29,7 @@ ], "PostToolUse": [ { - "matcher": "^(Bash|exec|exec_command|apply_patch|Edit|Write)$", + "matcher": "*", "hooks": [ { "type": "command", diff --git a/scripts/voltflow.mjs b/scripts/voltflow.mjs index fe13cd3..68dfb3c 100644 --- a/scripts/voltflow.mjs +++ b/scripts/voltflow.mjs @@ -50,7 +50,7 @@ const OVERRIDE_INTENT = /^\s*(?:(?:voltflow[:,]?\s+)?(?:please\s+)?(?:override|b const OVERRIDE_QUESTION = /^\s*(?:how|what|when|where|why|who|(?:can|could|should|would|do)\s+(?:i|we)|is\s+it)\b/i; const OVERRIDE_NEGATION = /\b(?:do\s+not|don't|dont|never)\s+(?:\w+\s+){0,2}(?:override|bypass|ignore|overrule|waive|deploy|release|ship)\b/i; const USAGE = [ - "Usage: voltflow.mjs start|skip|red|validate|review|approve|status|gate --session ", + "Usage: voltflow.mjs start|skip|red|validate|review|approve|status|cost|report|gate --session ", "start: --tier trivial|standard|high --tdd required|exempt --review self|single|split", "skip: --evidence (simple work only, before any change)", "red|validate: --evidence ", @@ -130,6 +130,7 @@ export function runController(argv, options = {}) { saveState(dataDir, state); return success(`VoltFlow updated: ${flags.tier}, tdd=${flags.tdd}, review=${flags.review}`); } + const sessionMetrics = state.active === true ? sessionMetricsFor(state) : freshSessionMetrics(); Object.assign(state, { active: true, tier: flags.tier, @@ -143,10 +144,12 @@ export function runController(argv, options = {}) { validation: null, reviewAssignments: [], reviewPasses: [], + reviewHistory: [], reviewFailure: null, approval: null, override: null, skip: null, + sessionMetrics, lastFingerprint: currentFingerprint, stopBlocks: 0, reworkCycles: 0, @@ -195,6 +198,7 @@ export function runController(argv, options = {}) { if (!textFlag(flags.evidence)) return failure("validate requires --evidence"); if (currentFingerprint === null) return failure("Git fingerprint unavailable"); state.validation = evidence(flags.evidence, currentFingerprint); + sessionMetricsFor(state).workflow.validationRecords += 1; saveState(dataDir, state); return success("VoltFlow validation recorded"); } @@ -207,8 +211,10 @@ export function runController(argv, options = {}) { const token = randomUUID(); state.reviewAssignments = state.reviewAssignments.filter((entry) => entry.lane !== flags.lane); state.reviewAssignments.push({ lane: flags.lane, token, fingerprint: currentFingerprint, at: timestamp() }); + state.reviewHistory ??= []; + state.reviewHistory.push({ lane: flags.lane, at: timestamp() }); saveState(dataDir, state); - return success(`VoltFlow review assigned: ${flags.lane} token=${token}`); + return success(`VoltFlow review assigned: ${flags.lane} token=${token}\nInclude VOLTFLOW_REVIEW_ASSIGNMENT: ${flags.lane} ${token} in the reviewer spawn prompt.`); } if (command === "approve") { @@ -226,6 +232,14 @@ export function runController(argv, options = {}) { return success(JSON.stringify(state, null, 2)); } + if (command === "cost") { + return success(JSON.stringify(costReport(state), null, 2)); + } + + if (command === "report") { + return success(JSON.stringify(sessionReport(state), null, 2)); + } + if (command === "gate") { const current = fingerprint(cwd); const gate = gateStatus(state, current); @@ -235,6 +249,101 @@ export function runController(argv, options = {}) { return failure(USAGE); } +function costReport(state) { + const reviewers = state.reviewMode === "single" ? 1 : state.reviewMode === "split" ? 2 : 0; + const reviewHistory = Array.isArray(state.reviewHistory) ? state.reviewHistory : []; + const likelyDrivers = []; + if (state.reviewAssignments.length > 0) likelyDrivers.push("A pending independent review is active."); + if (reviewHistory.length > reviewers && reviewers > 0) { + likelyDrivers.push("Repeated review assignments are likely increasing context and reasoning usage."); + } + if (state.reviewMode === "split") { + likelyDrivers.push("Split review doubles independent reviewer context by design."); + } + if (likelyDrivers.length === 0) likelyDrivers.push("No independent review activity is currently recorded."); + + return { + exactTokenTelemetry: "unavailable", + forecast: { + routineReview: { + model: "gpt-5.6-terra", + reasoningEffort: "high", + reviewers, + }, + costBand: reviewers === 0 ? "low" : reviewers === 1 ? "medium" : "high", + solEscalation: "Require an explicit high-risk reason or direct user request.", + }, + observed: { + reviewAssignments: state.reviewAssignments.length, + reviewPasses: state.reviewPasses.length, + reviewRounds: reviewHistory.length, + }, + likelyDrivers, + }; +} + +function sessionReport(state) { + const metrics = sessionMetricsFor(state); + const reviewHistory = Array.isArray(state.reviewHistory) ? state.reviewHistory : []; + const likelyDrivers = []; + const recommendedImprovements = []; + const solRequests = Object.entries(metrics.delegation.requestedProfiles) + .filter(([profile]) => profile.startsWith("gpt-5.6-sol:")) + .reduce((total, [, count]) => total + count, 0); + + if (metrics.delegation.spawnRequests > 0) { + likelyDrivers.push(`${metrics.delegation.spawnRequests} delegated agent request(s) added independent context and reasoning work.`); + } + if (state.reviewMode === "split") { + likelyDrivers.push("Split review uses two independent reviewer contexts by design."); + recommendedImprovements.push("Use split review only for high-risk work; use one composite Terra review when the selected tier permits it."); + } + if (reviewHistory.length > 2) { + likelyDrivers.push("Repeated review assignments are likely replaying change context."); + recommendedImprovements.push("Batch related fixes before requesting another review wave."); + } + if (metrics.toolContext.outputBytes > metrics.toolContext.inputBytes * 3 && metrics.toolContext.outputBytes > 50000) { + likelyDrivers.push("Large tool outputs are likely expanding visible working context."); + recommendedImprovements.push("Ask discovery and test commands for focused output, then inspect only the relevant files or failures."); + } + if (metrics.visiblePrompt.bytes > 20000) { + likelyDrivers.push("Long user prompts are contributing substantial visible context."); + recommendedImprovements.push("Move stable workflow rules into repository guidance and keep issue prompts to outcome, boundaries, gates, and stop conditions."); + } + if (solRequests > 0) { + likelyDrivers.push(`${solRequests} requested Sol delegation(s) may be a major usage driver.`); + recommendedImprovements.push("Reserve Sol delegations for named high-risk exceptions; default review and scoped delivery to Terra."); + } + if (likelyDrivers.length === 0) likelyDrivers.push("No material session cost proxy is recorded yet."); + if (recommendedImprovements.length === 0) recommendedImprovements.push("Keep delegation narrow and request this report at discovery, pre-review, and finish checkpoints."); + + return { + exactTokenTelemetry: "unavailable", + limitations: "This report excludes hidden system, reasoning, billing, and quota telemetry. It stores counts and byte sizes only, never prompt, tool, or reviewer content.", + parentModel: { + observed: metrics.parentModels, + note: "Observed from user-prompt hook metadata; absent values are reported as unknown.", + }, + visiblePrompt: metrics.visiblePrompt, + toolContext: metrics.toolContext, + delegation: { + ...metrics.delegation, + requestedProfiles: metrics.delegation.requestedProfiles, + note: "Profiles are requested at spawn time, not verified runtime billing telemetry.", + }, + workflow: { + ...metrics.workflow, + reviewMode: state.reviewMode, + reviewAssignments: state.reviewAssignments.length, + reviewPasses: state.reviewPasses.length, + reviewRounds: reviewHistory.length, + validationRecorded: state.validation !== null, + }, + likelyDrivers, + recommendedImprovements, + }; +} + export function loadState(dataDir, sessionId) { const file = statePath(dataDir, sessionId); if (!existsSync(file)) return null; @@ -264,6 +373,48 @@ export function isDeployInvocation(toolName, toolInput, cwd) { return false; } +function reviewAssignmentReference(toolInput) { + const message = typeof toolInput.message === "string" ? toolInput.message : ""; + const match = /(?:^|\n)\s*VOLTFLOW_REVIEW_ASSIGNMENT:\s*([a-z0-9][a-z0-9_-]*)\s+([a-f0-9-]+)\s*$/im.exec(message); + return match === null ? null : { lane: match[1], token: match[2] }; +} + +function boundReviewSpawnViolation(state, toolInput, reference, currentFingerprint) { + const assignment = state.reviewAssignments.find( + (entry) => entry.lane === reference.lane && entry.token === reference.token && entry.fingerprint === currentFingerprint, + ); + if (assignment === undefined) return "VoltFlow rejected reviewer spawn: assignment is missing, stale, or belongs to a different lane."; + if (assignment.spawn !== undefined) return "VoltFlow rejected reviewer spawn: this assignment is already bound to another agent."; + const scope = typeof toolInput.message === "string" ? toolInput.message : ""; + if (toolInput.model === "gpt-5.6-sol" && namedSolReviewException(scope)) return null; + if (toolInput.model === "gpt-5.6-terra" && toolInput.reasoning_effort === "high") return null; + return "VoltFlow cost policy requires routine review agents to explicitly use model=gpt-5.6-terra and reasoning_effort=high. Sol review requires SOL_EXCEPTION with a named high-risk boundary or a direct user request."; +} + +function bindReviewSpawn(state, input, currentFingerprint) { + if (!isRecord(input.tool_input)) return null; + const reference = reviewAssignmentReference(input.tool_input); + if (reference === null) return null; + const assignment = state.reviewAssignments.find( + (entry) => entry.lane === reference.lane && entry.token === reference.token && entry.fingerprint === currentFingerprint, + ); + if (assignment === undefined || assignment.spawn !== undefined) return null; + const response = isRecord(input.tool_response) ? input.tool_response : {}; + const agentId = typeof response.agent_id === "string" ? response.agent_id : typeof response.agentId === "string" ? response.agentId : null; + if (agentId === null) return { systemMessage: "VoltFlow could not bind the reviewer: the spawn response did not include an agent id." }; + assignment.spawn = { + agentId, + model: typeof input.tool_input.model === "string" ? input.tool_input.model : "inherited-or-unknown", + reasoningEffort: typeof input.tool_input.reasoning_effort === "string" ? input.tool_input.reasoning_effort : "inherited-or-unknown", + at: timestamp(), + }; + return null; +} + +function namedSolReviewException(scope) { + return /SOL_EXCEPTION:\s*(?:direct user request|authorization|authentication|private data|private uploads?|payments?|refunds?|booking lifecycle|destructive migration|data[- ]integrity migration|cross[- ]cutting architecture)\b/i.test(scope); +} + export function workspaceFingerprint(cwd) { const rootResult = git(cwd, ["rev-parse", "--show-toplevel"]); if (!rootResult.ok) return null; @@ -329,6 +480,10 @@ function onUserPromptLocked(input, context) { : freshState(input.session_id, input.cwd, previous, current); state.active = true; state.promptHash = createHash("sha256").update(input.prompt).digest("hex"); + const metrics = sessionMetricsFor(state); + metrics.visiblePrompt.submissions += 1; + metrics.visiblePrompt.bytes += byteSize(input.prompt); + increment(metrics.parentModels, typeof input.model === "string" ? input.model : "unknown"); state.stopBlocks = 0; state.reworkCycles = 0; state.valueWarningIssued = false; @@ -339,11 +494,11 @@ function onUserPromptLocked(input, context) { const prefix = `node ${quote(script)} --data-dir ${quote(context.dataDir)} --session ${quote(input.session_id)}`; const configNote = context.configError === null ? "" : ` Config warning: ${context.configError}`; return userContext( - `VoltFlow is active. Before editing, run ${prefix.replace("", "start")} --tier --tdd --review . ` + + `VoltFlow is active. Before editing, run ${prefix.replace("", "start")} --tier --tdd --review . ` + `If the controller cannot access PLUGIN_DATA inside the sandbox, rerun the exact command with external permission; do not relocate the approval state. ` + `For a simple, low-risk edit with no deployment intent, replace start with skip and add --evidence ; skip must happen before any change and does not approve deployment. ` + `For manual evidence, replace start with red or validate and add --evidence ; self review uses approve --self --evidence . ` + - `Before an independent review, replace start with review and add --lane ; give its returned token to the reviewer, whose final receipt must be VOLTFLOW_REVIEW: PASS|FAIL . ` + + `Before an independent review, run ${prefix.replace("", "cost")} for the forecast. Routine review agents must explicitly use gpt-5.6-terra at high reasoning; Sol needs a named high-risk exception or direct user request. Run ${prefix.replace("", "report")} at discovery, pre-review, and finish for whole-session cost proxies. Then replace start with review and add --lane ; give its returned token to the reviewer, whose final receipt must be VOLTFLOW_REVIEW: PASS|FAIL . ` + `Completion bar: finish when current evidence shows the result is safe and satisfies the requested scope. Theoretical edge cases are advisory unless they are reproducible in ordinary documented use and break requested behavior, a repository invariant, or a material safety boundary. Do not reopen validated work for speculative improvement.${configNote}`, ); } @@ -372,6 +527,11 @@ function onPreToolUse(input, context) { ) { return deny('VoltFlow requires v2 subagents to use fork_turns: "none".'); } + if (state?.active === true && input.tool_name.endsWith("spawn_agent") && isRecord(input.tool_input)) { + const reference = reviewAssignmentReference(input.tool_input); + const violation = reference === null ? null : boundReviewSpawnViolation(state, input.tool_input, reference, context.fingerprint(input.cwd)); + if (violation !== null) return deny(violation); + } if (isDeployInvocation(input.tool_name, input.tool_input, input.cwd)) { if (state === null) return deny("VoltFlow blocked deployment: no workflow state or review receipt exists."); @@ -421,7 +581,9 @@ function onPostToolUseLocked(input, context) { const state = loadState(context.dataDir, input.session_id); if (state?.active !== true || !sameWorkspace(state.cwd, input.cwd)) return null; const failed = toolFailed(input.tool_response); + recordToolUsage(state, input, failed); const current = context.fingerprint(input.cwd); + const spawnBindingWarning = failed ? null : bindReviewSpawn(state, input, current); const recoveredViolation = recoverRevertedViolation(state, current); const editTool = typeof input.tool_name === "string" && isEditTool(input.tool_name); @@ -431,6 +593,7 @@ function onPostToolUseLocked(input, context) { const fingerprintChanged = current !== null && state.lastFingerprint !== null && current !== state.lastFingerprint; let valueWarning = null; if ((!failed && editTool) || fingerprintChanged) { + sessionMetricsFor(state).workflow.changeEvents += 1; const paths = editedPaths(input.tool_input); const testOnlyEdit = editTool && paths.length > 0 && paths.every(isTestPath); const touchesTest = (editTool && paths.some(isTestPath)) || (command !== null && commandMentionsTestPath(command)); @@ -463,6 +626,9 @@ function onPostToolUseLocked(input, context) { } if (command !== null && isTestCommand(command)) { + const workflow = sessionMetricsFor(state).workflow; + workflow.testRuns += 1; + if (failed) workflow.failedTestRuns += 1; if (failed && state.tdd === "required" && state.tddViolation !== true) { state.red = evidence(command, current); state.redObserved = state.red; @@ -472,7 +638,7 @@ function onPostToolUseLocked(input, context) { if (current !== null) state.lastFingerprint = current; state.updatedAt = timestamp(); saveState(context.dataDir, state); - return valueWarning; + return valueWarning ?? spawnBindingWarning; } function onSubagentStart() { @@ -508,6 +674,11 @@ function onSubagentStopLocked(input, context) { return { systemMessage: "VoltFlow ignored an unassigned or stale review receipt." }; } + const agentId = typeof input.agent_id === "string" ? input.agent_id : "unknown"; + if (assignment.spawn === undefined || assignment.spawn.agentId !== agentId) { + return { systemMessage: "VoltFlow ignored review receipt: it must come from the bound reviewer agent." }; + } + if (outcome === "FAIL") { state.reviewFailure = { lane, at: timestamp() }; state.reviewPasses = []; @@ -521,7 +692,6 @@ function onSubagentStopLocked(input, context) { if (blocker !== null) return { systemMessage: `VoltFlow ignored review pass: ${blocker}` }; if (current === null) return { systemMessage: "VoltFlow ignored review pass: Git fingerprint unavailable." }; - const agentId = typeof input.agent_id === "string" ? input.agent_id : "unknown"; state.reviewPasses = state.reviewPasses.filter((entry) => entry.fingerprint === current); if (!state.reviewPasses.some((entry) => entry.agentId === agentId)) { state.reviewPasses.push({ agentId, lane, fingerprint: current, at: timestamp() }); @@ -595,6 +765,7 @@ function freshState(sessionId, cwd, previous, currentFingerprint) { validation: null, reviewAssignments: [], reviewPasses: [], + reviewHistory: [], reviewFailure: null, approval: previous?.approval?.fingerprint === currentFingerprint ? previous.approval : null, override: @@ -605,12 +776,84 @@ function freshState(sessionId, cwd, previous, currentFingerprint) { stopBlocks: 0, reworkCycles: 0, valueWarningIssued: false, + sessionMetrics: validSessionMetrics(previous?.sessionMetrics) ? previous.sessionMetrics : freshSessionMetrics(), lastFingerprint: currentFingerprint, createdAt: timestamp(), updatedAt: timestamp(), }; } +function freshSessionMetrics() { + return { + parentModels: {}, + visiblePrompt: { submissions: 0, bytes: 0 }, + toolContext: { calls: 0, inputBytes: 0, outputBytes: 0, categories: {} }, + delegation: { spawnRequests: 0, handoffBytes: 0, requestedProfiles: {} }, + workflow: { changeEvents: 0, testRuns: 0, failedTestRuns: 0, validationRecords: 0 }, + }; +} + +function sessionMetricsFor(state) { + const metrics = validSessionMetrics(state.sessionMetrics) ? state.sessionMetrics : freshSessionMetrics(); + state.sessionMetrics = metrics; + return metrics; +} + +function recordToolUsage(state, input, failed) { + if (typeof input.tool_name !== "string") return; + const metrics = sessionMetricsFor(state); + const category = toolCategory(input.tool_name, input.tool_input); + const inputBytes = byteSize(input.tool_input); + const outputBytes = byteSize(input.tool_response); + metrics.toolContext.calls += 1; + metrics.toolContext.inputBytes += inputBytes; + metrics.toolContext.outputBytes += outputBytes; + const bucket = metrics.toolContext.categories[category] ?? { calls: 0, inputBytes: 0, outputBytes: 0, failures: 0 }; + bucket.calls += 1; + bucket.inputBytes += inputBytes; + bucket.outputBytes += outputBytes; + if (failed) bucket.failures += 1; + metrics.toolContext.categories[category] = bucket; + + if (category !== "delegation" || failed || !isRecord(input.tool_input)) return; + metrics.delegation.spawnRequests += 1; + metrics.delegation.handoffBytes += byteSize(input.tool_input.task_name) + byteSize(input.tool_input.message); + const model = typeof input.tool_input.model === "string" ? input.tool_input.model : "inherited-or-unknown"; + const reasoningEffort = typeof input.tool_input.reasoning_effort === "string" + ? input.tool_input.reasoning_effort + : typeof input.tool_input.reasoningEffort === "string" + ? input.tool_input.reasoningEffort + : "inherited-or-unknown"; + increment(metrics.delegation.requestedProfiles, `${model}:${reasoningEffort}`); +} + +function toolCategory(toolName, toolInput) { + const name = toolName.toLowerCase(); + const command = isCommandTool(toolName) ? commandFrom(toolInput) : null; + if (name.endsWith("spawn_agent")) return "delegation"; + if (name.includes("codebase_memory") || name.includes("search_graph") || name.includes("trace_path") || name.includes("get_code_snippet")) return "discovery"; + if (name.includes("github") || name.startsWith("gh_")) return "github"; + if (name.includes("browser") || name.includes("playwright") || name.includes("chrome")) return "browser"; + if (command !== null && isTestCommand(command)) return "test"; + if (isEditTool(toolName)) return "edit"; + if (isCommandTool(toolName)) return "command"; + return "other"; +} + +function byteSize(value) { + if (value === undefined) return 0; + if (typeof value === "string") return Buffer.byteLength(value, "utf8"); + try { + return Buffer.byteLength(JSON.stringify(value), "utf8"); + } catch { + return 0; + } +} + +function increment(record, key) { + record[key] = (record[key] ?? 0) + 1; +} + function gateStatus(state, currentFingerprint) { if (currentFingerprint === null) return { allowed: false, reason: "Git fingerprint unavailable" }; if (state.approval?.fingerprint === currentFingerprint) return { allowed: true, source: "review receipt" }; @@ -699,6 +942,8 @@ function validState(value, sessionId) { && value.reviewAssignments.every(validReviewAssignment) && Array.isArray(value.reviewPasses) && value.reviewPasses.every(validReviewPass) + && (value.reviewHistory === undefined || (Array.isArray(value.reviewHistory) && value.reviewHistory.every(validReviewHistoryEntry))) + && (value.sessionMetrics === undefined || validSessionMetrics(value.sessionMetrics)) && (value.reviewFailure === null || (isRecord(value.reviewFailure) && typeof value.reviewFailure.lane === "string")) && (value.approval === null || validApproval(value.approval)) && (value.override === null || validOverride(value.override)) @@ -717,6 +962,15 @@ function validReviewAssignment(value) { && typeof value.lane === "string" && typeof value.token === "string" && typeof value.fingerprint === "string" + && typeof value.at === "string" + && (value.spawn === undefined || validReviewSpawn(value.spawn)); +} + +function validReviewSpawn(value) { + return isRecord(value) + && typeof value.agentId === "string" + && typeof value.model === "string" + && typeof value.reasoningEffort === "string" && typeof value.at === "string"; } @@ -728,6 +982,51 @@ function validReviewPass(value) { && typeof value.at === "string"; } +function validSessionMetrics(value) { + return isRecord(value) + && isCountRecord(value.parentModels) + && isRecord(value.visiblePrompt) + && validCount(value.visiblePrompt.submissions) + && validCount(value.visiblePrompt.bytes) + && isRecord(value.toolContext) + && validCount(value.toolContext.calls) + && validCount(value.toolContext.inputBytes) + && validCount(value.toolContext.outputBytes) + && isRecord(value.toolContext.categories) + && Object.values(value.toolContext.categories).every(validToolCategoryMetrics) + && isRecord(value.delegation) + && validCount(value.delegation.spawnRequests) + && validCount(value.delegation.handoffBytes) + && isCountRecord(value.delegation.requestedProfiles) + && isRecord(value.workflow) + && validCount(value.workflow.changeEvents) + && validCount(value.workflow.testRuns) + && validCount(value.workflow.failedTestRuns) + && validCount(value.workflow.validationRecords); +} + +function validToolCategoryMetrics(value) { + return isRecord(value) + && validCount(value.calls) + && validCount(value.inputBytes) + && validCount(value.outputBytes) + && validCount(value.failures); +} + +function isCountRecord(value) { + return isRecord(value) && Object.values(value).every(validCount); +} + +function validCount(value) { + return Number.isSafeInteger(value) && value >= 0; +} + +function validReviewHistoryEntry(value) { + return isRecord(value) + && typeof value.lane === "string" + && typeof value.at === "string"; +} + function validApproval(value) { return isRecord(value) && typeof value.fingerprint === "string" @@ -866,9 +1165,10 @@ function compilePaths(value, root) { if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.length === 0)) { throw new Error("fingerprintPaths must be an array of relative file paths"); } + const normalizedRoot = path.resolve(root); for (const entry of value) { - const resolved = path.resolve(root, entry); - if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + const resolved = path.resolve(normalizedRoot, entry); + if (resolved !== normalizedRoot && !resolved.startsWith(`${normalizedRoot}${path.sep}`)) { throw new Error("fingerprintPaths entries must stay inside the Git worktree"); } } @@ -876,8 +1176,9 @@ function compilePaths(value, root) { } function hashWorkspacePath(hash, root, relativePath, allowMissing = false) { - const resolved = path.resolve(root, relativePath); - if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return false; + const normalizedRoot = path.resolve(root); + const resolved = path.resolve(normalizedRoot, relativePath); + if (resolved !== normalizedRoot && !resolved.startsWith(`${normalizedRoot}${path.sep}`)) return false; hashField(hash, "path", relativePath); if (!existsSync(resolved)) { if (!allowMissing) return false; @@ -886,7 +1187,7 @@ function hashWorkspacePath(hash, root, relativePath, allowMissing = false) { } const stats = lstatSync(resolved); if (stats.isDirectory()) return false; - const object = git(root, ["hash-object", "--no-filters", "--", relativePath]); + const object = git(normalizedRoot, ["hash-object", "--no-filters", "--", relativePath]); if (!object.ok) return false; hashField(hash, "mode", String(stats.mode)); hashField(hash, "object", object.stdout); diff --git a/skills/voltflow/SKILL.md b/skills/voltflow/SKILL.md index f8c880b..ff5500e 100644 --- a/skills/voltflow/SKILL.md +++ b/skills/voltflow/SKILL.md @@ -55,6 +55,12 @@ Each spawn prompt contains five labeled fields and nothing repetitive: Treat examples as examples, not hidden requirements. When instructions conflict, follow the user outcome, repository invariants, and the bounded assignment in that order. Return a concise result with evidence; do not invent neighboring work to make the assignment look complete. +## Monitor whole-session cost honestly + +Run the injected `report` command after discovery, before independent review, and at finish. It is a proxy ledger, not a token meter: it records visible prompt bytes, tool input/output byte totals by category, requested agent profiles and handoff bytes, validation activity, and review waves. It never stores prompt, tool, or reviewer content, and it cannot see hidden reasoning, billing, quota, or actual provider token usage. + +Use the report to remove avoidable repetition: narrow discovery output, keep delegations to one bounded outcome, batch related fixes before a review wave, and use split review only for high-risk work. Do not claim that requested subagent profiles are verified runtime billing telemetry. + ## Review in proportion to risk Review the final diff against the request, not against an imagined ideal rewrite. Cover correctness, regression risk, relevant security boundaries, validation quality, and unnecessary scope. @@ -63,7 +69,7 @@ Review the final diff against the request, not against an imagined ideal rewrite - `single`: one independent reviewer covers the full checklist. - `split`: two reviewers use non-overlapping lanes: correctness/security and validation/scope. -Before spawning each independent reviewer, run the injected controller with `review --lane `. Put the returned token in the review prompt. The reviewer ends with `VOLTFLOW_REVIEW: PASS ` only when no blocking finding remains, or `VOLTFLOW_REVIEW: FAIL ` after its findings. Receipts without a current assignment are ignored, a failed lane clears earlier passes, and any edit invalidates every receipt. +Before spawning each independent reviewer, run the injected controller with `review --lane `. Put `VOLTFLOW_REVIEW_ASSIGNMENT: ` in the spawn prompt. VoltFlow binds the successful spawn's agent ID, requested model, and reasoning effort to that assignment. The bound reviewer ends with `VOLTFLOW_REVIEW: PASS ` only when no blocking finding remains, or `VOLTFLOW_REVIEW: FAIL ` after its findings. Receipts without a current assignment or bound agent are ignored, a failed lane clears earlier passes, and any edit invalidates every receipt. ### Bound review exploration @@ -71,7 +77,7 @@ Before spawning a reviewer, state the supported product boundary and material co When failures share one mechanism, fix and test that mechanism once. Do not keep adding syntax-specific patterns after the boundary is characterized. If complete enforcement would require a shell parser, a general solver, or control over an interception surface the host does not provide, keep the local guard conservative, document the limit, and route authoritative enforcement to the controller or project configuration. -After a failed review, group findings by root cause and rerun only affected coverage. If a rerun finds another variant of the same bounded limitation, stop patching variants and reassess the abstraction or accepted boundary. Finish when the original acceptance criteria have current evidence and no material ordinary-use blocker remains. Use Sol high for routine independent review; raise effort only for a named risk that changes the completion bar. +After a failed review, group findings by root cause and rerun only affected coverage. If a rerun finds another variant of the same bounded limitation, stop patching variants and reassess the abstraction or accepted boundary. Finish when the original acceptance criteria have current evidence and no material ordinary-use blocker remains. Use Terra high for routine independent review. Use Sol only when the review names an authorization, private-data, payment, booking, destructive-migration, data-integrity, or cross-cutting-architecture risk that changes the completion bar, or when the user explicitly requests Sol. ## Gate deployment diff --git a/skills/voltflow/references/routing.md b/skills/voltflow/references/routing.md index 8b16909..02a3d3d 100644 --- a/skills/voltflow/references/routing.md +++ b/skills/voltflow/references/routing.md @@ -6,8 +6,8 @@ Use the smallest profile that clears the task. These profiles seed VoltFlow's ro | --- | --- | | `gpt-5.6-luna`, `high` | Read-heavy discovery, focused test execution, mechanical edits, and simple pattern-following work. | | `gpt-5.6-terra`, `max` | Standard implementation inside established architecture and bounded multi-file changes. | -| `gpt-5.6-sol`, `medium` | Planning, integration, and composite review that need global coherence without max-cost reasoning. | -| `gpt-5.6-sol`, `high` | Routine adversarial review, correctness checks, and bounded security review without a named high-risk boundary. | +| `gpt-5.6-terra`, `high` | Routine independent review, adversarial correctness checks, and bounded security review without a named high-risk boundary. | +| `gpt-5.6-sol`, `medium` | Planning and integration that need global coherence without max-cost reasoning. | | `gpt-5.6-sol`, `max` | High-risk architecture, security, concurrency, migrations, and ambiguous work where a missed constraint is expensive. | ## Routing rules @@ -15,7 +15,7 @@ Use the smallest profile that clears the task. These profiles seed VoltFlow's ro 1. Choose by difficulty, not by role title. A mechanical security-file edit can use Luna; a subtle one-file race can require Sol max. 2. Set `model` and `reasoning_effort` directly when the spawn schema exposes them. Omit `service_tier`. 3. If only `agent_type` is available, select a profile that pins the required model and effort. If neither override surface exists, inherit the parent and state `routing degraded` once in the result. -4. Use Sol medium for the normal planner and composite reviewer, and Sol high for routine independent review lanes. Raise to Sol max only for a named high-risk fact. +4. Use Terra high for routine review-only agents. The controller requires it explicitly, so do not inherit the parent model. Use Sol only with a named high-risk exception (authorization, private data, payments, bookings, destructive/data-integrity migrations, or cross-cutting architecture) or when the user explicitly requests it. 5. Size each wave to the useful independent slices and the host's available concurrency. Do not create slices solely to increase agent count. ## Spawn schema diff --git a/test/voltflow.test.mjs b/test/voltflow.test.mjs index b50a190..8dceb86 100644 --- a/test/voltflow.test.mjs +++ b/test/voltflow.test.mjs @@ -4,6 +4,7 @@ import { createHash } from "node:crypto"; import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; import { @@ -115,6 +116,22 @@ function review(fx, agentId, lane) { assert.equal(assigned.exitCode, 0, assigned.stderr); const token = /token=(\S+)/.exec(assigned.stdout)?.[1]; assert.ok(token); + const toolInput = { + task_name: "checker", + message: `VOLTFLOW_REVIEW_ASSIGNMENT: ${lane} ${token}`, + fork_turns: "none", + model: "gpt-5.6-terra", + reasoning_effort: "high", + }; + assert.equal(handleHook(input("PreToolUse", { tool_name: "agentsspawn_agent", tool_input: toolInput }), fx.options), null); + handleHook( + input("PostToolUse", { + tool_name: "agentsspawn_agent", + tool_input: toolInput, + tool_response: { agent_id: agentId }, + }), + fx.options, + ); return handleHook( input("SubagentStop", { agent_id: agentId, @@ -143,11 +160,12 @@ test("prompt injection starts session state and names the exact controller", () fx.options, ); - assert.match(output.hookSpecificOutput.additionalContext, /node ['"]\/plugin\/scripts\/voltflow\.mjs['"]/); + assert.match(output.hookSpecificOutput.additionalContext, /node ['"][^'"]*plugin[\\/]scripts[\\/]voltflow\.mjs['"]/); assert.match(output.hookSpecificOutput.additionalContext, /--session ['"]session-1['"]/); assert.match(output.hookSpecificOutput.additionalContext, /external permission/i); assert.match(output.hookSpecificOutput.additionalContext, /theoretical edge cases are advisory/i); assert.match(output.hookSpecificOutput.additionalContext, /safe and satisfies the requested scope/i); + assert.match(output.hookSpecificOutput.additionalContext, /cost.*gpt-5\.6-terra.*high/i); assert.equal(loadState(fx.dataDir, "session-1").tier, "unclassified"); }); @@ -484,7 +502,198 @@ test("an unfinished workflow reactivates when the user says continue", () => { test("controller help lists the workflow commands", () => { const result = runController(["--help"]); assert.equal(result.exitCode, 0); - assert.match(result.stdout, /start\|skip\|red\|validate\|review\|approve\|status\|gate/); + assert.match(result.stdout, /start\|skip\|red\|validate\|review\|approve\|status\|cost\|report\|gate/); +}); + +test("cost report plans Terra high reviews and records review-round pressure", () => { + const fx = fixture(); + start(fx, { tier: "standard", tdd: "exempt", review: "single" }); + + const initial = runController(["cost", "--session", "session-1"], { ...fx.options, cwd: "/repo" }); + assert.equal(initial.exitCode, 0, initial.stderr); + const initialReport = JSON.parse(initial.stdout); + assert.equal(initialReport.exactTokenTelemetry, "unavailable"); + assert.deepEqual(initialReport.forecast.routineReview, { + model: "gpt-5.6-terra", + reasoningEffort: "high", + reviewers: 1, + }); + + const validated = runController( + ["validate", "--session", "session-1", "--evidence", "closest relevant check passed"], + { ...fx.options, cwd: "/repo" }, + ); + assert.equal(validated.exitCode, 0, validated.stderr); + const assigned = runController( + ["review", "--session", "session-1", "--lane", "composite"], + { ...fx.options, cwd: "/repo" }, + ); + assert.equal(assigned.exitCode, 0, assigned.stderr); + + const report = JSON.parse(runController(["cost", "--session", "session-1"], { ...fx.options, cwd: "/repo" }).stdout); + assert.equal(report.observed.reviewAssignments, 1); + assert.equal(report.observed.reviewPasses, 0); + assert.equal(report.likelyDrivers[0], "A pending independent review is active."); +}); + +test("session report tracks metadata-only whole-session cost proxies", () => { + const fx = fixture(); + const secret = "do-not-persist-this-content"; + handleHook( + input("UserPromptSubmit", { prompt: `Investigate ${secret}`, model: "gpt-5.6-terra" }), + fx.options, + ); + start(fx, { tier: "standard", tdd: "exempt", review: "single" }); + + handleHook( + input("PostToolUse", { + tool_name: "mcp__codebase_memory_mcp__search_graph", + tool_input: { query: "Asset" }, + tool_response: { output: "matched symbol" }, + }), + fx.options, + ); + handleHook( + input("PostToolUse", { + tool_name: "agentsspawn_agent", + tool_input: { + task_name: "reviewer", + message: `WORK LAYER: review ${secret}`, + fork_turns: "none", + model: "gpt-5.6-terra", + reasoning_effort: "high", + }, + tool_response: { agent_id: "agent-1" }, + }), + fx.options, + ); + + const result = runController(["report", "--session", "session-1"], { ...fx.options, cwd: "/repo" }); + assert.equal(result.exitCode, 0, result.stderr); + const report = JSON.parse(result.stdout); + assert.equal(report.exactTokenTelemetry, "unavailable"); + assert.equal(report.parentModel.observed["gpt-5.6-terra"], 1); + assert.ok(report.visiblePrompt.bytes > 0); + assert.equal(report.toolContext.categories.discovery.calls, 1); + assert.equal(report.delegation.requestedProfiles["gpt-5.6-terra:high"], 1); + assert.equal(report.delegation.handoffBytes > 0, true); + assert.equal(JSON.stringify(report).includes(secret), false); + assert.equal(JSON.stringify(loadState(fx.dataDir, "session-1")).includes(secret), false); +}); + +test("session report retains proxy metrics across completed turns", () => { + const fx = fixture(); + handleHook(input("UserPromptSubmit", { prompt: "first turn", model: "gpt-5.6-terra" }), fx.options); + start(fx, { tier: "trivial", tdd: "exempt", review: "self" }); + handleHook(input("Stop", { last_assistant_message: "Done" }), fx.options); + + handleHook(input("UserPromptSubmit", { prompt: "second turn", model: "gpt-5.6-terra" }), fx.options); + const result = runController(["report", "--session", "session-1"], { ...fx.options, cwd: "/repo" }); + assert.equal(result.exitCode, 0, result.stderr); + const report = JSON.parse(result.stdout); + assert.equal(report.visiblePrompt.submissions, 2); + assert.equal(report.parentModel.observed["gpt-5.6-terra"], 2); +}); + +test("routine review agents require Terra high unless a named Sol exception applies", () => { + const fx = fixture(); + handleHook(input("UserPromptSubmit", { prompt: "Review the parser" }), fx.options); + start(fx, { tier: "standard", tdd: "exempt", review: "single" }); + + const validated = runController( + ["validate", "--session", "session-1", "--evidence", "closest relevant check passed"], + { ...fx.options, cwd: "/repo" }, + ); + assert.equal(validated.exitCode, 0, validated.stderr); + const assigned = runController( + ["review", "--session", "session-1", "--lane", "composite"], + { ...fx.options, cwd: "/repo" }, + ); + assert.equal(assigned.exitCode, 0, assigned.stderr); + const token = /token=(\S+)/.exec(assigned.stdout)?.[1]; + assert.ok(token); + + const reviewSpawn = (model, reasoningEffort, message) => handleHook( + input("PreToolUse", { + tool_name: "agentsspawn_agent", + tool_input: { + task_name: "checker", + message, + fork_turns: "none", + model, + reasoning_effort: reasoningEffort, + }, + }), + fx.options, + ); + + const assignment = `VOLTFLOW_REVIEW_ASSIGNMENT: composite ${token}`; + const denied = reviewSpawn("gpt-5.6-sol", "high", assignment); + assert.equal(denied.hookSpecificOutput.permissionDecision, "deny"); + assert.match(denied.hookSpecificOutput.permissionDecisionReason, /gpt-5\.6-terra.*high/i); + assert.equal(reviewSpawn("gpt-5.6-terra", "high", assignment), null); +}); + +test("review receipts require the agent and profile bound to an explicit assignment", () => { + const fx = fixture(); + handleHook(input("UserPromptSubmit", { prompt: "Review the parser" }), fx.options); + start(fx, { tier: "standard", tdd: "exempt", review: "single" }); + const validated = runController( + ["validate", "--session", "session-1", "--evidence", "closest relevant check passed"], + { ...fx.options, cwd: "/repo" }, + ); + assert.equal(validated.exitCode, 0, validated.stderr); + const assigned = runController( + ["review", "--session", "session-1", "--lane", "composite"], + { ...fx.options, cwd: "/repo" }, + ); + const token = /token=(\S+)/.exec(assigned.stdout)?.[1]; + assert.ok(token); + const toolInput = { + task_name: "inspection", + message: `VOLTFLOW_REVIEW_ASSIGNMENT: composite ${token}`, + fork_turns: "none", + model: "gpt-5.6-terra", + reasoning_effort: "high", + }; + assert.equal(handleHook(input("PreToolUse", { tool_name: "agentsspawn_agent", tool_input: toolInput }), fx.options), null); + handleHook( + input("PostToolUse", { tool_name: "agentsspawn_agent", tool_input: toolInput, tool_response: { agent_id: "bound-agent" } }), + fx.options, + ); + assert.deepEqual(loadState(fx.dataDir, "session-1").reviewAssignments[0].spawn, { + agentId: "bound-agent", + model: "gpt-5.6-terra", + reasoningEffort: "high", + at: loadState(fx.dataDir, "session-1").reviewAssignments[0].spawn.at, + }); + + const wrongAgent = handleHook( + input("SubagentStop", { + agent_id: "other-agent", + last_assistant_message: `VOLTFLOW_REVIEW: PASS composite ${token}`, + }), + fx.options, + ); + assert.match(wrongAgent.systemMessage, /bound reviewer agent/i); + assert.equal(loadState(fx.dataDir, "session-1").approval, null); + + assert.equal( + handleHook( + input("SubagentStop", { + agent_id: "bound-agent", + last_assistant_message: `VOLTFLOW_REVIEW: PASS composite ${token}`, + }), + fx.options, + ), + null, + ); + assert.equal(loadState(fx.dataDir, "session-1").approval.source, "subagent"); +}); + +test("installed PostToolUse hook captures every tool category", () => { + const hooks = JSON.parse(readFileSync(new URL("../hooks/hooks.json", import.meta.url), "utf8")); + assert.equal(hooks.hooks.PostToolUse[0].matcher, "*"); }); function productionPatchDecision(fx) { @@ -640,6 +849,29 @@ test("review receipts require a fingerprint-bound assignment token", () => { assert.equal(assigned.exitCode, 0, assigned.stderr); const token = /token=(\S+)/.exec(assigned.stdout)?.[1]; assert.ok(token); + const reviewToolInput = { + task_name: "checker", + message: `VOLTFLOW_REVIEW_ASSIGNMENT: correctness-security ${token}`, + fork_turns: "none", + model: "gpt-5.6-terra", + reasoning_effort: "high", + }; + handleHook( + input("SubagentStop", { + agent_id: "reviewer-1", + last_assistant_message: `VOLTFLOW_REVIEW: PASS correctness-security ${token}`, + }), + fx.options, + ); + assert.equal(loadState(fx.dataDir, "session-1").reviewPasses.length, 0); + handleHook( + input("PostToolUse", { + tool_name: "agentsspawn_agent", + tool_input: reviewToolInput, + tool_response: { agent_id: "reviewer-1" }, + }), + fx.options, + ); handleHook( input("SubagentStop", { agent_id: "reviewer-1", @@ -720,6 +952,25 @@ test("concurrent split reviews retain both receipts", async () => { const validationToken = /token=(\S+)/.exec(validation.stdout)?.[1]; assert.ok(correctnessToken, correctness.stderr); assert.ok(validationToken, validation.stderr); + for (const [lane, token, agentId] of [ + ["correctness-security", correctnessToken, "reviewer-a"], + ["validation-scope", validationToken, "reviewer-b"], + ]) { + handleHook({ + hook_event_name: "PostToolUse", + session_id: "parallel", + cwd: root, + tool_name: "agentsspawn_agent", + tool_input: { + task_name: "checker", + message: `VOLTFLOW_REVIEW_ASSIGNMENT: ${lane} ${token}`, + fork_turns: "none", + model: "gpt-5.6-terra", + reasoning_effort: "high", + }, + tool_response: { agent_id: agentId }, + }, { dataDir }); + } await Promise.all([ hookProcess(root, dataDir, { hook_event_name: "SubagentStop", @@ -983,11 +1234,19 @@ test("controller state cannot cross repository roots", () => { assert.equal(result.exitCode, 1); }); -test("controller recognizes symlink aliases for the same workspace", () => { +test("controller recognizes symlink aliases for the same workspace", (t) => { const fx = fixture(); const other = fixture(); const alias = `${fx.root}-alias`; - symlinkSync(fx.root, alias, "dir"); + try { + symlinkSync(fx.root, alias, "dir"); + } catch (error) { + if (error?.code === "EPERM" || error?.code === "EACCES" || error?.code === "ENOSYS") { + t.skip(`symlinks unavailable: ${error.code}`); + return; + } + throw error; + } const started = runController( ["start", "--session", "aliased", "--tier", "standard", "--tdd", "exempt", "--review", "single"], { ...fx.options, cwd: alias }, @@ -1020,7 +1279,7 @@ test("quoted override language does not arm deployment", () => { assert.doesNotMatch(output.hookSpecificOutput.additionalContext, /override armed/i); }); -test("fingerprints distinguish large changes and untracked executable modes", () => { +test("fingerprints distinguish large changes and untracked executable modes", (t) => { const root = mkdtempSync(path.join(tmpdir(), "voltflow-fingerprint-")); git(root, "init", "-q"); git(root, "config", "user.email", "qa@example.com"); @@ -1036,6 +1295,11 @@ test("fingerprints distinguish large changes and untracked executable modes", () assert.equal(first, null); assert.equal(second, null); + if (process.platform === "win32") { + t.skip("Windows does not preserve executable mode changes with chmodSync"); + return; + } + const modeRoot = mkdtempSync(path.join(tmpdir(), "voltflow-mode-")); git(modeRoot, "init", "-q"); const script = path.join(modeRoot, "deploy.sh"); @@ -1076,7 +1340,7 @@ test("fingerprints frame untracked path fields unambiguously", () => { writeFileSync(secondPath, "same"); const twoFiles = workspaceFingerprint(root); const mode = String(lstatSync(firstPath).mode); - const object = git(root, "hash-object", "--no-filters", "--", "a"); + const object = git(root, "hash-object", "--no-filters", "--", "a").trim(); unlinkSync(firstPath); unlinkSync(secondPath); writeFileSync(path.join(root, `a${mode}${object}b`), "same"); @@ -1126,10 +1390,10 @@ function git(root, ...args) { function hookProcess(cwd, dataDir, payload) { return new Promise((resolve, reject) => { - const entry = new URL("../scripts/voltflow.mjs", import.meta.url); - const child = spawn(process.execPath, [entry.pathname, "hook"], { + const entryPath = fileURLToPath(new URL("../scripts/voltflow.mjs", import.meta.url)); + const child = spawn(process.execPath, [entryPath, "hook"], { cwd, - env: { ...process.env, PLUGIN_DATA: dataDir, PLUGIN_ROOT: path.dirname(path.dirname(entry.pathname)) }, + env: { ...process.env, PLUGIN_DATA: dataDir, PLUGIN_ROOT: path.dirname(path.dirname(entryPath)) }, stdio: ["pipe", "pipe", "pipe"], }); let stderr = "";