@lazyingart/agintiflow 0.20.213 → 0.20.215

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.
@@ -130,3 +130,59 @@ checker exactly once, performed no file, canvas, commit, or push side effect,
130
130
  and completed with a clean repository-state check. The full npm suite and the
131
131
  focused dynamic-budget, SCS/model-role, and web-canvas regressions pass for
132
132
  AgInTiFlow `0.20.213`.
133
+
134
+ ### Java repair, permission pause, and durable artifact evidence
135
+
136
+ `java-event-window-013` passed after exposing two runtime defects with a normal,
137
+ underspecified Java repair prompt. DeepSeek correctly implemented decimal
138
+ duration parsing, non-mutating percentile interpolation, deterministic window
139
+ summaries, project guidance, and generated-output ignores. The first run then
140
+ encountered a host permission blocker while invoking the checked-in test script.
141
+ The runtime continued spending model and SCS turns instead of persisting a
142
+ single actionable pause. After trusted-host approval, loose artifact inference
143
+ also treated a generated test transcript as a mandatory deliverable, prompting
144
+ the agent to create and commit an unrequested `docs/test-results.txt`.
145
+
146
+ Permission advice that cannot auto-recover now stops the run immediately with
147
+ durable resume data. Approval is single-use, and a resolved blocker cannot be
148
+ replayed by a stale web request. Same-task continuation still preserves the
149
+ original goal when an approval sentence precedes the continuation instruction.
150
+ Artifact evidence from commands is accepted only when it names a supported,
151
+ existing, nonempty path; removed files and label-only prose no longer satisfy
152
+ completion. Exclusion language such as "ignore generated build and session
153
+ outputs" no longer invents an artifact requirement.
154
+
155
+ The queued correction was applied to the same live session, the stray transcript
156
+ commit was removed, and the intended repair remains at target commit `f2792f3`.
157
+ Independent verification passed the checked-in Java test script, the hidden
158
+ event-window contract, generated-output tracking checks, clean-worktree checks,
159
+ the focused permission/evidence regressions, and the complete AgInTiFlow npm
160
+ suite.
161
+
162
+ ### Long-context memo synthesis and local visual verification
163
+
164
+ `memo-full-context-pdf-014` used a normal, short writing prompt against a
165
+ realistic interrupted chat export containing corrections, cancellations,
166
+ deadlines, dependencies, completed work, publication boundaries, research
167
+ ideas, and personal errands. The DeepSeek-backed writing agent read the full
168
+ history and produced an editable two-page XeLaTeX/PDF memo instead of copying
169
+ transport rows. Independent acceptance checked every critical commitment,
170
+ rejected raw timestamps and internal identifiers, verified the PDF structure,
171
+ and required a clean intentional commit.
172
+
173
+ The first draft generalized a concrete Nutstore private-backup destination and
174
+ left generated agent/perception directories unignored. The same durable session
175
+ accepted those exact external findings, restored the actionable destination,
176
+ added narrow generated-state ignores, rebuilt the PDF, inspected both rendered
177
+ pages, and committed the correction as `a275878`. The hidden full-context
178
+ checker, `qpdf`, `pdfinfo`, extracted-text review, independent page renders, and
179
+ clean-worktree check then passed.
180
+
181
+ The visual-review turn exposed a reusable provider-boundary defect: automatic
182
+ `read_image` was blocked merely because DeepSeek was the active reasoning
183
+ provider, even though the configured local image-perception handoff was safe
184
+ and the perception runtime already supported it. Guardrails now allow the
185
+ DeepSeek-to-LocalLLM handoff when local perception is enabled, continue to block
186
+ hosted vision without explicit authorization, and block all automatic vision
187
+ when both local and hosted routes are disabled. A focused regression covers the
188
+ guard and the actual LocalLLM client route.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.213",
3
+ "version": "0.20.215",
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",
@@ -196,6 +196,74 @@ try {
196
196
  sourceRepairContract.requiredEvidence.some((item) => item.category === "file"),
197
197
  "an explicit source repair lost its file-change evidence gate"
198
198
  );
