@lazyingart/agintiflow 0.20.299 → 0.20.300

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.
@@ -6,7 +6,7 @@ AgInTiFlow separates visual understanding, web search, and wrapper advice so eac
6
6
 
7
7
  | Tool | Purpose | Evidence |
8
8
  | --- | --- | --- |
9
- | `read_image` | Read workspace screenshots, plots, scanned text, diagrams, or allowed remote image URLs. | Saves JSON and Markdown reports under `artifacts/perception/`, records image hashes, and sends the Markdown report to the canvas when used by an agent run. |
9
+ | `read_image` | Read workspace screenshots, plots, scanned text, diagrams, or allowed remote image URLs. | Saves canonical JSON/Markdown evidence in the session artifact store, keeps the workspace-relative canvas copy under ignored `.aginti/artifacts/perception/`, and records image hashes. |
10
10
  | `web_search` | Cheap raw search snippets or a bounded multi-provider ensemble. | Returns compact titles, canonical URLs, snippets, provider attempts, and per-provider discovery evidence. |
11
11
  | `read_web_page` | Read one exact public source instead of relying on a snippet. | Returns bounded article/PDF text, metadata, canonical URL, relevant passages, retrieval time, and content hash. |
12
12
  | `web_research` | Sourced research unit for current or external information. | Saves `artifacts/research/*-web-research.json` with query, mode, source list, and answer. |
@@ -18,6 +18,7 @@ AgInTiFlow separates visual understanding, web search, and wrapper advice so eac
18
18
  - In a LocalLLM session, `read_image provider=auto` sends bounded image data to the loopback LocalLLM Chat Completions endpoint with `AGINTI_LOCALLLM_VISION_MODEL` or `localllm-vision-xl`. It does not try OpenAI or a wrapper after a local failure.
19
19
  - In an OpenAI session, `read_image provider=auto` uses OpenAI Responses vision. A non-OpenAI session must set `allowHostedImagePerception=true` before `provider=openai` is accepted. `OPENAI_API_KEY` by itself grants no permission.
20
20
  - Codex image reading requires both `provider=codex` and explicitly enabled wrapper tools. It is not an automatic fallback.
21
+ - Visual verification never creates a task-owned `artifacts/perception/` tree. Its workspace-relative Markdown copy stays under ignored `.aginti/` storage so a successful review cannot dirty an otherwise clean repository.
21
22
  - OpenAI image reading defaults to `AGINTI_PERCEPTION_MODEL=gpt-5.4-mini` and `AGINTI_PERCEPTION_REASONING=medium`, with fallback models remaining inside the same explicitly selected OpenAI provider.
22
23
  - `web_research` defaults to lightweight snippet mode. The active LocalLLM can synthesize those returned snippets and sources during the next agent step without a second provider call.
23
24
  - `web_search provider=auto` falls back from DuckDuckGo HTML to Bing RSS. `provider=multi` merges both indexes and adds Brave only when explicitly configured. Standard/deep research defaults to this ensemble; quick research stays on auto.
@@ -132,6 +132,10 @@ This starts interactive chat with Docker workspace mode, file tools, shell tools
132
132
 
133
133
  Docker workspace mode also mounts common host data roots, such as the user's home parent, read-only at their original absolute paths. This lets a task in `/home/lachlan/ProjectsLFS/aginti-test` inspect a sibling dataset such as `/home/lachlan/ProjectsLFS/ProteinStructure` without switching to host-write mode. Outputs and edits still belong under `/workspace` unless the user explicitly approves stronger host permissions. Set `AGINTI_DOCKER_READONLY_HOST_MOUNTS=/path/a:/path/b` to override the read-only mounts, or set it to `off` to disable them.
134
134
 
