@tea-agent/loop-agent 0.27.1 → 0.28.1-beta.1

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/application/task-lifecycle/observe.js +5 -0
  3. package/dist/application/task-lifecycle/plan-transitions.js +7 -2
  4. package/dist/cli/program.js +1 -1
  5. package/dist/commands/client-recovery.js +439 -20
  6. package/dist/commands/init.js +42 -6
  7. package/dist/executors/dag-pi-executor.js +161 -60
  8. package/dist/executors/pi-playwright-cli-tool.js +955 -0
  9. package/dist/executors/pi-sdk-executor.js +56 -0
  10. package/dist/executors/playwright-cli-launcher.js +63 -0
  11. package/dist/executors/shell-executor.js +128 -0
  12. package/dist/shared/playwright-cli-command-policy.js +41 -0
  13. package/dist/task/task-demand-routing.js +1 -15
  14. package/dist/worker/observability/read-model.js +66 -8
  15. package/dist/worker/observe/static/dag-model.js +85 -13
  16. package/dist/workflows/dag/dynamic-runtime/loop-until.js +4 -0
  17. package/dist/workflows/dag/dynamic-runtime/map.js +13 -13
  18. package/dist/workflows/dag/frontend-implementation-contract.js +124 -6
  19. package/dist/workflows/dag/frontend-prewrite-gate.js +22 -8
  20. package/dist/workflows/dag/frontend-test-case-checklist.js +201 -8
  21. package/dist/workflows/dag/frontend-test-result-contract.js +52 -3
  22. package/dist/workflows/dag/init-hybrid.js +181 -68
  23. package/dist/workflows/dag/lifecycle.js +33 -2
  24. package/dist/workflows/dag/node-execution.js +11 -5
  25. package/dist/workflows/dag/output-protocol.js +48 -83
  26. package/dist/workflows/dag/report.js +9 -2
  27. package/dist/workflows/dag/rerun-run.js +62 -3
  28. package/dist/workflows/dag/run-store.js +6 -1
  29. package/dist/workflows/dag/runner.js +15 -3
  30. package/dist/workflows/dag/types.js +27 -0
  31. package/dist/workflows/dag/validate.js +121 -1
  32. package/docs/architecture/runtime-boundaries.md +13 -11
  33. package/docs/init-surface.manifest.json +6 -2
  34. package/docs/templates/README.md +9 -1
  35. package/docs/templates/frontend-implementation-contract.schema.json +2 -2
  36. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +14 -7
  37. package/docs/templates/frontend-test-dag.json +55 -15
  38. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -9
  39. package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
  40. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  41. package/docs/templates/frontend-test-dag.review-execution.prompt.md +1 -1
  42. package/harness.json +1 -1
  43. package/package.json +1 -1
  44. package/skills/loop-agent/SKILL.md +1 -1
  45. package/skills/loop-agent/references/command-reference.md +18 -6
  46. package/skills/playwright-cli/SKILL.md +69 -402
  47. package/skills/playwright-cli/references/tracing.md +3 -137
  48. package/skills/playwright-cli/references/video-recording.md +3 -141
  49. package/skills/playwright-cli-case-generator/SKILL.md +53 -46
@@ -62,126 +62,68 @@ export function firstNonEmptyLine(text) {
62
62
  }
63
63
  return undefined;
64
64
  }
65
- function extractSingleJsonObjectText(text) {
65
+ /**
66
+ * Parse the JSON-only review verdict protocol. The complete trimmed output
67
+ * must be exactly one JSON object: fences, prose, partial objects, and
68
+ * concatenated objects are all rejected before schema validation.
69
+ */
70
+ export function parseJsonReviewVerdict(text) {
66
71
  const trimmed = String(text).trim();
67
72
  if (!trimmed)
68
73
  return { ok: false, reason: "missing JSON output" };
69
- const extractBalancedObject = (source) => {
70
- for (let start = 0; start < source.length; start += 1) {
71
- if (source[start] !== "{")
72
- continue;
73
- let depth = 0;
74
- let inString = false;
75
- let escaped = false;
76
- for (let index = start; index < source.length; index += 1) {
77
- const char = source[index];
78
- if (inString) {
79
- if (escaped)
80
- escaped = false;
81
- else if (char === "\\")
82
- escaped = true;
83
- else if (char === '"')
84
- inString = false;
85
- continue;
86
- }
87
- if (char === '"')
88
- inString = true;
89
- else if (char === "{")
90
- depth += 1;
91
- else if (char === "}" && --depth === 0) {
92
- const candidate = source.slice(start, index + 1);
93
- try {
94
- const parsed = JSON.parse(candidate);
95
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
96
- return candidate;
97
- }
98
- catch {
99
- // Continue scanning for the next complete object.
100
- }
101
- break;
102
- }
103
- }
104
- }
105
- return undefined;
106
- };
107
- const direct = extractBalancedObject(trimmed);
108
- if (direct)
109
- return { ok: true, jsonText: direct };
110
- const fencedMatches = Array.from(trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)\s*```/gi));
111
- if (fencedMatches.length === 1) {
112
- const jsonText = fencedMatches[0][1].trim();
113
- const fencedObject = extractBalancedObject(jsonText);
114
- if (fencedObject)
115
- return { ok: true, jsonText: fencedObject };
116
- return {
117
- ok: false,
118
- reason: "single fenced block is not a JSON object",
119
- };
120
- }
121
- if (fencedMatches.length > 1) {
122
- return {
123
- ok: false,
124
- reason: "multiple fenced JSON candidates found; expected exactly one JSON object",
125
- };
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(trimmed);
126
77
  }
127
- return {
128
- ok: false,
129
- reason: "output is not a single JSON object; expected only JSON with no Markdown or prose",
130
- };
131
- }
132
- function validateJsonReviewVerdict(text) {
133
- const extracted = extractSingleJsonObjectText(text);
134
- if (!extracted.ok) {
78
+ catch (error) {
135
79
  return {
136
80
  ok: false,
137
- failureCategory: "protocol-invalid",
138
- reason: extracted.reason,
139
- firstNonEmptyLine: firstNonEmptyLine(text),
81
+ reason: `output must be exactly one JSON object: ${error instanceof Error ? error.message : String(error)}`,
140
82
  };
141
83
  }
142
- let parsed;
143
- try {
144
- parsed = JSON.parse(extracted.jsonText);
145
- }
146
- catch (error) {
84
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
147
85
  return {
148
86
  ok: false,
149
- failureCategory: "protocol-invalid",
150
- reason: `invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
151
- firstNonEmptyLine: firstNonEmptyLine(text),
87
+ reason: "output must be exactly one JSON object",
152
88
  };
153
89
  }