199
+ const ignoredGeneratedOutputsContract = deriveScsTaskContract({
200
+ goal: [
201
+ "You have explicit trusted-host approval for this isolated Java fixture.",
202
+ "Continue the same task from the current edits. Run the checked-in project test script, repair any failures,",
203
+ "create the required project guidance, ignore generated build and session outputs, commit only intentional work,",
204
+ "and finish with verified evidence.",
205
+ ].join(" "),
206
+ taskProfile: "java",
207
+ });
208
+ assert(
209
+ ignoredGeneratedOutputsContract.requiredEvidence.some((item) => item.category === "file") &&
210
+ ignoredGeneratedOutputsContract.requiredEvidence.some((item) => item.category === "command") &&
211
+ ignoredGeneratedOutputsContract.requiredEvidence.some((item) => item.category === "git") &&
212
+ !ignoredGeneratedOutputsContract.requiredEvidence.some((item) => item.category === "artifact"),
213
+ "ignoring generated build/session outputs invented a standalone artifact requirement"
214
+ );
215
+ const durableArtifactPath = path.join(workspace, "reports", "durable-report.pdf");
216
+ await fs.mkdir(path.dirname(durableArtifactPath), { recursive: true });
217
+ await fs.writeFile(durableArtifactPath, "%PDF-1.4\nsmoke\n", "utf8");
218
+ const artifactEvents = [
219
+ {
220
+ type: "tool.completed",
221
+ data: {
222
+ ok: true,
223
+ toolName: "run_command",
224
+ args: { command: "printf smoke > reports/durable-report.pdf" },
225
+ stdout: "created reports/durable-report.pdf",
226
+ exitCode: 0,
227
+ },
228
+ },
229
+ ];
230
+ const durableArtifactLedger = buildScsEvidenceLedger({
231
+ context: { events: artifactEvents, commandCwd: workspace },
232
+ });
233
+ assert(
234
+ durableArtifactLedger.categories.includes("artifact"),
235
+ "an existing shell-generated PDF did not count as durable artifact evidence"
236
+ );
237
+ await fs.rm(durableArtifactPath);
238
+ const removedArtifactLedger = buildScsEvidenceLedger({
239
+ context: { events: artifactEvents, commandCwd: workspace },
240
+ });
241
+ assert(
242
+ !removedArtifactLedger.categories.includes("artifact") &&
243
+ removedArtifactLedger.items.some((item) => item.category === "artifact" && item.verified === false),
244
+ "a removed artifact continued to satisfy the final evidence ledger"
245
+ );
246
+ const labelOnlyArtifactLedger = buildScsEvidenceLedger({
247
+ context: {
248
+ events: [
249
+ {
250
+ type: "tool.completed",
251
+ data: {
252
+ ok: true,
253
+ toolName: "run_command",
254
+ args: { command: "echo ARTIFACT READY" },
255
+ stdout: "ARTIFACT READY",
256
+ exitCode: 0,
257
+ },
258
+ },
259
+ ],
260
+ commandCwd: workspace,
261
+ },
262
+ });
263
+ assert(
264
+ !labelOnlyArtifactLedger.categories.includes("artifact"),
265
+ "an artifact label in generic shell text counted as a durable artifact"
266
+ );
199
267
  assert(
200
268
  completionEvidenceNeedsCommand({ missingProjectCommands: ["python analysis.py"] }),
201
269
  "a pending canonical command did not reopen command execution"
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
3
5
  import path from "node:path";
4
6
  import { fileURLToPath } from "node:url";
5
7
  import {
@@ -1086,18 +1088,27 @@ assert(
1086
1088
  !missingWriterToolEval.ok && missingWriterToolEval.missingToolCalls.includes("writing_specialist"),
1087
1089
  "SCS should reject finish when required specialist call is missing"
1088
1090
  );
1089
- const presentWriterToolEval = evaluateScsEvidence(
1090
- requiredWriterToolContract,
1091
- buildScsEvidenceLedger({
1092
- context: {
1093
- events: [
1094
- { type: "file.changed", data: { path: "final/story.md" } },
1095
- { type: "tool.completed", data: { toolName: "writing_specialist", ok: true, artifactPath: "artifacts/writer.json" } },
1096
- ],
1097
- },
1098
- })
1099
- );
1100
- assert(presentWriterToolEval.ok, "SCS should accept required specialist call when tool evidence is present");
1091
+ const writerEvidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "aginti-writer-evidence-"));
1092
+ try {
1093
+ const writerArtifactPath = path.join(writerEvidenceRoot, "artifacts", "writer.json");
1094
+ fs.mkdirSync(path.dirname(writerArtifactPath), { recursive: true });
1095
+ fs.writeFileSync(writerArtifactPath, JSON.stringify({ status: "completed" }), "utf8");
1096
+ const presentWriterToolEval = evaluateScsEvidence(
1097
+ requiredWriterToolContract,
1098
+ buildScsEvidenceLedger({
1099
+ context: {
1100
+ commandCwd: writerEvidenceRoot,
1101
+ events: [
1102
+ { type: "file.changed", data: { path: "final/story.md" } },
1103
+ { type: "tool.completed", data: { toolName: "writing_specialist", ok: true, artifactPath: writerArtifactPath } },
1104
+ ],
1105
+ },
1106
+ })
1107
+ );
1108
+ assert(presentWriterToolEval.ok, "SCS should accept required specialist call when tool evidence is present");
1109
+ } finally {
1110
+ fs.rmSync(writerEvidenceRoot, { recursive: true, force: true });
1111
+ }
1101
1112
 
1102
1113
  const blockedFileFinish = await reviewScsFinish(
1103
1114
  { mock: true },
@@ -83,6 +83,36 @@ async function verifyProviderBoundary({ pngPath, store, config }) {
83
83
  config: { ...config, provider: "localllm", allowHostedImagePerception: false },
84
84
  });
85
85
  assert(!guardedImage.allowed, "guardrails allowed unapproved hosted image perception");
