## Summary
Wave 18 continues the package code-organization program after wave 17
domain folders (U4 Slice A from
`docs/plans/2026-07-14-001-refactor-package-code-organization-plan.md`).
### What changed
Peel **pure, behavior-preserving** helpers out of
`packages/engine/src/executor.ts` into domain modules under
`packages/engine/src/executor/`, with **stable re-exports** from
`executor.ts` so deep imports and `vi.mock("../executor.js")` keep
working.
| New module | Symbols |
|------------|---------|
| `executor/task-done-refusal.ts` | `evaluateTaskDoneRefusal`,
`determineRevisionResetStart`, skip-bypass refusal helper |
| `executor/workflow-feedback-paths.ts` |
`extractReferencedPathsFromWorkflowFeedback`,
`isAlwaysAllowedScopeLeakPath`, `workflowPathMatchesDeclaredScope` |
| `executor/workflow-step-verdict.ts` |
`FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE`, `parseWorkflowStepVerdict`
/ `parseWorkflowStepOutput`, step outcome types |
| `executor/await-input-parse.ts` | `parseAwaitInputSentinel`,
`parseAwaitInputQuestionToolCall` |
| `executor/no-commit-eligibility.ts` | `getNoCommitEligibilityReason`
(+ prompt heuristics) |
`executor.ts` live LOC ~**22817 → ~22427** (first pure-peel batch; more
peels needed to approach the 2k cap).
### Shims
- `old path` `executor.ts` public exports → `new path` `executor/*.ts` →
delete-when consumer deep-imports are re-pointed (not this PR)
### Test plan
- [x] `@fusion/engine` typecheck
- [x] Oracle: task-done refusal, skip-bypass, workflow malformed
verdict, scope-leak allowlist, executor-step-session, executor-prompt
- [x] `vitest --project=engine-core` (merge-gate curated suite)
- [ ] CI merge gate
**Stack:** wave17 (merged) → **this PR**
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Improved recognition of workflow outcomes from structured and
conversational responses.
* Added support for extracting questions from await-input responses and
tool calls.
* Improved workflow feedback handling for referenced files and declared
scope patterns.
* Added clearer guidance for task execution, approvals, verification,
and available tools.
* **Bug Fixes**
* Prevented completion when required review approvals are missing or
revisions remain pending.
* Improved handling of workflows that legitimately require no code
changes.
* Added clearer refusal messages and more reliable revision restarts.
* Sanitized repository paths in Git remediation instructions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
119 lines
5.2 KiB
TypeScript
119 lines
5.2 KiB
TypeScript
/**
|
|
* FNXC:CodeOrganization 2026-08-03-16:50:
|
|
* captureModifiedFiles / captureWorkspaceModifiedFiles / captureUncommittedModifiedFiles
|
|
* peeled from TaskExecutor (U4 Slice B). Attribution-aware + workspace multi-repo file capture.
|
|
*/
|
|
import { exec } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import type { Task } from "@fusion/core";
|
|
import { BranchAttributionError, filterFilesToOwnTaskCommits } from "../execution/branch-attribution.js";
|
|
import { executorLog } from "../logger.js";
|
|
import type { RunAuditor } from "../util/run-audit.js";
|
|
import { resolveDiffBaseRef } from "./worktree-git-refs.js";
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
export async function captureModifiedFiles(
|
|
worktreePath: string,
|
|
baseCommitSha: string | undefined,
|
|
taskId: string,
|
|
audit?: RunAuditor,
|
|
source = "unspecified",
|
|
): Promise<string[]> {
|
|
try {
|
|
const baseRef = await resolveDiffBaseRef(worktreePath, baseCommitSha);
|
|
if (!baseRef) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const attributed = await filterFilesToOwnTaskCommits({
|
|
worktreePath,
|
|
baseRef,
|
|
taskId,
|
|
});
|
|
const divergence = attributed.rawDiffFileCount - attributed.files.length;
|
|
if (divergence > 0) {
|
|
await audit?.database({
|
|
type: "task:worktree-contamination-detected",
|
|
target: taskId,
|
|
metadata: {
|
|
rawDiffFileCount: attributed.rawDiffFileCount,
|
|
attributedFileCount: attributed.files.length,
|
|
foreignCommitCount: attributed.foreignCommits.length,
|
|
foreignCommitShas: attributed.foreignCommits.slice(0, 5).map((commit) => commit.sha),
|
|
source,
|
|
},
|
|
});
|
|
executorLog.warn(
|
|
`${taskId}: contamination detected — raw diff ${attributed.rawDiffFileCount} files, attributed ${attributed.files.length} (foreign commits: ${attributed.foreignCommits.length})`,
|
|
);
|
|
}
|
|
return attributed.files;
|
|
} catch (error) {
|
|
if (error instanceof BranchAttributionError) {
|
|
executorLog.warn(`${taskId}: branch-attribution failed (${error.message}); falling back to raw diff`);
|
|
const { stdout } = await execAsync(`git diff --name-only ${baseRef}..HEAD`, {
|
|
cwd: worktreePath,
|
|
encoding: "utf-8",
|
|
});
|
|
const output = stdout.trim();
|
|
return output ? output.split("\n").filter(Boolean) : [];
|
|
}
|
|
throw error;
|
|
}
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
executorLog.debug(`Failed to capture modified files: ${errorMessage}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FNXC:Workspace 2026-06-21-23:30: KTD1 — per-repo modified-file capture for workspace tasks.
|
|
* Loops `task.workspaceWorktrees` and REUSES `captureModifiedFiles` per sub-repo (NOT a hand-built `git diff`), so each repo gets: (a) resolveDiffBaseRef's merge-base fallback when repo.baseCommitSha is undefined, and (b) the filterFilesToOwnTaskCommits raw-vs-attributed divergence/contamination audit for free. Returned files are repo-prefixed (`<repoRel>/<file>`) and aggregated, so a downstream File-Scope check / merge can attribute each change to its sub-repo. Returns [] for a zero-acquire workspace task.
|
|
*/
|
|
export async function captureWorkspaceModifiedFiles(
|
|
task: Task,
|
|
audit?: RunAuditor,
|
|
source = "post-session",
|
|
): Promise<string[]> {
|
|
const workspaceWorktrees = task.workspaceWorktrees ?? {};
|
|
// FNXC:Workspace 2026-06-21-15:00: F4/F6 — per-repo error isolation + deterministic ordering.
|
|
// F4: an unexpected throw from one repo's `captureModifiedFiles` must NOT escape and skip the
|
|
// downstream `updateTask({modifiedFiles})` write — that would leave `task.modifiedFiles` empty and
|
|
// blind the merge file audit. Wrap each per-repo call (log + continue), mirroring the post-session
|
|
// branch-attribution loop. F6: iterate sorted repo keys so aggregation order is stable across runs.
|
|
const aggregated: string[] = [];
|
|
for (const repoRel of Object.keys(workspaceWorktrees).sort()) {
|
|
const repo = workspaceWorktrees[repoRel];
|
|
try {
|
|
const repoFiles = await captureModifiedFiles(repo.worktreePath, repo.baseCommitSha ?? undefined, task.id, audit, source);
|
|
for (const file of repoFiles) {
|
|
aggregated.push(`${repoRel}/${file}`);
|
|
}
|
|
} catch (repoErr: unknown) {
|
|
executorLog.warn(`${task.id}: per-repo modified-file capture failed for ${repoRel}: ${repoErr instanceof Error ? repoErr.message : String(repoErr)}`);
|
|
}
|
|
}
|
|
return aggregated;
|
|
}
|
|
|
|
export async function captureUncommittedModifiedFiles(worktreePath: string): Promise<string[]> {
|
|
try {
|
|
const [unstaged, staged] = await Promise.all([
|
|
execAsync("git diff --name-only", { cwd: worktreePath, encoding: "utf-8" }),
|
|
execAsync("git diff --name-only --cached", { cwd: worktreePath, encoding: "utf-8" }),
|
|
]);
|
|
const files = [...unstaged.stdout.split("\n"), ...staged.stdout.split("\n")]
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
return [...new Set(files)];
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
executorLog.warn(`Failed to capture uncommitted modified files: ${errorMessage}`);
|
|
return [];
|
|
}
|
|
}
|
|
|