154
90
  const checked = reviewJsonVerdictSchema.safeParse(parsed);
155
91
  if (!checked.success) {
156
92
  return {
157
93
  ok: false,
158
- failureCategory: "protocol-invalid",
159
94
  reason: `JSON review verdict schema violation: ${checked.error.issues
160
95
  .map((issue) => `${issue.path.join(".") || "<root>"} ${issue.message}`)
161
96
  .join("; ")}`,
162
- firstNonEmptyLine: firstNonEmptyLine(text),
163
97
  };
164
98
  }
165
99
  const blockingFindings = checked.data.findings.filter((finding) => finding.severity === "Critical" || finding.severity === "Important");
166
100
  if (checked.data.verdict === "pass" && blockingFindings.length > 0) {
167
101
  return {
168
102
  ok: false,
169
- failureCategory: "protocol-invalid",
170
103
  reason: "JSON review verdict cannot be pass when Critical or Important findings are present",
171
- firstNonEmptyLine: firstNonEmptyLine(text),
172
104
  };
173
105
  }
174
106
  if (checked.data.verdict === "request-revision" &&
175
107
  checked.data.findings.length === 0) {
176
108
  return {
177
109
  ok: false,
178
- failureCategory: "protocol-invalid",
179
110
  reason: "JSON review verdict request-revision requires at least one finding",
180
- firstNonEmptyLine: firstNonEmptyLine(text),
181
111
  };
182
112
  }
183
113
  return { ok: true, verdict: checked.data.verdict };
184
114
  }