135
+ For a linked Git worktree, bounded Git mutation commands such as the runtime's path-enumerated `git add` plus `git commit` also receive a writable mount for the validated shared Git common directory. The mount is not added to ordinary shell commands or read-only Git inspection. This lets Docker workspace mode commit the current worktree without granting broad host-write access.
136
+
137
+ Outstanding exact project commands always run before Git completion. A pending producer, test, or user-declared validation command cannot be preempted by a commit merely because the current artifacts already exist.
138
+
135
139
  Direct trusted host mode:
136
140
 
137
141
  ```bash
@@ -56,6 +56,29 @@ in `evidence_json` and `events`.
56
56
 
57
57
  ## Current Campaign Findings
58
58
 
59
+ ### Retained dependencies, linked-worktree commits, and clean visual evidence
60
+
61
+ `data-analysis-retained-dependencies-060` resumed the original DeepSeek session
62
+ after an imperfect data-analysis repair had narrowed its context too far. The
63
+ agent retained the immutable configuration and CSV inputs referenced by the
64
+ mutable Python source, regenerated every required output, ran the canonical
65
+ producer and all four unit tests, inspected the 900 x 600 plot, and committed
66
+ target revision `55cadcd` from Docker workspace mode. Independent verification
67
+ confirmed 11 raw rows, one duplicate removal, two invalid-signal removals,
68
+ eight clean rows, the expected condition means, valid CSV/JSON/Markdown/PNG
69
+ artifacts, and a clean target worktree.
70
+
71
+ The run established three reusable runtime contracts:
72
+
73
+ - Failed-test recovery keeps bounded, previously read immutable dependencies
74
+ when the mutable source names them, while mutation scope remains limited to
75
+ the canonical repair paths.
76
+ - An outstanding exact producer or validation command runs before artifact Git
77
+ completion. In a linked worktree, only bounded Git mutation commands receive
78
+ a writable mount for the validated shared Git common directory.
79
+ - `read_image` stores its task-relative review copy under ignored `.aginti/`
80
+ runtime storage, so visual verification cannot dirty a completed repository.
81
+
59
82
  ### Data analysis retained-state recovery
60
83
 
61
84
  `data-analysis-local-011` passed after reusable runtime fixes. AgInTi completed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.299",
3
+ "version": "0.20.300",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
6
6
  "license": "Apache-2.0",
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { promisify } from "node:util";
9
9
  import {
10
+ dockerWritableGitMounts,
10
11
  dockerWorkspaceAliasMounts,
11
12
  ensureDockerGitIdentity,
12
13
  } from "../src/docker-sandbox.js";
@@ -52,6 +53,47 @@ try {
52
53
  assert.equal(storedEmail.stdout.trim(), "aginti-test@example.invalid");
53
54
  assert.equal((await fs.stat(identityConfig)).mode & 0o777, 0o600, "Docker author config should be private");
54
55
 
56
+ const worktreeRoot = path.join(tempRoot, "worktree-root");
57
+ const linkedWorktree = path.join(tempRoot, "linked-worktree");
58
+ await execFileAsync("git", ["init", worktreeRoot]);
59
+ await execFileAsync("git", ["-C", worktreeRoot, "config", "user.name", "AgInTi Test Author"]);
60
+ await execFileAsync("git", ["-C", worktreeRoot, "config", "user.email", "aginti-test@example.invalid"]);
61
+ await fs.writeFile(path.join(worktreeRoot, "seed.txt"), "seed\n", "utf8");
62
+ await execFileAsync("git", ["-C", worktreeRoot, "add", "seed.txt"]);
63
+ await execFileAsync("git", ["-C", worktreeRoot, "commit", "-m", "seed"]);
64
+ await execFileAsync("git", ["-C", worktreeRoot, "worktree", "add", "-b", "linked-test", linkedWorktree]);
65
+ const linkedCommonDir = (await execFileAsync(
66
+ "git",
67
+ ["-C", linkedWorktree, "rev-parse", "--path-format=absolute", "--git-common-dir"]
68
+ )).stdout.trim();
69
+ assert.deepEqual(
70
+ dockerWritableGitMounts(
71
+ "git add -- changed.txt && git commit -m 'linked worktree change'",
72
+ { commandCwd: linkedWorktree, sandboxMode: "docker-workspace" },
73
+ { gitOnly: true }
74
+ ),
75
+ [linkedCommonDir],
76
+ "Docker Git mutation did not mount validated linked-worktree metadata writable"
77
+ );
78
+ assert.deepEqual(
79
+ dockerWritableGitMounts(
80
+ "git status --short",
81
+ { commandCwd: linkedWorktree, sandboxMode: "docker-workspace" },
82
+ { gitOnly: true }
83
+ ),
84
+ [],
85
+ "read-only Git inspection received an unnecessary writable metadata mount"
86
+ );
87
+ assert.deepEqual(
88
+ dockerWritableGitMounts(
89
+ "git add -- seed.txt",
90
+ { commandCwd: worktreeRoot, sandboxMode: "docker-workspace" },
91
+ { gitOnly: true }
92
+ ),
93
+ [],
94
+ "ordinary repositories should rely on the existing writable workspace mount"
95
+ );
96
+
55
97
  const { stdout } = await execFileAsync(process.execPath, [path.join(repoRoot, "bin/aginti-cli.js"), "docker", "status", "--json", "--cwd", tempRoot], {
56
98
  cwd: tempRoot,
57
99
  env: {
@@ -572,6 +572,98 @@ try {
572
572
  duplicateFocus?.duplicateDeclarations?.[0]?.name === "status_service",
573
573
  "duplicate production declarations were not promoted into a focused failed-test repair"
574
574
  );
575
+ const dependencyRecoveryWorkspace = path.join(tempRoot, "dependency-recovery-workspace");
576
+ await fs.mkdir(path.join(dependencyRecoveryWorkspace, "config"), { recursive: true });
577
+ await fs.mkdir(path.join(dependencyRecoveryWorkspace, "raw"), { recursive: true });
578
+ await fs.mkdir(path.join(dependencyRecoveryWorkspace, "tests"), { recursive: true });
579
+ await fs.writeFile(
580
+ path.join(dependencyRecoveryWorkspace, "analysis.py"),
581
+ [
582
+ "from pathlib import Path",
583
+ "import json",
584
+ "",
585
+ "ROOT = Path(__file__).resolve().parent",
586
+ "config = json.loads((ROOT / 'config' / 'experiment.json').read_text())",
587
+ "for source_file in ('run_a.csv', 'run_b.csv'):",
588
+ " print((ROOT / 'raw' / source_file).read_text())",
589
+ "",
590
+ ].join("\n"),
591
+ "utf8"
592
+ );
593
+ await fs.writeFile(
594
+ path.join(dependencyRecoveryWorkspace, "tests", "test_analysis.py"),
595
+ "from analysis import config\n",
596
+ "utf8"
597
+ );
598
+ await fs.writeFile(
599
+ path.join(dependencyRecoveryWorkspace, "config", "experiment.json"),
600
+ '{"offset":10}\n',
601
+ "utf8"
602
+ );
603
+ await fs.writeFile(
604
+ path.join(dependencyRecoveryWorkspace, "raw", "run_a.csv"),
605
+ "sample,signal\nA,100\n",
606
+ "utf8"
607
+ );
608
+ await fs.writeFile(
609
+ path.join(dependencyRecoveryWorkspace, "raw", "run_b.csv"),
610
+ "sample,signal\nB,90\n",
611
+ "utf8"
612
+ );
613
+ await fs.writeFile(
614
+ path.join(dependencyRecoveryWorkspace, "raw", "unrelated.csv"),
615
+ "private,unrelated\nno,1\n",
616
+ "utf8"
617
+ );
618
+ const dependencyRecoveryState = {
619
+ meta: {
620
+ projectVerification: {
621
+ mutationRevision: 2,
622
+ lastMutation: {
623
+ revision: 2,
624
+ toolName: "apply_patch",
625
+ paths: ["analysis.py"],
626
+ },
627
+ discoveredTests: ["tests/test_analysis.py"],
628
+ testRuns: [{
629
+ command: "python analysis.py",
630
+ mutationRevision: 2,
631
+ passed: false,
632
+ failureEvidenceVersion: 2,
633
+ failureSignature: "source-dependency-schema-mismatch",
634
+ failureSummary: "File \"analysis.py\", line 7, in <module>: KeyError: signal_mv",
635
+ }],
636
+ },
637
+ toolLoop: {
638
+ recent: [
639
+ "config/experiment.json",
640
+ "raw/run_a.csv",
641
+ "raw/run_b.csv",
642
+ "raw/unrelated.csv",
643
+ ].map((readPath) => ({
644
+ toolName: "read_file",
645
+ path: readPath,
646
+ ok: true,
647
+ blocked: false,
648
+ })),
649
+ },
650
+ },
651
+ };
652
+ const dependencyRecoveryPacket = await buildFailedTestRecoveryPacket(
653
+ { commandCwd: dependencyRecoveryWorkspace },
654
+ dependencyRecoveryState
655
+ );
656
+ assert(
657
+ [
658
+ "analysis.py",
659
+ "config/experiment.json",
660
+ "raw/run_a.csv",
661
+ "raw/run_b.csv",
662
+ ].every((item) => dependencyRecoveryPacket.paths.includes(item)) &&
663
+ !dependencyRecoveryPacket.paths.includes("raw/unrelated.csv") &&
664
+ JSON.stringify(dependencyRecoveryPacket.repairPaths) === JSON.stringify(["analysis.py"]),
665
+ `failed-test recovery dropped previously read immutable dependencies or widened mutation scope: ${JSON.stringify(dependencyRecoveryPacket)}`
666
+ );
575
667
  const baselineWorkspace = path.join(tempRoot, "baseline-recovery-workspace");
576
668
  await fs.mkdir(baselineWorkspace, { recursive: true });
577
669
  const trackedBaselineSource = [
@@ -343,6 +343,10 @@ async function main() {
343
343
  assert(image.artifactPath, "read_image did not persist a perception artifact");
344
344
  assert(image.markdownArtifactPath, "read_image did not persist a Markdown perception artifact");
345
345
  assert(image.markdownPath, "read_image did not persist a workspace Markdown report");
346
+ assert(
347
+ image.markdownPath.startsWith(".aginti/artifacts/perception/"),
348
+ `read_image dirtied the task-owned artifact tree: ${image.markdownPath}`
349
+ );
346
350
  assert(
347
351
  path.basename(image.markdownPath).includes("tiny-image-analysis"),
348
352
  `read_image emitted a generic artifact filename: ${image.markdownPath}`
@@ -1584,6 +1584,30 @@ assertStrict.deepEqual(
1584
1584
  "task-owned Git completion delegated path selection back to the model"
1585
1585
  );
1586
1586
 
1587
+ const requiredCommandBeforeCommitTools = selectProgressiveTools(allTools, {
1588
+ config: {
1589
+ provider: "localllm",
1590
+ artifactValidationPhase: true,
1591
+ artifactValidationNeedsCommand: true,
1592
+ requiredProjectCommandPending: true,
1593
+ requiredProjectCommand: "python analysis.py",
1594
+ artifactValidationPendingGitActions: ["commit"],
1595
+ artifactValidationCommitPaths: ["analysis.py", "AGINTI.md"],
1596
+ },
1597
+ goal: "Regenerate the outputs, validate them, and commit the intended repair.",
1598
+ profile: "data",
1599
+ });
1600
+ sameNames(
1601
+ requiredCommandBeforeCommitTools,
1602
+ ["run_command", "finish"],
1603
+ "artifact Git completion preempted an outstanding exact project command"
1604
+ );
1605
+ assertStrict.deepEqual(
1606
+ requiredCommandBeforeCommitTools[0].function.parameters.properties.command.enum,
1607
+ ["python analysis.py"],
1608
+ "artifact recovery exposed an open-ended command instead of the retained requirement"
1609
+ );
1610
+
1587
1611
  const resumedArtifactRuntime = nextStepRuntimeConfig(
1588
1612
  {
1589
1613
  goal: "Perform a visual screenshot inspection of the generated plot and commit the intentional changes.",
@@ -8495,7 +8495,7 @@ export function taskForbidsExternalValidatorSourceRead(config = {}, state = {})
8495
8495
  function recoveryEvidenceDependencies(
8496
8496
  sourcePath = "",
8497
8497
  content = "",
8498
- { commandCwd = "" } = {}
8498
+ { commandCwd = "", knownReadPaths = [] } = {}
8499
8499
  ) {
8500
8500
  const explicitDependencies = [];
8501
8501
  const importCandidates = [];
@@ -8531,7 +8531,7 @@ function recoveryEvidenceDependencies(
8531
8531
  append(explicitDependencies, path.posix.normalize(raw));
8532
8532
  }
8533
8533
  for (const match of String(content || "").matchAll(
8534
- /^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\s*[A-Za-z_][A-Za-z0-9_]*(?<segments>(?:\s*\/\s*["'][^"'\r\n]+["']){1,8})\s*$/gm
8534
+ /\b[A-Za-z_][A-Za-z0-9_]*(?<segments>(?:\s*\/\s*["'][^"'\r\n]+["']){1,8})/g
8535
8535
  )) {
8536
8536
  const segments = [...String(match.groups?.segments || "").matchAll(/["']([^"'\r\n]+)["']/g)]
8537
8537
  .map((segment) => String(segment[1] || "").trim())
@@ -8540,6 +8540,22 @@ function recoveryEvidenceDependencies(
8540
8540
  append(explicitDependencies, path.posix.normalize(segments.join("/")));
8541
8541
  }
8542
8542
  }
8543
+ const sourceText = String(content || "");
8544
+ for (const item of Array.isArray(knownReadPaths) ? knownReadPaths : []) {
8545
+ const candidate = safeRecoveryEvidencePath(item);
8546
+ if (!candidate || safeTaskOwnedCommitPath(candidate) !== candidate) continue;
8547
+ const basename = path.posix.basename(candidate);
8548
+ const parentBasename = path.posix.basename(path.posix.dirname(candidate));
8549
+ if (!basename || !sourceText.includes(basename)) continue;
8550
+ if (
8551
+ parentBasename &&
8552
+ parentBasename !== "." &&
8553
+ !sourceText.includes(parentBasename)
8554
+ ) {
8555
+ continue;
8556
+ }
8557
+ append(explicitDependencies, candidate);
8558
+ }
8543
8559
  if (path.posix.extname(sourcePath).toLowerCase() === ".java") {
8544
8560
  const normalizedSource = String(sourcePath || "").replace(/\\/g, "/");
8545
8561
  const packageMatch = String(content || "").match(
@@ -8960,6 +8976,19 @@ export async function buildFailedTestRecoveryPacket(config = {}, state = {}) {
8960
8976
  state,
8961
8977
  testRun
8962
8978
  );
8979
+ const knownReadPaths = (Array.isArray(state.meta?.toolLoop?.recent)
8980
+ ? state.meta.toolLoop.recent
8981
+ : [])
8982
+ .filter(
8983
+ (entry) =>
8984
+ entry?.ok === true &&
8985
+ entry?.blocked !== true &&
8986
+ String(entry?.toolName || "") === "read_file"
8987
+ )
8988
+ .map((entry) => safeRecoveryEvidencePath(toolLoopReadEvidencePath(entry) || entry?.path))
8989
+ .filter((item) => item && safeTaskOwnedCommitPath(item) === item)
8990
+ .filter((item, index, items) => items.indexOf(item) === index)
8991
+ .slice(-24);
8963
8992
  const queue = [
8964
8993
  ...failureMentionedPaths.filter(failedTestPathIsTestEvidence),
8965
8994
  ...(Array.isArray(verification.discoveredTests) ? verification.discoveredTests : []),
@@ -9536,7 +9565,10 @@ export async function buildFailedTestRecoveryPacket(config = {}, state = {}) {
9536
9565
  );
9537
9566
  }
9538
9567
  }
9539
- for (const dependency of recoveryEvidenceDependencies(relativePath, raw, { commandCwd })) {
9568
+ for (const dependency of recoveryEvidenceDependencies(relativePath, raw, {
9569
+ commandCwd,
9570
+ knownReadPaths,
9571
+ })) {
9540
9572
  if (!seen.has(dependency) && !queue.includes(dependency)) queue.push(dependency);
9541
9573
  }
9542
9574
  }
@@ -196,6 +196,63 @@ export function dockerWorkspaceAliasMounts(
196
196
  return workspace === DOCKER_WORKSPACE ? [] : [workspace];
197
197
  }
198
198
 
199
+ function pathIsWithin(parentPath, candidatePath) {
200
+ const relative = path.relative(parentPath, candidatePath);
201
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
202
+ }
203
+
204
+ function linkedWorktreeCommonGitDir(workspace) {
205
+ const dotGitPath = path.join(workspace, ".git");
206
+ let dotGit;
207
+ try {
208
+ if (!fsSync.statSync(dotGitPath).isFile()) return "";
209
+ dotGit = fsSync.readFileSync(dotGitPath, "utf8");
210
+ } catch {
211
+ return "";
212
+ }
213
+ const match = String(dotGit).match(/^gitdir:\s*(.+?)\s*$/im);
214
+ if (!match?.[1]) return "";
215
+ const gitDir = path.resolve(workspace, match[1]);
216
+ let commonDir = gitDir;
217
+ try {
218
+ const commonPath = path.join(gitDir, "commondir");
219
+ if (fsSync.existsSync(commonPath)) {
220
+ commonDir = path.resolve(gitDir, fsSync.readFileSync(commonPath, "utf8").trim());
221
+ }
222
+ const resolvedGitDir = fsSync.realpathSync(gitDir);
223
+ const resolvedCommonDir = fsSync.realpathSync(commonDir);
224
+ if (
225
+ !pathIsWithin(resolvedCommonDir, resolvedGitDir) ||
226
+ !fsSync.existsSync(path.join(resolvedCommonDir, "HEAD")) ||
227
+ !fsSync.existsSync(path.join(resolvedCommonDir, "objects"))
228
+ ) {
229
+ return "";
230
+ }
231
+ return resolvedCommonDir;
232
+ } catch {
233
+ return "";
234
+ }
235
+ }
236
+
237
+ export function dockerWritableGitMounts(
238
+ command = "",
239
+ config = {},
240
+ policy = {},
241
+ sandboxMode = normalizeSandboxMode(config.sandboxMode)
242
+ ) {
243
+ if (
244
+ sandboxMode !== "docker-workspace" ||
245
+ policy.gitOnly !== true ||
246
+ !/\bgit\s+(?:add|commit|rm|mv)\b/i.test(String(command || ""))
247
+ ) {
248
+ return [];
249
+ }
250
+ const workspace = path.resolve(String(config.commandCwd || process.cwd()));
251
+ const commonDir = linkedWorktreeCommonGitDir(workspace);
252
+ if (!commonDir || pathIsWithin(workspace, commonDir)) return [];
253
+ return uniqueHostMounts([commonDir]);
254
+ }
255
+
199
256
  async function ensurePersistentDockerDirs(config) {
200
257
  const dirs = persistentDockerDirs(config);
201
258
  await Promise.all([fs.mkdir(dirs.home, { recursive: true }), fs.mkdir(dirs.cache, { recursive: true }), fs.mkdir(dirs.env, { recursive: true })]);
@@ -381,6 +438,12 @@ function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command,
381
438
  "-v",
382
439
  `${mount}:${mount}:${mountMode}`,
383
440
  ]);
441
+ const writableGitMountArgs = dockerWritableGitMounts(
442
+ command,
443
+ config,
444
+ policy,
445
+ sandboxMode
446
+ ).flatMap((mount) => ["-v", `${mount}:${mount}:rw`]);
384
447
  const readOnlyArgs =
385
448
  mountMode === "ro"
386
449
  ? ["--read-only", "--tmpfs", "/tmp:rw,nosuid,nodev,size=128m"]
@@ -412,6 +475,7 @@ function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command,
412
475
  ? ["-e", `GIT_CONFIG_GLOBAL=${DOCKER_GIT_CONFIG}`]
413
476
  : []),
414
477
  ...readOnlyHostMountArgs,
478
+ ...writableGitMountArgs,
415
479
  ...workspaceAliasMountArgs,
416
480
  "-v",
417
481
  `${config.commandCwd}:${DOCKER_WORKSPACE}:${mountMode}`,
@@ -302,7 +302,10 @@ async function persistMarkdownArtifact(store, config, subdir, stem, payload) {
302
302
  }
303
303
 
304
304
  if (config?.commandCwd) {
305
- const workspaceDir = path.join(config.commandCwd, "artifacts", subdir);
305
+ // Perception reports are runtime evidence, not task-owned source. Keep the
306
+ // workspace-relative copy under the standard ignored AgInTi directory so
307
+ // visual validation cannot dirty an otherwise completed repository.
308
+ const workspaceDir = path.join(config.commandCwd, ".aginti", "artifacts", subdir);
306
309
  await fs.mkdir(workspaceDir, { recursive: true });
307
310
  const workspacePath = path.join(workspaceDir, filename);
308
311
  await fs.writeFile(workspacePath, content, "utf8");
@@ -2304,7 +2304,8 @@ export function selectProgressiveTools(
2304
2304
  if (
2305
2305
  config.taskOwnedCommitPending === true &&
2306
2306
  config.testFailureRepairActive !== true &&
2307
- config.testVerificationPending !== true
2307
+ config.testVerificationPending !== true &&
2308
+ config.requiredProjectCommandPending !== true
2308
2309
  ) {
2309
2310
  const available = new Map(enabled.map(({ name, tool }) => [name, tool]));
2310
2311
  const pendingGitActions = Array.isArray(config.taskOwnedPendingGitActions)
@@ -2332,6 +2333,18 @@ export function selectProgressiveTools(
2332
2333
  config.testVerificationPending !== true
2333
2334
  ) {
2334
2335
  const available = new Map(enabled.map(({ name, tool }) => [name, tool]));
2336
+ if (
2337
+ config.requiredProjectCommandPending === true &&
2338
+ typeof config.requiredProjectCommand === "string" &&
2339
+ config.requiredProjectCommand.trim()
2340
+ ) {
2341
+ const requiredCommand = constrainRunCommand(
2342
+ available.get("run_command"),
2343
+ config.requiredProjectCommand,
2344
+ "Run this exact outstanding project command before Git completion or final artifact acceptance. Preserve the generated outputs and do not substitute another status, commit, or discovery command."
2345
+ );
2346
+ return [requiredCommand, finish].filter(Boolean);
2347
+ }
2335
2348
  const pendingGitActions = Array.isArray(config.artifactValidationPendingGitActions)
2336
2349
  ? config.artifactValidationPendingGitActions.map((item) => String(item || "").toLowerCase())
2337
2350
  : [];