86
+ const guardedDeepSeekLocalHandoff = checkToolUse({
87
+ toolName: "read_image",
88
+ args: { path: path.relative(config.commandCwd, pngPath) },
89
+ snapshot: { elements: [] },
90
+ config: {
91
+ ...config,
92
+ provider: "deepseek",
93
+ allowLocalImagePerception: true,
94
+ allowHostedImagePerception: false,
95
+ },
96
+ });
97
+ assert(
98
+ guardedDeepSeekLocalHandoff.allowed,
99
+ "guardrails blocked the safe DeepSeek-to-LocalLLM image handoff"
100
+ );
101
+ const blockedDeepSeekWithoutHandoff = checkToolUse({
102
+ toolName: "read_image",
103
+ args: { path: path.relative(config.commandCwd, pngPath) },
104
+ snapshot: { elements: [] },
105
+ config: {
106
+ ...config,
107
+ provider: "deepseek",
108
+ allowLocalImagePerception: false,
109
+ allowHostedImagePerception: false,
110
+ },
111
+ });
112
+ assert(
113
+ !blockedDeepSeekWithoutHandoff.allowed,
114
+ "guardrails allowed auto image perception after every backend handoff was disabled"
115
+ );
86
116
  const guardedResearch = checkToolUse({
87
117
  toolName: "web_research",
88
118
  args: { query: "provider boundary smoke", mode: "openai" },
@@ -152,38 +152,50 @@ const finishDecision = await reviewScsFinish(
152
152
  assert.equal(finishDecision.decision, "finish_allowed", "SCS should not reject finish as no-evidence when the contract ledger is satisfied");
153
153
  assert.match(finishDecision.reason, /Overrode a no-evidence finish rejection/);
154
154
 
155
- const researchReportLedger = buildScsEvidenceLedger({
156
- context: {
157
- events: [{
158
- type: "tool.completed",
159
- data: {
160
- ok: true,
161
- toolName: "deep_research",
162
- status: "completed",
163
- reportPath: "/workspace/reports/cited-research.md",
164
- artifactPath: "/session/artifacts/deep-research.json",
165
- coverage: {
166
- verifiedClaimCount: 12,
167
- quoteVerificationRate: 1,
168
- },
169
- audit: {
170
- citationCoverage: 1,
171
- unknownEvidenceIds: [],
155
+ const researchEvidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "aginti-research-evidence-"));
156
+ try {
157
+ const reportPath = path.join(researchEvidenceRoot, "reports", "cited-research.md");
158
+ const artifactPath = path.join(researchEvidenceRoot, "artifacts", "deep-research.json");
159
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
160
+ fs.mkdirSync(path.dirname(artifactPath), { recursive: true });
161
+ fs.writeFileSync(reportPath, "# Cited research\n\nVerified report fixture.\n", "utf8");
162
+ fs.writeFileSync(artifactPath, JSON.stringify({ status: "completed" }), "utf8");
163
+
164
+ const researchReportLedger = buildScsEvidenceLedger({
165
+ context: {
166
+ events: [{
167
+ type: "tool.completed",
168
+ data: {
169
+ ok: true,
170
+ toolName: "deep_research",
171
+ status: "completed",
172
+ reportPath,
173
+ artifactPath,
174
+ coverage: {
175
+ verifiedClaimCount: 12,
176
+ quoteVerificationRate: 1,
177
+ },
178
+ audit: {
179
+ citationCoverage: 1,
180
+ unknownEvidenceIds: [],
181
+ },
172
182
  },
173
- },
174
- }],
175
- },
176
- });
177
- assert(
178
- ["file", "command", "artifact"].every((category) => researchReportLedger.categories.includes(category)),
179
- "a completed and audited deep-research report did not satisfy file, validation-command, and artifact evidence categories"
180
- );
181
- assert(
182
- researchReportLedger.items.some(
183
- (item) => item.category === "command" && /deterministic audit completed/.test(item.proof)
184
- ),
185
- "deep-research validation evidence did not preserve its deterministic audit provenance"
186
- );
183
+ }],
184
+ },
185
+ });
186
+ assert(
187
+ ["file", "command", "artifact"].every((category) => researchReportLedger.categories.includes(category)),
188
+ "a completed and audited deep-research report did not satisfy file, validation-command, and artifact evidence categories"
189
+ );
190
+ assert(
191
+ researchReportLedger.items.some(
192
+ (item) => item.category === "command" && /deterministic audit completed/.test(item.proof)
193
+ ),
194
+ "deep-research validation evidence did not preserve its deterministic audit provenance"
195
+ );
196
+ } finally {
197
+ fs.rmSync(researchEvidenceRoot, { recursive: true, force: true });
198
+ }
187
199
 
188
200
  const recoverableLedger = buildScsEvidenceLedger({
189
201
  context: {
@@ -233,6 +233,29 @@ try {
233
233
  "same-task resume used the new-request boundary marker"
234
234
  );
235
235
 
236
+ const prefixedSameTaskContinuation = await runCase({
237
+ id: "ordinary-explanation",
238
+ goal: [
239
+ "You have explicit trusted-host approval for this isolated fixture.",
240
+ "Continue the same task from the current edits. Finish it with verified evidence.",
241
+ ].join(" "),
242
+ resume: true,
243
+ responses: [assistant("A base case remains the condition that terminates recursive expansion.")],
244
+ });
245
+ const prefixedContinuationEvent = [...prefixedSameTaskContinuation.events]
246
+ .reverse()
247
+ .find((event) => event.type === "conversation.continued");
248
+ assert.equal(
249
+ prefixedContinuationEvent?.data?.preservesTaskBoundary,
250
+ true,
251
+ "a same-task continuation prefixed by a permission statement opened a new task boundary"
252
+ );
253
+ assert.equal(
254
+ prefixedSameTaskContinuation.state.meta?.goalContract?.taskGoal,
255
+ "Explain why recursion needs a base case.",
256
+ "a prefixed same-task continuation replaced the durable task goal"
257
+ );
258
+
236
259
  const quotedChatClassification = await runCase({
237
260
  id: "quoted-chat-classification",
238
261
  taskProfile: "chatops",
@@ -266,6 +289,36 @@ try {
266
289
  assert.equal(proseOnlyAction.events.filter((event) => event.type === "completion.evidence_rejected").length, 2);
267
290
  assert(!proseOnlyAction.events.some((event) => event.type === "session.finished"));
268
291
 
292
+ const permissionPause = await runCase({
293
+ id: "permission-pause",
294
+ goal: "Run the checked-in project test script and report its verified result.",
295
+ taskProfile: "java",
296
+ allowShellTool: true,
297
+ scsActive: true,
298
+ setup: async (workspace) => {
299
+ await fs.mkdir(path.join(workspace, "scripts"), { recursive: true });
300
+ await fs.writeFile(path.join(workspace, "scripts", "test.sh"), "#!/usr/bin/env bash\necho pass\n", "utf8");
301
+ },
302
+ responses: [
303
+ assistant("", [toolCall("permission-test", "run_command", { command: "bash scripts/test.sh" })]),
304
+ ],
305
+ });
306
+ assert.equal(permissionPause.calls.length, 1, "permission blocker consumed another model turn");
307
+ assert.equal(permissionPause.result.stopped, true);
308
+ assert.equal(permissionPause.result.reason, "permission_required");
309
+ assert(permissionPause.result.permissionAdvice?.suggestedCommand, "permission pause lost its exact resume command");
310
+ assert.equal(
311
+ permissionPause.events.filter((event) => event.type === "session.stopped" && event.data?.reason === "permission_required").length,
312
+ 1,
313
+ "permission blocker did not persist exactly one paused state"
314
+ );
315
+ assert(
316
+ !permissionPause.events.some((event) =>
317
+ ["scs.student.rethink_plan", "scs.student.reject_phase", "scs.committee.replan_drafted"].includes(event.type)
318
+ ),
319
+ "permission blocker triggered an SCS replan instead of waiting for approval"
320
+ );
321
+
269
322
  const reasoningTruncation = await runCase({
270
323
  id: "reasoning-only-tool-continuation",
271
324
  goal: "Run pwd and report the verified working directory.",
@@ -371,12 +424,12 @@ try {
371
424
 
372
425
  const approvalNarrativeWithBlockerEvidence = await runCase({
373
426
  id: "approval-narrative-with-blocker-evidence",
374
- goal: "Run definitely_missing_aginti_command and report the result.",
427
+ goal: "Run which definitely_missing_aginti_command and report the result.",
375
428
  taskProfile: "shell",
376
429
  allowShellTool: true,
377
430
  responses: [
378
431
  assistant("", [
379
- toolCall("missing-command", "run_command", { command: "definitely_missing_aginti_command" }),
432
+ toolCall("missing-command", "run_command", { command: "which definitely_missing_aginti_command" }),
380
433
  ]),
381
434
  assistant("The command is unavailable. Approve installing it and I will continue after approval."),
382
435
  assistant("Unable to execute the requested command because it is not installed in this environment."),
@@ -695,11 +748,11 @@ try {
695
748
 
696
749
  const verifiedBlocker = await runCase({
697
750
  id: "verified-blocker",
698
- goal: "Execute the shell command definitely_not_an_aginti_command and report the result.",
751
+ goal: "Run which definitely_not_an_aginti_command and report the result.",
699
752
  taskProfile: "shell",
700
753
  allowShellTool: true,
701
754
  responses: [
702
- assistant("", [toolCall("run-blocked", "run_command", { command: "definitely_not_an_aginti_command" })]),
755
+ assistant("", [toolCall("run-blocked", "run_command", { command: "which definitely_not_an_aginti_command" })]),
703
756
  assistant("", [
704
757
  toolCall("finish-blocked", "finish", {
705
758
  result: "Unable to execute the requested command because it is not installed in this environment.",
@@ -64,11 +64,12 @@ async function waitForHealth() {
64
64
  throw new Error(`web server did not become healthy. stdout=${stdout.slice(-500)} stderr=${stderr.slice(-500)}`);
65
65
  }
66
66
 
67
- async function waitForRun(sessionId) {
67
+ async function waitForRun(sessionId, terminalStatuses = ["finished", "failed"]) {
68
+ const acceptedStatuses = new Set(terminalStatuses);
68
69
  const deadline = Date.now() + 20000;
69
70
  while (Date.now() < deadline) {
70
71
  const run = await fetchJson(`/api/runs/${encodeURIComponent(sessionId)}`);
71
- if (run.status === "finished" || run.status === "failed") return run;
72
+ if (acceptedStatuses.has(run.status)) return run;
72
73
  await delay(400);
73
74
  }
74
75
  throw new Error(`run ${sessionId} did not finish in time`);
@@ -548,7 +549,10 @@ try {
548
549
  headless: true,
549
550
  }),
550
551
  });
551
- const approvalRaceBlocked = await waitForRun(approvalRaceStart.sessionId);
552
+ const approvalRaceBlocked = await waitForRun(approvalRaceStart.sessionId, ["stopped", "failed"]);
553
+ if (approvalRaceBlocked.status !== "stopped") {
554
+ throw new Error(`permission/message race fixture did not stop for approval: ${approvalRaceBlocked.status}`);
555
+ }
552
556
  if (!approvalRaceBlocked.logs?.some((entry) => entry.message === "tool.blocked" && entry.data?.permissionAdvice)) {
553
557
  throw new Error("permission/message race fixture did not produce pending permission advice");
554
558
  }
@@ -595,7 +599,10 @@ try {
595
599
  headless: true,
596
600
  }),
597
601
  });
598
- const safeRun = await waitForRun(safeRunStart.sessionId);
602
+ const safeRun = await waitForRun(safeRunStart.sessionId, ["stopped", "failed"]);
603
+ if (safeRun.status !== "stopped") {
604
+ throw new Error(`safe mode web run did not stop for approval: ${safeRun.status}`);
605
+ }
599
606
  if (!safeRun.logs?.some((entry) => entry.message === "tool.blocked" && entry.data?.permissionAdvice?.category === "workspace-write")) {
600
607
  throw new Error("safe mode web run did not expose workspace-write permission advice");
601
608
  }
@@ -630,6 +637,14 @@ try {
630
637
  if (!safeApproved.includes("Created by AgInTiFlow mock mode.")) {
631
638
  throw new Error("permission-approved continuation did not create the requested file");
632
639
  }
640
+ const staleApproval = await fetch(`${baseUrl}/api/sessions/${encodeURIComponent(safeRunStart.sessionId)}/approve-permission`, {
641
+ method: "POST",
642
+ headers: { "Content-Type": "application/json" },
643
+ body: JSON.stringify({ action: "once" }),
644
+ });
645
+ if (staleApproval.status !== 404) {
646
+ throw new Error(`resolved permission advice remained reusable: ${staleApproval.status}`);
647
+ }
633
648
  await fetchJson("/api/preferences", {
634
649
  method: "POST",
635
650
  headers: { "Content-Type": "application/json" },
@@ -2216,7 +2216,7 @@ function isGenericTaskContinuationText(value = "") {
2216
2216
  const normalized = String(value || "").replace(/\s+/g, " ").trim();
2217
2217
  if (!normalized || normalized.length > 600) return false;
2218
2218
  const explicitSameTaskContinuation =
2219
- /^(?:(?:please|kindly)\s+)?(?:continue|resume|keep\s+working|finish|complete)\b.{0,180}\b(?:same|current|previous|existing|retained|saved|unfinished)\b.{0,80}\b(?:task|work|run|session|job|state)\b/i.test(normalized);
2219
+ /(?:^|[.!?]\s+)(?:(?:please|kindly)\s+)?(?:continue|resume|keep\s+working|finish|complete)\b.{0,180}\b(?:same|current|previous|existing|retained|saved|unfinished)\b.{0,80}\b(?:task|work|run|session|job|state)\b/i.test(normalized);
2220
2220
  return explicitSameTaskContinuation || /^(?:(?:please|kindly)\s+)?(?:continue|resume|finish|complete|keep\s+working)(?:\s+(?:and\s+)?(?:continue|finish|complete|working))?(?:\s+(?:the\s+)?(?:same|current|previous|existing|retained|saved|unfinished)\s+(?:task|work|run|session|job))?(?:\s+from\s+(?:the\s+)?(?:retained|saved|current|previous)\s+state)?[.!?]*$/i.test(normalized) ||
2221
2221
  /^(?:请)?(?:继续|接着|恢复|完成)(?:之前|上次|当前|同一|这个)?(?:的)?(?:任务|工作|会话|进度)?(?:并完成)?[。!?.!?]*$/u.test(normalized) ||
2222
2222
  /^(?:このまま|前回から|保存した状態から)?(?:同じ|現在の|前の)?(?:タスク|作業|セッション)?(?:を)?(?:続けて|再開して|完了して)(?:ください)?[。!?.!?]*$/u.test(normalized);
@@ -2907,6 +2907,14 @@ export function shouldShortCircuitToolBatch(toolResult) {
2907
2907
  );
2908
2908
  }
2909
2909
 
2910
+ export function shouldPauseForPermissionAdvice(toolResult = {}) {
2911
+ return Boolean(
2912
+ toolResult?.blocked &&
2913
+ toolResult?.permissionAdvice &&
2914
+ toolResult.permissionAdvice.autoRecover !== true
2915
+ );
2916
+ }
2917
+
2910
2918
  export function skippedAfterBlockedToolResult(toolCall, blockedResult) {
2911
2919
  const toolName = toolCall?.function?.name || "unknown";
2912
2920
  const args = sanitizeToolArgs(toolName, safeParseToolArgs(toolCall));
@@ -6441,6 +6449,51 @@ async function stopForMissingCompletionEvidence({ config, state, store, observer
6441
6449
  };
6442
6450
  }
6443
6451
 
6452
+ async function stopForPermissionAdvice({ config, state, store, observers, sessionId, step, toolResult }) {
6453
+ const advice = toolResult?.permissionAdvice && typeof toolResult.permissionAdvice === "object"
6454
+ ? toolResult.permissionAdvice
6455
+ : {};
6456
+ const result = [
6457
+ advice.summary || toolResult?.reason || "The requested action needs a stronger permission mode.",
6458
+ advice.instruction || "Resume after approving the required mode or choose a safer alternative.",
6459
+ advice.suggestedCommand ? `Contained resume: ${advice.suggestedCommand}` : "",
6460
+ advice.trustedHostCommand ? `Trusted-host resume: ${advice.trustedHostCommand}` : "",
6461
+ ].filter(Boolean).join("\n");
6462
+ const detail = {
6463
+ step,
6464
+ toolName: toolResult?.toolName || "",
6465
+ category: toolResult?.category || advice.category || "permission-required",
6466
+ reason: toolResult?.reason || advice.reason || "",
6467
+ permissionAdvice: advice,
6468
+ };
6469
+ state.stepsCompleted = step;
6470
+ state.updatedAt = new Date().toISOString();
6471
+ state.meta = state.meta || {};
6472
+ state.meta.pendingPermissionAdvice = detail;
6473
+ updateGoalStatus(state, "paused", "permission_required", state.updatedAt);
6474
+ await store.appendEvent("session.stopped", {
6475
+ reason: "permission_required",
6476
+ step,
6477
+ detail,
6478
+ });
6479
+ observers.event("session.stopped", {
6480
+ reason: "permission_required",
6481
+ sessionId,
6482
+ toolName: detail.toolName,
6483
+ category: detail.category,
6484
+ });
6485
+ await store.saveState(state);
6486
+ emitConsole(config, result, { kind: "error", error: true });
6487
+ return {
6488
+ sessionId,
6489
+ result,
6490
+ stopped: true,
6491
+ reason: "permission_required",
6492
+ permissionAdvice: advice,
6493
+ ...goalRunMetadata(state),
6494
+ };
6495
+ }
6496
+
6444
6497
  export function resetPerTurnToolContractState(state = {}, at = new Date().toISOString()) {
6445
6498
  const prior = state.meta?.toolContractViolation;
6446
6499
  if (!prior) return null;
@@ -8040,6 +8093,7 @@ export async function runAgent(config) {
8040
8093
 
8041
8094
  let continueForQueuedInput = false;
8042
8095
  let continueForCompletionRepair = false;
8096
+ let pendingPermissionPause = null;
8043
8097
  const postBatchToolResults = [];
8044
8098
  for (let toolIndex = 0; toolIndex < toolCalls.length; toolIndex += 1) {
8045
8099
  const toolCall = toolCalls[toolIndex];
@@ -8128,6 +8182,7 @@ export async function runAgent(config) {
8128
8182
  priorBlockedTool: skippedResult.priorBlockedTool,
8129
8183
  });
8130
8184
  }
8185
+ if (shouldPauseForPermissionAdvice(toolResult)) pendingPermissionPause = toolResult;
8131
8186
  break;
8132
8187
  }
8133
8188
 
@@ -8256,6 +8311,18 @@ export async function runAgent(config) {
8256
8311
  }
8257
8312
  }
8258
8313
 
8314
+ if (pendingPermissionPause) {
8315
+ return await stopForPermissionAdvice({
8316
+ config,
8317
+ state,
8318
+ store,
8319
+ observers,
8320
+ sessionId,
8321
+ step,
8322
+ toolResult: pendingPermissionPause,
8323
+ });
8324
+ }
8325
+
8259
8326
  if (continueForCompletionRepair) continue;
8260
8327
 
8261
8328
  for (const toolResult of postBatchToolResults) {
package/src/guardrails.js CHANGED
@@ -230,11 +230,12 @@ export function checkToolUse({ toolName, args, snapshot, config }) {
230
230
  !args.dryRun &&
231
231
  ["", "auto", "default"].includes(provider) &&
232
232
  !["localllm", "openai"].includes(activeProvider) &&
233
+ config.allowLocalImagePerception === false &&
233
234
  config.allowHostedImagePerception !== true
234
235
  ) {
235
236
  return {
236
237
  allowed: false,
237
- reason: `No automatic image-reading backend is enabled for active provider ${activeProvider}. Select localllm or explicitly enable a hosted image backend.`,
238
+ reason: `No automatic image-reading backend is enabled for active provider ${activeProvider}. Enable the local image-perception handoff, select localllm, or explicitly enable a hosted image backend.`,
238
239
  category: "perception-tools",
239
240
  };
240
241
  }
@@ -85,7 +85,11 @@ function mockToolCall(name, args = {}) {
85
85
 
86
86
  function latestToolPayload(messages) {
87
87
  for (const message of [...messages].reverse()) {
88
- if (message.role === "user" && /^Continue with this new request:|^Goal:/i.test(String(message.content || ""))) {
88
+ const userContent = String(message.content || "");
89
+ if (
90
+ message.role === "user" &&
91
+ /^(?:Continue with this new request:|Continue the current task from saved state:|Goal:)/i.test(userContent)
92
+ ) {
89
93
  return null;
90
94
  }
91
95
  if (message.role !== "tool" || !message.content) continue;
@@ -580,6 +580,10 @@ function inferRequirementCategories(goal = "", taskProfile = "", acceptanceCrite
580
580
  /\b(?:clean(?:\s+up)?|remove|delete|clear|purge)\b[^.\n;]{0,120}\b(?:generated|temporary|stale|test)?\s*(?:test\s+)?(?:debris|caches?|byproducts?)\b/gi,
581
581
  ""
582
582
  )
583
+ .replace(
584
+ /\b(?:ignore|exclude|omit|skip|leave\s+out)\b[^.\n;]{0,160}\b(?:generated|temporary|stale|build|session)\b[^.\n;]{0,100}\b(?:outputs?|artifacts?|files?|directories?|folders?)\b/gi,
585
+ ""
586
+ )
583
587
  .replace(/\bfigure\s+out\b/gi, "");
584
588
  const mandatoryEvidenceText = artifactSignalText.replace(
585
589
  /[^.\n]{0,240}\b(?:as appropriate|if appropriate|when useful|where applicable)\b/gi,
@@ -2172,6 +2176,7 @@ function eventToEvidence(event = {}) {
2172
2176
  target: data.path || data.artifactId || data.outputPath || "",
2173
2177
  proof: type,
2174
2178
  verified: true,
2179
+ virtualArtifact: Boolean(data.artifactId && !data.path && !data.outputPath),
2175
2180
  });
2176
2181
  if (type === "image.generated") {
2177
2182
  evidence.push({
@@ -2280,16 +2285,31 @@ function toolPayloadToEvidence(payload = {}, source = "tool") {
2280
2285
  if (["open_url", "click", "type", "scroll", "press", "back"].includes(toolName) || /\b(browser|chrome|cdp|playwright|selenium|upload|attach|submit|click|tab|page)\b/.test(text)) {
2281
2286
  push("browser", `${toolName || "browser tool"} affected or inspected browser/UI state`, payload.url || args.url || args.command || "");
2282
2287
  }
2283
- if (
2284
- ["open_workspace_file", "preview_workspace", "send_to_canvas", "generate_image", "read_image", "writing_specialist", "json_specialist", "json_specialist_batch"].includes(
2285
- toolName
2286
- ) ||
2287
- payload.artifactId ||
2288
- payload.outputPath ||
2289
- payload.artifactPath ||
2290
- /\b(pdf|png|jpg|jpeg|image|video|screenshot|artifact|preview)\b/.test(text)
2291
- ) {
2292
- push("artifact", `${toolName || "tool"} produced or inspected an artifact`, payload.path || payload.outputPath || payload.artifactPath || args.path || "");
2288
+ const artifactTools = new Set([
2289
+ "open_workspace_file",
2290
+ "preview_workspace",
2291
+ "send_to_canvas",
2292
+ "generate_image",
2293
+ "read_image",
2294
+ "writing_specialist",
2295
+ "json_specialist",
2296
+ "json_specialist_batch",
2297
+ ]);
2298
+ const artifactPath = firstArtifactPath(
2299
+ payload.artifactPath,
2300
+ payload.outputPath,
2301
+ payload.reportPath,
2302
+ payload.path,
2303
+ args.path,
2304
+ text
2305
+ );
2306
+ if (artifactTools.has(toolName) || payload.artifactId || artifactPath) {
2307
+ push(
2308
+ "artifact",
2309
+ `${toolName || "tool"} produced or inspected an artifact`,
2310
+ artifactPath || payload.artifactId || "",
2311
+ { virtualArtifact: Boolean(payload.artifactId && !artifactPath) }
2312
+ );
2293
2313
  }
2294
2314
  if (["read_image", "generate_image"].includes(toolName) || /\b(screenshot|visible|thumbnail|preview|image)\b/.test(text)) {
2295
2315
  push("visual", `${toolName || "tool"} supplied visual evidence`, payload.path || payload.outputPath || args.path || "");
@@ -2313,6 +2333,59 @@ function toolPayloadToEvidence(payload = {}, source = "tool") {
2313
2333
  return evidence;
2314
2334
  }
2315
2335
 
2336
+ const ARTIFACT_EXTENSION_PATTERN = /\.(?:md|json|csv|txt|html?|tex|pdf|docx|pptx|xlsx|png|jpe?g|webp|svg|mp4|mov|mkv|webm|wav|mp3|flac|zip|7z|tar|gz|step|stp|stl|3mf)$/i;
2337
+ const ARTIFACT_PATH_PATTERN = /(?:^|[\s"'`(=])([^\s"'`()=]+\.(?:md|json|csv|txt|html?|tex|pdf|docx|pptx|xlsx|png|jpe?g|webp|svg|mp4|mov|mkv|webm|wav|mp3|flac|zip|7z|tar|gz|step|stp|stl|3mf))(?:$|[\s"'`),;:])/i;
2338
+
2339
+ function firstArtifactPath(...values) {
2340
+ for (const value of values) {
2341
+ const candidate = String(value || "").trim();
2342
+ if (!candidate) continue;
2343
+ if (!/\s/.test(candidate) && ARTIFACT_EXTENSION_PATTERN.test(candidate)) {
2344
+ return candidate;
2345
+ }
2346
+ const match = candidate.match(ARTIFACT_PATH_PATTERN);
2347
+ if (match?.[1]) return match[1];
2348
+ }
2349
+ return "";
2350
+ }
2351
+
2352
+ function revalidateArtifactEvidence(item = {}, state = {}, context = {}) {
2353
+ if (item?.category !== "artifact" || item?.verified === false || item?.virtualArtifact === true) return item;
2354
+ const candidate = firstArtifactPath(item.target, item.proof);
2355
+ if (!candidate) {
2356
+ return item.toolName === "run_command"
2357
+ ? {
2358
+ ...item,
2359
+ verified: false,
2360
+ proof: `${item.proof || "artifact evidence"}; no durable artifact path was reported`,
2361
+ }
2362
+ : item;
2363
+ }
2364
+ const commandCwd = String(
2365
+ context.commandCwd ||
2366
+ state.commandCwd ||
2367
+ state.meta?.runtimeConfig?.commandCwd ||
2368
+ process.cwd()
2369
+ );
2370
+ const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(commandCwd, candidate);
2371
+ let durable = false;
2372
+ try {
2373
+ const stat = fs.statSync(resolved);
2374
+ durable = stat.isDirectory() || (stat.isFile() && stat.size > 0);
2375
+ } catch {
2376
+ durable = false;
2377
+ }
2378
+ return {
2379
+ ...item,
2380
+ target: candidate,
2381
+ resolvedTarget: resolved,
2382
+ verified: durable,
2383
+ proof: durable
2384
+ ? item.proof
2385
+ : `${item.proof || "artifact evidence"}; artifact path no longer exists or is empty`,
2386
+ };
2387
+ }
2388
+
2316
2389
  function messageToEvidence(message = {}) {
2317
2390
  if (message.role !== "tool") return [];
2318
2391
  try {
@@ -2327,9 +2400,12 @@ export function buildScsEvidenceLedger({ state = {}, context = {} } = {}) {
2327
2400
  const messages = Array.isArray(state.messages) ? state.messages : [];
2328
2401
  const eventEvidence = events.flatMap(eventToEvidence);
2329
2402
  const messageEvidence = messages.flatMap(messageToEvidence);
2330
- const items = [...eventEvidence, ...messageEvidence].slice(-80);
2331
- const categories = unique(items.map((item) => item.category));
2332
- const toolNames = unique(items.map((item) => item.toolName).filter(Boolean));
2403
+ const items = [...eventEvidence, ...messageEvidence]
2404
+ .slice(-80)
2405
+ .map((item) => revalidateArtifactEvidence(item, state, context));
2406
+ const verifiedItems = items.filter((item) => item?.verified !== false);
2407
+ const categories = unique(verifiedItems.map((item) => item.category));
2408
+ const toolNames = unique(verifiedItems.map((item) => item.toolName).filter(Boolean));
2333
2409
  const blockers = [...events.map(eventToBlocker), ...messages.map(messageToBlocker)]
2334
2410
  .filter(Boolean)
2335
2411
  .slice(-20)
package/web.js CHANGED
@@ -1367,6 +1367,17 @@ async function ensureNotRunning(sessionId) {
1367
1367
  }
1368
1368
 
1369
1369
  async function latestPermissionAdvice(sessionId) {
1370
+ const events = await sessionStore(sessionId).loadEvents().catch(() => []);
1371
+ for (const event of [...events].reverse()) {
1372
+ if (["permission.approval_granted", "permission.approval_declined"].includes(event.type)) return null;
1373
+ if (event.type === "tool.blocked" && event.data?.permissionAdvice) {
1374
+ return {
1375
+ ...event.data.permissionAdvice,
1376
+ category: event.data.permissionAdvice.category || event.data.category || "",
1377
+ };
1378
+ }
1379
+ }
1380
+
1370
1381
  const inMemory = runs.get(sessionId);
1371
1382
  const memoryEntry = [...(inMemory?.logs || [])]
1372
1383
  .reverse()
@@ -1378,13 +1389,7 @@ async function latestPermissionAdvice(sessionId) {
1378
1389
  };
1379
1390
  }
1380
1391
 
1381
- const events = await sessionStore(sessionId).loadEvents().catch(() => []);
1382
- const event = [...events].reverse().find((candidate) => candidate.type === "tool.blocked" && candidate.data?.permissionAdvice);
1383
- if (!event?.data?.permissionAdvice) return null;
1384
- return {
1385
- ...event.data.permissionAdvice,
1386
- category: event.data.permissionAdvice.category || event.data.category || "",
1387
- };
1392
+ return null;
1388
1393
  }
1389
1394
 
1390
1395
  function permissionApprovalPrompt(action, advice = {}, originalGoal = "") {
@@ -2421,6 +2426,12 @@ app.post("/api/sessions/:sessionId/approve-permission", async (req, res) => {
2421
2426
  source: "web",
2422
2427
  category: advice.category || "",
2423
2428
  });
2429
+ const state = await store.loadState();
2430
+ if (state?.meta?.pendingPermissionAdvice) {
2431
+ delete state.meta.pendingPermissionAdvice;
2432
+ state.updatedAt = new Date().toISOString();
2433
+ await store.saveState(state);
2434
+ }
2424
2435
  const existing = runs.get(sessionId);
2425
2436
  if (existing) {
2426
2437
  existing.logs.push({
@@ -2473,6 +2484,11 @@ app.post("/api/sessions/:sessionId/approve-permission", async (req, res) => {
2473
2484
  category: advice.category || "",
2474
2485
  permissionMode: targetMode,
2475
2486
  });
2487
+ if (state.meta?.pendingPermissionAdvice) {
2488
+ delete state.meta.pendingPermissionAdvice;
2489
+ state.updatedAt = new Date().toISOString();
2490
+ await store.saveState(state);
2491
+ }
2476
2492
 
2477
2493
  const stored = await loadStoredRun(sessionId);
2478
2494
  if (runs.get(sessionId)?.status === "running") {