115
+ function validateJsonReviewVerdict(text) {
116
+ const parsed = parseJsonReviewVerdict(text);
117
+ if (!parsed.ok) {
118
+ return {
119
+ ok: false,
120
+ failureCategory: "protocol-invalid",
121
+ reason: parsed.reason,
122
+ firstNonEmptyLine: firstNonEmptyLine(text),
123
+ };
124
+ }
125
+ return { ok: true, verdict: parsed.verdict };
126
+ }
185
127
  /**
186
128
  * Validate node output against an explicit outputProtocol.
187
129
  * Pure function — does not mutate run facts.
@@ -206,6 +148,29 @@ export function validateOutputProtocol(protocol, text) {
206
148
  reason: `missing first non-empty line; expected one of: ${protocol.validLines.map((l) => JSON.stringify(l)).join(" or ")}`,
207
149
  };
208
150
  }
151
+ const isReviewVerdict = protocol.validLines.length === 2 && protocol.validLines.includes("VERDICT: pass") && protocol.validLines.includes("VERDICT: request-revision");
152
+ // Models frequently prepend a short explanation despite the protocol
153
+ // instruction. Recover only when there is exactly one unambiguous protocol
154
+ // line; conflicting or repeated verdicts remain fail-closed.
155
+ const candidates = isReviewVerdict ? text
156
+ .split("\n")
157
+ .map((line) => normalizeVerdictCandidateLine(line.trim()))
158
+ .filter((line) => protocol.validLines.includes(line)) : [];
159
+ const uniqueCandidates = [...new Set(candidates)];
160
+ if (uniqueCandidates.length > 1) {
161
+ return {
162
+ ok: false,
163
+ failureCategory: "protocol-invalid",
164
+ reason: `conflicting protocol lines found: ${uniqueCandidates.map((line) => JSON.stringify(line)).join(" and ")}`,
165
+ firstNonEmptyLine: first,
166
+ };
167
+ }
168
+ if (protocol.validLines.includes(first)) {
169
+ return { ok: true, matchedLine: first };
170
+ }
171
+ if (uniqueCandidates.length === 1) {
172
+ return { ok: true, matchedLine: uniqueCandidates[0] };
173
+ }
209
174
  if (!protocol.validLines.includes(first)) {
210
175
  return {
211
176
  ok: false,
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { resolveCliPath } from "../../shared/path-refs.js";
5
5
  import { z } from "zod";
6
6
  import { repairArtifactSchema } from "./repair-artifact.js";
7
- import { dagRunDirExists, getDagRunDir, locateDagRun, readDagRunSpec, readDagRunState, } from "./lifecycle.js";
7
+ import { dagRunDirExists, getDagRunDir, isDagExecutionLifecycle, locateDagRun, readDagRunSpec, readDagRunState, } from "./lifecycle.js";
8
8
  export const DAG_CLOSEOUT_DRAFT_DISCLAIMER = "> **Advisory only.** Derived from completed run facts. Canonical source remains `dag report --json` and `.harness/dag-runs/completed/<run-id>/`. Do not treat this draft as authoritative.";
9
9
  import { dagNormalizedFailureCategorySchema, normalizeDagFailureCategory, } from "./failure-category.js";
10
10
  import { dagProductLineFailureCategoryValues, routeDagFailure, } from "./failure-routing.js";
@@ -659,7 +659,14 @@ async function locateRunForReport(repoRoot, runId, filter) {
659
659
  if (!located) {
660
660
  throw new Error(`dag run not found: ${runId}`);
661
661
  }
662
- return { ...located, runId };
662
+ if (!isDagExecutionLifecycle(located.lifecycle)) {
663
+ throw new Error(`dag run not found: ${runId}`);
664
+ }
665
+ return {
666
+ lifecycle: located.lifecycle,
667
+ runDir: located.runDir,
668
+ runId,
669
+ };
663
670
  }
664
671
  const runDir = getDagRunDir(repoRoot, filter, runId);
665
672
  if (!(await dagRunDirExists(runDir))) {
@@ -4,9 +4,9 @@ import path from "node:path";
4
4
  import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
5
5
  import { resolveRunningControllerIdentity } from "../../shared/package-metadata.js";
6
6
  import { readControllerIdentityArtifact, captureControllerIdentity } from "./controller-identity.js";
7
- import { DAG_RUNS_DIR, getDagRunDir, locateDagRun, readDagRunSpec, readDagRunState, } from "./lifecycle.js";
7
+ import { DAG_RUNS_DIR, getDagRunDir, isDagNonExecutionLifecycle, locateDagRun, readDagRunSpec, readDagRunState, } from "./lifecycle.js";
8
8
  import { buildDagRunId, runDagContinuation, } from "./runner.js";
9
- import { evaluateDagRerunPlan, evaluateLiveDagRerunBindings, } from "./rerun-plan.js";
9
+ import { computePlanHash, evaluateDagRerunPlan, evaluateLiveDagRerunBindings, } from "./rerun-plan.js";
10
10
  import { prepareActiveRunDir, writeNodeRecord, writeRunSpec, writeRunState, } from "./run-store.js";
11
11
  import { cloneParentSkillSnapshotForContinuation, readSkillSnapshot, } from "./skill-snapshot.js";
12
12
  import { topoSortToRanks } from "./topo.js";
@@ -123,6 +123,65 @@ export async function planDagRerun(input) {
123
123
  const nonCompletedSpec = await readDagRunSpec(located.runDir);
124
124
  const nonCompletedState = await readDagRunState(located.runDir);
125
125
  const nonCompletedWorkerHints = await detectWorkerManagedRun(located.runDir, nonCompletedState, nonCompletedSpec);
126
+ // evaluateDagRerunPlan only accepts execution lifecycles. Audit parents must
127
+ // not be cast to "active" (reconcile) or "paused" (resume). Also avoid routing
128
+ // audit through evaluate: missing workspace checkpoint would surface as
129
+ // standalone-task-rerun instead of the required fail-closed manual action.
130
+ if (isDagNonExecutionLifecycle(located.lifecycle)) {
131
+ const bindingStatus = await evaluateLiveDagRerunBindings({
132
+ cwd: input.cwd,
133
+ parentSpec: nonCompletedSpec,
134
+ });
135
+ const workerAssociation = nonCompletedWorkerHints.workerManaged
136
+ ? {
137
+ kind: "worker-managed",
138
+ ...(nonCompletedWorkerHints.featureId
139
+ ? { featureId: nonCompletedWorkerHints.featureId }
140
+ : {}),
141
+ ...(nonCompletedWorkerHints.taskId
142
+ ? { taskId: nonCompletedWorkerHints.taskId }
143
+ : {}),
144
+ ...(nonCompletedWorkerHints.workerRunId
145
+ ? { workerRunId: nonCompletedWorkerHints.workerRunId }
146
+ : {}),
147
+ }
148
+ : { kind: "standalone" };
149
+ const planWithoutHash = {
150
+ schemaVersion: 1,
151
+ eligible: false,
152
+ parentRunId: input.parentRunId,
153
+ parentLifecycle: located.lifecycle,
154
+ selectedNodeId: input.selectedNodeId,
155
+ rewriteApplied: false,
156
+ resetNodeIds: [],
157
+ importedNodeIds: [],
158
+ blockingNodes: [],
159
+ reasonCodes: ["parent-lifecycle-ineligible"],
160
+ blockedReasons: ["parent-lifecycle-ineligible"],
161
+ risk: "high",
162
+ bindings: {
163
+ sourceBindingMatched: bindingStatus.sourceBindingMatched,
164
+ taskContractBindingMatched: bindingStatus.taskContractBindingMatched,
165
+ controllerIdentityMatched: true,
166
+ skillSnapshotVerified: true,
167
+ workspaceMatched: false,
168
+ },
169
+ workerAssociation,
170
+ estimatedExecution: {
171
+ nodeCount: 0,
172
+ piCallCount: 0,
173
+ shellNodeCount: 0,
174
+ avoidedNodeCount: 0,
175
+ avoidedPiCallCount: 0,
176
+ },
177
+ suggestedAction: "manual",
178
+ };
179
+ return {
180
+ ...planWithoutHash,
181
+ planHash: computePlanHash(planWithoutHash),
182
+ };
183
+ }
184
+ const parentLifecycle = located.lifecycle === "active" ? "active" : "paused";
126
185
  return evaluateDagRerunPlan({
127
186
  cwd: input.cwd,
128
187
  parentRunId: input.parentRunId,
@@ -130,7 +189,7 @@ export async function planDagRerun(input) {
130
189
  parentRunDir: located.runDir,
131
190
  parentSpec: nonCompletedSpec,
132
191
  parentState: nonCompletedState,
133
- parentLifecycle: located.lifecycle,
192
+ parentLifecycle,
134
193
  workerManaged: nonCompletedWorkerHints.workerManaged,
135
194
  workerAssociation: nonCompletedWorkerHints.workerManaged
136
195
  ? {
@@ -11,10 +11,15 @@ export async function writeRunState(runDir, state, options) {
11
11
  export async function readRunSpec(runDir) {
12
12
  return readDagRunSpec(runDir);
13
13
  }
14
- export async function prepareActiveRunDir(runDir) {
14
+ /** Prepare any lifecycle run directory (active / dry-run / init-only / …). */
15
+ export async function prepareRunDir(runDir) {
15
16
  await rm(runDir, { recursive: true, force: true });
16
17
  await mkdir(runDir, { recursive: true });
17
18
  }
19
+ /** @deprecated Prefer prepareRunDir; kept as thin alias for execute callers. */
20
+ export async function prepareActiveRunDir(runDir) {
21
+ await prepareRunDir(runDir);
22
+ }
18
23
  export async function writeRunSpec(runDir, spec) {
19
24
  await writeJsonAtomic(path.join(runDir, "run.json"), spec);
20
25
  }
@@ -7,7 +7,7 @@ import { readCandidateRecord } from "../../infrastructure/evaluation/candidate-s
7
7
  import { CANONICAL_TASK_ID_PATTERN, formatLocalCompactDate, } from "../../task/runtime.js";
8
8
  import { assertFrozenBudget, initRunBudgetLedger, preflightBudgetOrBreach, recordFinishedNodeBudget, writeBudgetLedgerArtifacts, } from "./budget-enforcement.js";
9
9
  import { getDagRunDir, isTerminalDagRunStatus, locateDagRun, readHumanApprovalArtifact, requireActiveDagRun, } from "./lifecycle.js";
10
- import { moveToCompletedRunDir, moveToPausedRunDir, prepareActiveRunDir, writeRunSpec, writeRunState, } from "./run-store.js";
10
+ import { moveToCompletedRunDir, moveToPausedRunDir, prepareRunDir, writeRunSpec, writeRunState, } from "./run-store.js";
11
11
  import { evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
12
12
  import { createDagNodeExecutor } from "./executor-registry.js";
13
13
  import { executeDagPiNode } from "../../executors/dag-pi-executor.js";
@@ -126,12 +126,18 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
126
126
  : {}),
127
127
  };
128
128
  }
129
+ const executionMode = opts.dryRun
130
+ ? "dry-run"
131
+ : opts.initOnly
132
+ ? "init-only"
133
+ : "execute";
129
134
  const state = {
130
135
  version: 1,
131
136
  title: spec.title,
132
137
  runId,
133
138
  cwd: opts.cwd,
134
139
  startedAt: new Date().toISOString(),
140
+ executionMode,
135
141
  runner: {
136
142
  pid: process.pid,
137
143
  hostname: hostname(),
@@ -212,8 +218,14 @@ export async function runDag(spec, opts) {
212
218
  const activeRunDir = getDagRunDir(opts.cwd, "active", state.runId);
213
219
  const completedRunDir = getDagRunDir(opts.cwd, "completed", state.runId);
214
220
  const pausedRunDir = getDagRunDir(opts.cwd, "paused", state.runId);
215
- const runDir = activeRunDir;
216
- await prepareActiveRunDir(runDir);
221
+ // R-A2: non-execution modes use dedicated lifecycles; only execute enters active/.
222
+ const lifecycleDir = state.executionMode === "dry-run"
223
+ ? "dry-run"
224
+ : state.executionMode === "init-only"
225
+ ? "init-only"
226
+ : "active";
227
+ const runDir = getDagRunDir(opts.cwd, lifecycleDir, state.runId);
228
+ await prepareRunDir(runDir);
217
229
  await writeRunSpec(runDir, spec);
218
230
  // Freeze the running controller identity as a run-owned fact before the
219
231
  // first canonical state write, reusing the identity resolved for preflight.
@@ -10,6 +10,27 @@ export const dagNodeExecutorSchema = z.enum(["pi", "shell", "static"]);
10
10
  export const CURSOR_DAG_EXECUTOR_REMOVED_ERROR = 'executor "cursor" is no longer supported; regenerate the DAG with Pi-only writers (implement-pi / repair-pi)';
11
11
  export const CURSOR_EXECUTOR_MODELS_REMOVED_ERROR = "executorModels.cursor is no longer supported; use executorModels.pi only";
12
12
  export const dagToolProfileSchema = z.enum(["read-only", "write"]);
13
+ /** Static command capabilities only; tasks cannot inject executables or shell prefixes. */
14
+ export const dagCommandCapabilitySchema = z.enum(["playwright-cli"]);
15
+ export const dagCommandPolicySchema = z.discriminatedUnion("mode", [
16
+ z.object({ mode: z.literal("deny") }).strict(),
17
+ z
18
+ .object({
19
+ mode: z.literal("capability-allowlist"),
20
+ capabilities: z.array(dagCommandCapabilitySchema).min(1),
21
+ })
22
+ .strict(),
23
+ ]);
24
+ /** Undefined / omitted commandPolicy is treated as deny by executors and validators. */
25
+ export function resolveDagCommandPolicy(policy) {
26
+ return policy ?? { mode: "deny" };
27
+ }
28
+ export function dagCommandPolicyAllows(policy, capability) {
29
+ const resolved = resolveDagCommandPolicy(policy);
30
+ if (resolved.mode !== "capability-allowlist")
31
+ return false;
32
+ return resolved.capabilities.includes(capability);
33
+ }
13
34
  export const dagOutputModeSchema = z.enum(["default", "structured-required"]);
14
35
  export const dagShellPresetSchema = z.enum(["loop-agent-standard-verify"]);
15
36
  export const dagVerifyQuotaSchema = z.enum(["1", "3", "full"]);
@@ -319,6 +340,7 @@ export const dagBackendTestPipelineSchema = z.enum([
319
340
  "markdown-execute-html",
320
341
  "markdown-manifest",
321
342
  ]);
343
+ export const dagFrontendBrowserToolPreflightSchema = z.object({}).strict();
322
344
  export const dagFrontendTestEvidenceValidationSchema = z.object({}).strict();
323
345
  export const dagFrontendTestCaseChecklistSchema = z.object({}).strict();
324
346
  export const dagFrontendTestHtmlReportSchema = z.object({}).strict();
@@ -369,6 +391,7 @@ export const dagShellConfigSchema = z.object({
369
391
  frontendVerificationBundle: dagFrontendVerificationBundleSchema.optional(),
370
392
  frontendReviewContext: dagFrontendReviewContextSchema.optional(),
371
393
  frontendTestCaseChecklist: dagFrontendTestCaseChecklistSchema.optional(),
394
+ frontendBrowserToolPreflight: dagFrontendBrowserToolPreflightSchema.optional(),
372
395
  frontendTestEvidenceValidation: dagFrontendTestEvidenceValidationSchema.optional(),
373
396
  frontendTestL5Report: z.object({}).strict().optional(),
374
397
  frontendTestHtmlReport: dagFrontendTestHtmlReportSchema.optional(),
@@ -416,6 +439,7 @@ export const dagDynamicExpansionChildTaskSchema = z.object({
416
439
  outputContract: z.string().optional(),
417
440
  staticResultTemplate: z.string().optional(),
418
441
  toolProfile: dagToolProfileSchema.optional(),
442
+ commandPolicy: dagCommandPolicySchema.optional(),
419
443
  writePolicy: dagWritePolicySchema.optional(),
420
444
  allowedPaths: z.array(z.string()).optional().default([]),
421
445
  forbiddenPaths: z.array(z.string()).optional().default([]),
@@ -487,6 +511,7 @@ export const dagDynamicLoopBodyTaskSchema = z.object({
487
511
  outputContract: z.string().optional(),
488
512
  staticResultTemplate: z.string().optional(),
489
513
  toolProfile: dagToolProfileSchema.optional(),
514
+ commandPolicy: dagCommandPolicySchema.optional(),
490
515
  writePolicy: dagWritePolicySchema.optional(),
491
516
  allowedPaths: z.array(z.string()).optional().default([]),
492
517
  forbiddenPaths: z.array(z.string()).optional().default([]),
@@ -558,6 +583,8 @@ export const dagTaskSchema = z.object({
558
583
  role: dagRoleSchema.optional(),
559
584
  skills: z.array(z.string()).optional(),
560
585
  toolProfile: dagToolProfileSchema.optional(),
586
+ /** Default deny: file write (toolProfile=write) does not grant command execution. */
587
+ commandPolicy: dagCommandPolicySchema.optional(),
561
588
  writePolicy: dagWritePolicySchema.optional(),
562
589
  writeSet: z.array(z.string()).optional(),
563
590
  piStep: z.string().optional(),
@@ -1,4 +1,4 @@
1
- import { DEFAULT_DAG_EXECUTOR_MODELS, ENV_VAR_NAME_PATTERN, } from "./types.js";
1
+ import { DEFAULT_DAG_EXECUTOR_MODELS, ENV_VAR_NAME_PATTERN, dagCommandPolicyAllows, resolveDagCommandPolicy, } from "./types.js";
2
2
  import { resolveShellCommands } from "../../executors/shell-executor.js";
3
3
  import { pathMatchesPattern } from "../../shared/git-progress.js";
4
4
  import { resolveRepairTaskForGate } from "./repair-artifact.js";
@@ -461,6 +461,7 @@ function validateShellTaskConfig(task, spec, issues) {
461
461
  !shell.jsonArtifactGate &&
462
462
  !shell.backendTestPipeline &&
463
463
  !shell.frontendPrewriteGate &&
464
+ !shell.frontendBrowserToolPreflight &&
464
465
  !shell.frontendVerificationBundle &&
465
466
  !shell.frontendReviewContext &&
466
467
  !shell.frontendTestCaseChecklist &&
@@ -623,6 +624,104 @@ function validateWriterOutcomePolicyTaskConfig(task, issues) {
623
624
  });
624
625
  }
625
626
  }
627
+ function isFrontendEvidenceWriteSet(writeSet) {
628
+ const entries = writeSet ?? [];
629
+ if (entries.length === 0)
630
+ return false;
631
+ return entries.every((entry) => {
632
+ const normalized = entry.trim().replace(/\\/g, "/").replace(/^\.\//, "");
633
+ return (normalized === "testcase/frontend/evidence/**" ||
634
+ normalized.startsWith("testcase/frontend/evidence/"));
635
+ });
636
+ }
637
+ function isFrontendBrowserExecutorTask(task) {
638
+ const skills = task.skills ?? [];
639
+ return (task.executor === "pi" &&
640
+ task.toolProfile === "write" &&
641
+ skills.includes("playwright-cli") &&
642
+ isFrontendEvidenceWriteSet(task.writeSet));
643
+ }
644
+ function taskMentionsPlaywrightCliContract(task) {
645
+ const prompt = `${task.subtask_prompt}\n${task.outputContract ?? ""}`;
646
+ return /playwright[_-]?cli/i.test(prompt);
647
+ }
648
+ /**
649
+ * Command capability is orthogonal to file write. Fail closed when:
650
+ * - frontend browser executors lack playwright-cli capability
651
+ * - capability is granted without skill/prompt contract or outside evidence writeSet
652
+ * - non-browser nodes receive command capability
653
+ */
654
+ function validateCommandPolicyTaskConfig(task, issues) {
655
+ const policy = resolveDagCommandPolicy(task.commandPolicy);
656
+ const allowsPlaywright = dagCommandPolicyAllows(task.commandPolicy, "playwright-cli");
657
+ const isBrowserExecutor = isFrontendBrowserExecutorTask(task);
658
+ if (isBrowserExecutor && !allowsPlaywright) {
659
+ issues.push({
660
+ type: "invalid-command-policy",
661
+ message: `frontend-test browser executor requires playwright-cli command capability (task ${task.id})`,
662
+ });
663
+ }
664
+ if (policy.mode === "deny") {
665
+ return;
666
+ }
667
+ if (task.executor !== "pi") {
668
+ issues.push({
669
+ type: "invalid-command-policy",
670
+ message: `task ${task.id} commandPolicy requires executor=pi`,
671
+ });
672
+ return;
673
+ }
674
+ if (allowsPlaywright) {
675
+ const skills = task.skills ?? [];
676
+ if (!skills.includes("playwright-cli")) {
677
+ issues.push({
678
+ type: "invalid-command-policy",
679
+ message: `task ${task.id} playwright-cli command capability requires skills to include playwright-cli`,
680
+ });
681
+ }
682
+ if (!taskMentionsPlaywrightCliContract(task)) {
683
+ issues.push({
684
+ type: "invalid-command-policy",
685
+ message: `task ${task.id} playwright-cli command capability requires prompt/outputContract to reference playwright_cli`,
686
+ });
687
+ }
688
+ if (!isFrontendEvidenceWriteSet(task.writeSet)) {
689
+ issues.push({
690
+ type: "invalid-command-policy",
691
+ message: `task ${task.id} playwright-cli command capability requires frontend evidence writeSet under testcase/frontend/evidence/`,
692
+ });
693
+ }
694
+ if (task.toolProfile !== "write") {
695
+ issues.push({
696
+ type: "invalid-command-policy",
697
+ message: `task ${task.id} playwright-cli command capability requires toolProfile=write for evidence mutation`,
698
+ });
699
+ }
700
+ }
701
+ }
702
+ function validateDynamicChildCommandPolicy(task, issues) {
703
+ const expansion = task.dynamicExpansion;
704
+ if (!expansion)
705
+ return;
706
+ const child = expansion.childTask;
707
+ const synthetic = {
708
+ id: `${task.id}-child-template`,
709
+ depends_on: [],
710
+ complexity: child.complexity,
711
+ subtask_prompt: child.subtaskPromptTemplate,
712
+ executor: child.executor,
713
+ role: child.role,
714
+ skills: child.skills,
715
+ toolProfile: child.toolProfile,
716
+ commandPolicy: child.commandPolicy,
717
+ writePolicy: child.writePolicy,
718
+ allowedPaths: child.allowedPaths,
719
+ forbiddenPaths: child.forbiddenPaths,
720
+ writeSet: child.writeSet,
721
+ outputContract: child.outputContract,
722
+ };
723
+ validateCommandPolicyTaskConfig(synthetic, issues);
724
+ }
626
725
  function validateDecisionGateTaskConfig(task, issues) {
627
726
  if (!task.decisionGate?.enabled) {
628
727
  return;
@@ -727,6 +826,25 @@ export function collectForbiddenExecutorIssues(spec, forbiddenExecutors) {
727
826
  implicitDefault: rawTasks[index]?.executor === undefined,
728
827
  }));
729
828
  }
829
+ /** Revalidate a rendered dynamic child before it enters state or executes. */
830
+ export function assertValidMaterializedDagTask(task) {
831
+ const issues = [];
832
+ // A minimal v2 carrier makes write-policy validation strict while avoiding
833
+ // unrelated parent graph/rank checks. Dynamic templates are validated at
834
+ // init; this guards values introduced by {{item}} rendering.
835
+ const spec = { version: 2, tasks: [task] };
836
+ validateTaskWritePolicy(task, spec, issues);
837
+ validateShellTaskConfig(task, spec, issues);
838
+ validateStaticTaskConfig(task, issues);
839
+ validateDecisionGateTaskConfig(task, issues);
840
+ validateRetryPolicyTaskConfig(task, issues);
841
+ validateOutputProtocolTaskConfig(task, issues);
842
+ validateWriterOutcomePolicyTaskConfig(task, issues);
843
+ validateCommandPolicyTaskConfig(task, issues);
844
+ if (issues.length > 0) {
845
+ throw new Error(`invalid materialized dynamic child ${task.id}: ${issues.map((issue) => issue.message).join("; ")}`);
846
+ }
847
+ }
730
848
  export function validateDagSpec(spec) {
731
849
  const issues = [];
732
850
  if (spec.tasks.length === 0) {
@@ -778,6 +896,8 @@ export function validateDagSpec(spec) {
778
896
  validateRetryPolicyTaskConfig(task, issues);
779
897
  validateOutputProtocolTaskConfig(task, issues);
780
898
  validateWriterOutcomePolicyTaskConfig(task, issues);
899
+ validateCommandPolicyTaskConfig(task, issues);
900
+ validateDynamicChildCommandPolicy(task, issues);
781
901
  validateProjectGovernanceTaskConfig(task, spec, issues);
782
902
  validateFailureAwareDependsOn(task, spec, issues);
783
903
  }
@@ -141,7 +141,7 @@ Runner / Loop ──(迁移中)──> 逐步改为仅经 Store / Appli
141
141
  版本化自举包含两个不同的冻结层,不能用其中一个替代另一个:
142
142
 
143
143
  | 层 | 冻结对象 | 生命周期 | 权威证据 |
144
- |---|---|---|---|
144
+ | --- | --- | --- | --- |
145
145
  | Controller identity | 实际发布包、entry/realEntry、launch spec、binary hash、portable package fingerprint | Feature/batch/Task/final verification | Worker、Task Pool、batch/Feature、QA/final evidence 中的 `controllerIdentity` |
146
146
  | DAG skill snapshot | 某个 run 实际注入 prompt 的 ordered skill profiles、learned flag、budgets 与 dynamic bindings | 单个 DAG run,包括 pause/resume | `state.skillSnapshotRef` + `<runDir>/.runtime/skill-snapshot.json` |
147
147
 
@@ -150,7 +150,7 @@ Runner / Loop ──(迁移中)──> 逐步改为仅经 Store / Appli
150
150
  **规则摘要**
151
151
 
152
152
  | From | May import | Must not import |
153
- |------|------------|-----------------|
153
+ | ------ | ------------ | ----------------- |
154
154
  | `src/commands/**` | application, workflows, infrastructure, task, records, executors, shared | — |
155
155
  | `src/workflows/**` | executors, task, records, shared, application(目标) | `src/commands/**` |
156
156
  | `src/executors/**` | shared, 外部 SDK | `src/commands/**`, `src/cli/**` |
@@ -169,7 +169,7 @@ Runner / Loop ──(迁移中)──> 逐步改为仅经 Store / Appli
169
169
  以下脚本由 `scripts/check-repo.sh` 调用(Phase 0 起):
170
170
 
171
171
  | Script | 检查内容 | 失败条件 |
172
- |--------|----------|----------|
172
+ | -------- | ---------- | ---------- |
173
173
  | `scripts/check-architecture-boundaries.sh` | workflow/executor forbidden import,及 Worker → CLI/commands/application import | 新的未 allowlist violation |
174
174
  | `scripts/check-command-registry-drift.sh` | `command-reference.md` 中的 top-level command vs `src/cli/catalog.ts` | 文档引用未注册 command |
175
175
  | `scripts/check-exec-plan-index-sync.sh` | `ai_workspace/loop-agent/exec-plans/{active,completed}` 目录文件 vs 对应 `README.md` 索引 | 任一 plan 文件未在索引登记 |
@@ -190,23 +190,25 @@ bash scripts/check-skill-entry.sh
190
190
 
191
191
  Runtime 变更另需 `npm run typecheck` 及对应 targeted Vitest(见 exec plan 各 Phase 验证关口)。
192
192
 
193
- ## Client session 瞬态恢复边界
193
+ ## Client session 恢复边界
194
194
 
195
- 主会话模型偶尔会返回非标准 502 / `LLMRequestError` / 网络抖动 / 超时响应;OpenCode 可能先解析为 `TypeValidationError` 再落成 `UnknownError`,导致内置 APIError 重试不命中。loop-agent 通过 **项目级 OpenCode 插件补偿** **Pi 用户级配置显式启用** 处理该缺口,不修改 OpenCode/Pi 上游,也不引入外部监督器。
195
+ 主会话模型有两类常见缺口:**瞬态 UnknownError**(非标准 502 / `LLMRequestError` / 网络抖动 / 超时经 TypeValidation 落入 UnknownError,内置 APIError 重试不命中)与 **上下文过大**(含内网中文「请求上下文过大」及常见英文 overflow 文案,默认 overflow 识别不全)。loop-agent 通过 **职责分离的项目级产物** 补偿,不修改 OpenCode/Pi 上游,也不引入外部监督器,不改默认模型 `contextWindow`。
196
196
 
197
197
  | 面 | 路径 / 入口 | 边界 |
198
- |---|---|---|
199
- | OpenCode 项目插件 | `.opencode/plugins/loop-agent-transient-retry.js`(init surface `generated`) | 直接返回真实 `Hooks.event`,分发 `session.error` / `session.status` / `session.idle` / `message.updated`;每个 session 保存 pending error,busy/retry/unknown/status API 失败只暂停恢复,后续 idle 事件重新驱动单一 worker。`client.session.status({ throwOnError: true })` 在退避前后确认 idle,`client.session.promptAsync(..., throwOnError: true)` 仅在 1.18.9 的 204 响应明确返回对象型 `data` 时计入一次 attempt 并续接同一 session;只有成功完成的 assistant message 清零连续失败计数。认证/权限/配额/上下文溢出/取消/业务错误走 `plugin-ignore-permanent-error` |
198
+ | --- | --- | --- |
199
+ | OpenCode transient-retry | `.opencode/plugins/loop-agent-transient-retry.js`(init surface `generated`) | 仅瞬态 UnknownError resume;busy/retry 门控 + backoff;overflow/401/配额/取消/业务标 permanent(`plugin-ignore-permanent-error`),**不** compact |
200
+ | OpenCode overflow-compact | `.opencode/plugins/loop-agent-context-overflow-compact.js`(init surface `generated`) | 仅 context overflow → `session.compact`(无则 `summarize`)+ 有限 `promptAsync` 续接;`MAX_OVERFLOW_RECOVERIES ≤ 2`;session 锁;非 overflow 不动作 |
201
+ | Pi project extension | `.pi/extensions/loop-agent-context-overflow.js`(init surface `generated`) | `message_end` 上将 overflow `errorMessage` 幂等改写为 `context_length_exceeded:` 前缀,触发 Pi 原生 compact/retry;**不**写 `~/.pi` settings / compaction / 模型窗口 |
200
202
  | Pi 用户配置 | `~/.pi/agent/settings.json` | **不**进入项目 `.harness/init-surface.json` hash;仅 `--client-recovery=user` 可字段级补缺并原子写;`auto`/`project`/`off` 与 `check-update` 默认零写 home;读取时只有 `ENOENT` 视为缺文件,其他 I/O 错误 fail closed |
201
- | CLI mode | `--client-recovery=auto\|project\|user\|off`(默认 `auto`) | `auto`/`project` 只装项目插件;`user` = 项目插件 + 显式 Pi 合并;`off` 全跳过 |
202
- | Ownership | recorded sha256 + apply-safe | 插件缺失可补、与 recorded hash 一致可升级;用户改过 → model merge / human decision,禁止静默覆盖 |
203
+ | CLI mode | `--client-recovery=auto\|project\|user\|off`(默认 `auto`) | `auto`/`project` 安装上述三份项目产物;`user` = 三产物 + 显式 Pi retry 合并;`off` 全跳过 |
204
+ | Ownership | recorded sha256 + apply-safe | 三份 generated 缺失可补、与 recorded hash 一致可升级;用户改过 → model merge / human decision,禁止静默覆盖 |
203
205
 
204
- 实现落点:`src/commands/client-recovery.ts`(纯逻辑与生成器)+ `src/commands/init.ts`(编排)。
206
+ 实现落点:`src/commands/client-recovery.ts`(共享 `isContextOverflow`、生成器与 install)+ `src/commands/init.ts`(surface 编排与 `written` 投影)。
205
207
 
206
208
  ## 演进里程碑
207
209
 
208
210
  | Phase | 边界变化 |
209
- |-------|----------|
211
+ | ------- | ---------- |
210
212
  | 0(当前) | 文档 + 机器 guard;已知 loop/actions.ts 耦合 advisory |
211
213
  | 1 | CLI command definition 单源 |
212
214
  | 2 | Skill entry 瘦身 + frontmatter references |