@tea-agent/loop-agent 0.24.9 → 0.24.11-beta.0

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 (31) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/commands/init.js +2 -16
  3. package/dist/executors/shell-executor.js +6 -122
  4. package/dist/worker/cli.js +14 -59
  5. package/dist/worker/delivery/git-transaction.js +7 -32
  6. package/dist/worker/delivery/verification-bundle.js +5 -26
  7. package/dist/worker/observe/routes.js +0 -17
  8. package/dist/worker/observe/static/api.js +0 -9
  9. package/dist/worker/observe/static/constants.js +0 -9
  10. package/dist/worker/observe/static/state.js +0 -14
  11. package/dist/worker/observe/static/styles.css +0 -74
  12. package/dist/worker/observe/static/views/dag-inspector.js +15 -371
  13. package/dist/worker/outcomes/projector.js +1 -5
  14. package/dist/worker/runner/run-ready.js +1 -41
  15. package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -75
  16. package/dist/workflows/dag/backend-test-result-contract.js +0 -103
  17. package/dist/workflows/dag/frontend-test-result-contract.js +41 -106
  18. package/dist/workflows/dag/init-hybrid.js +14 -13
  19. package/dist/workflows/dag/node-execution.js +2 -3
  20. package/dist/workflows/dag/types.js +0 -2
  21. package/dist/workflows/dag/validate.js +2 -3
  22. package/docs/architecture/worker-and-feature.md +11 -0
  23. package/docs/init-surface.manifest.json +3 -0
  24. package/docs/templates/agent-dag.schema.json +9 -0
  25. package/docs/templates/frontend-implementation-contract.schema.json +2 -2
  26. package/package.json +1 -1
  27. package/skills/frontend-bounded-implement/SKILL.md +53 -0
  28. package/skills/frontend-implementation/SKILL.md +3 -7
  29. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  30. package/dist/worker/observe/dag-run-artifacts.js +0 -90
  31. package/dist/worker/observe/node-input.js +0 -444
@@ -10,9 +10,9 @@
10
10
  "sourceBinding": { "$ref": "#/$defs/sourceBinding" },
11
11
  "riskLevel": { "enum": ["small", "standard", "high-risk"] },
12
12
  "targets": { "type": "object", "additionalProperties": false, "required": ["files"], "properties": { "files": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/path" } }, "routes": { "type": "array", "items": { "type": "string", "pattern": "^/" } }, "publicApiChanges": { "type": "array", "items": { "type": "string", "minLength": 1 } } } },
13
- "requirements": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "implementationTargets", "verificationTargetIds"], "properties": { "id": { "$ref": "#/$defs/requirementId" }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "evidenceGap": { "$ref": "#/$defs/gap" } } } },
13
+ "requirements": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "expectedOutcome", "implementationTargets", "verificationTargetIds"], "properties": { "id": { "$ref": "#/$defs/requirementId" }, "expectedOutcome": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "evidenceGap": { "$ref": "#/$defs/gap" } } } },
14
14
  "uiStates": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["name", "applicable"], "properties": { "name": { "type": "string", "minLength": 1 }, "applicable": { "type": "boolean" }, "expectedBehavior": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "notApplicableReason": { "type": "string", "minLength": 1 } }, "allOf": [{ "if": { "properties": { "applicable": { "const": true } }, "required": ["applicable"] }, "then": { "required": ["expectedBehavior", "implementationTargets", "verificationTargetIds"], "properties": { "implementationTargets": { "minItems": 1 }, "verificationTargetIds": { "minItems": 1 } } } }, { "if": { "properties": { "applicable": { "const": false } }, "required": ["applicable"] }, "then": { "required": ["notApplicableReason"] } }] } },
15
- "interactions": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["name", "implementationTargets", "verificationTargetIds"], "properties": { "name": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string" } } } } },
15
+ "interactions": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["name", "trigger", "expectedBehavior", "implementationTargets", "verificationTargetIds"], "properties": { "name": { "type": "string", "minLength": 1 }, "trigger": { "type": "string", "minLength": 1 }, "expectedBehavior": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string" } } } } },
16
16
  "mockApi": { "type": "object", "additionalProperties": false, "required": ["strategy", "productionDefaultOff", "activation", "endpoints"], "properties": { "strategy": { "enum": ["native", "browser-intercept", "request-adapter", "not-needed"] }, "productionDefaultOff": { "const": true }, "activation": { "type": "string", "minLength": 1 }, "endpoints": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["method", "path"], "properties": { "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] }, "path": { "type": "string", "pattern": "^/" }, "fixture": { "$ref": "#/$defs/path" }, "consumer": { "$ref": "#/$defs/path" } } } } } },
17
17
  "designEvidence": { "type": "object", "additionalProperties": false, "required": ["source", "paths", "conflicts"], "properties": { "source": { "type": "string", "minLength": 1 }, "paths": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "conflicts": { "type": "array", "items": { "type": "string", "minLength": 1 } } } },
18
18
  "verificationTargets": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "type", "commandLabel", "file", "requirementIds", "uiStates"], "properties": { "id": { "type": "string", "minLength": 1 }, "type": { "enum": ["static", "unit", "component", "integration", "mock"] }, "commandLabel": { "type": "string", "minLength": 1 }, "file": { "$ref": "#/$defs/path" }, "symbol": { "type": "string", "minLength": 1 }, "requirementIds": { "type": "array", "items": { "$ref": "#/$defs/requirementId" } }, "uiStates": { "type": "array", "items": { "type": "string", "minLength": 1 } } } } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.24.9",
3
+ "version": "0.24.11-beta.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: frontend-bounded-implement
3
+ description: >-
4
+ Use only for frontend implementation and repair writers after the canonical
5
+ contract gate has authorized a bounded write set.
6
+ ---
7
+
8
+ # Frontend Bounded Implement
9
+
10
+ ## Authoritative Inputs
11
+
12
+ - For implementation, use the run-owned
13
+ `contracts/frontend-implementation-contract.json`.
14
+ - For repair, also use `contracts/frontend-repair-assessment.json`.
15
+ - Treat requirement `expectedOutcome`, interaction `trigger` /
16
+ `expectedBehavior`, target files, UI states, Mock/API strategy, and verification
17
+ targets as closed decisions. Do not rediscover or reinterpret them.
18
+
19
+ ## Exploration Boundary
20
+
21
+ - Read the canonical contract, its declared target files, and only directly
22
+ imported/called local code needed to edit those targets safely.
23
+ - Do not open task sources, plans, reviews, OpenSpec, `ai_workspace/`, knowledge
24
+ bases, or unrelated repository areas. Do not recursively search the repository.
25
+ - Start the bounded edit after confirming the contract and targets. Optional
26
+ context is not a reason to postpone implementation.
27
+ - If behavior is still ambiguous or a required target is outside `writeSet`, stop
28
+ with `IMPLEMENTATION_OUTCOME: blocked`; do not compensate with broader scouting.
29
+
30
+ ## Implementation Discipline
31
+
32
+ - Work test-first where behavior changes. Preserve unrelated files and stay inside
33
+ `allowedPaths` and `writeSet`.
34
+ - Implement every requirement outcome, interaction, and applicable UI state
35
+ assigned to the writer. Keep the real request path enabled by default and use
36
+ only the approved Mock/API strategy.
37
+ - Do not add dependencies unless the contract approves them. Never relax lint,
38
+ type, test, or build configuration; never add `.skip` or `.only`.
39
+ - Run only the focused checks already frozen by the task. Leave full verification
40
+ to downstream deterministic nodes.
41
+
42
+ ## Required Outcome
43
+
44
+ The first non-empty response line must be exactly one of:
45
+
46
+ - `IMPLEMENTATION_OUTCOME: changed` — a non-empty bounded diff was produced.
47
+ - `IMPLEMENTATION_OUTCOME: already-satisfied` — the contract was already met and
48
+ no file changed.
49
+ - `IMPLEMENTATION_OUTCOME: blocked` — implementation cannot proceed without
50
+ guessing or exceeding the write boundary.
51
+
52
+ Then report changed files, requirements/interactions implemented, tests or focused
53
+ checks attempted, and residual risks. Never report `changed` for an empty diff.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: frontend-implementation
3
3
  description: >-
4
- Use for frontend contract, scout, mock strategy, plan, and implementation DAG nodes.
4
+ Use for frontend contract, scout, mock strategy, and plan DAG nodes.
5
5
  references:
6
6
  - path: references/node-contracts.md
7
7
  required: true
@@ -29,12 +29,8 @@ rules, commands, or retrievals. Scout/planners locate and explicitly read applic
29
29
  specs; only successful paired reads count. Lockfile-only, fixture-only, or unread
30
30
  search hits do not prove a reusable Mock service.
31
31
 
32
- This broad discovery rule applies only to contract, scout, and plan phases. For
33
- `frontend-implement-pi`, the run-owned
34
- `contracts/frontend-implementation-contract.json` is the approved semantic input;
35
- do not repeat task-source, OpenSpec, AI workspace, plan, or repository-wide
36
- discovery. For `frontend-repair-pi`, consume that contract plus
37
- `contracts/frontend-repair-assessment.json` and make only the bounded repair.
32
+ This broad discovery rule applies only to contract, scout, and plan phases.
33
+ Implementation and repair use the separate `frontend-bounded-implement` skill.
38
34
 
39
35
  ## Implementation Discipline
40
36
 
@@ -6,9 +6,9 @@ Pre-write nodes are read-only. Preserve IDs, labels, commands, language, require
6
6
 
7
7
  - **`frontend-contract-pi`**: `Scope`, `Non-goals`, `Acceptance Criteria`, `UI States`, `Target Runtime Environment`, `Risks`, `Verification Expectations`. No guessed requirements.
8
8
  - **`frontend-scout-pi`**: routes, components, tokens, data/API/Mock, scripts, tests, assets. Fact vs inference vs gap. Query the knowledge base when available and always search+read `openspec/schemas/`, `openspec/project-specs/`, and `ai_workspace/` before repo fallback. Output stack, routes, components, styling, conventions, state/data, test entry points, reuse, risks.
9
- - **`frontend-plan-pi` + conditional design loop**: AC → steps, in-bound files, applicable UI states, reuse, deps, Mock/API strategy, activation/rollback, frozen verify entrypoints, real-integration gap, and exactly one `frontend-implementation-contract-v1` JSON object. Use `uiStates: []` for logic-only changes with no user-visible UI state; do not invent UI states. Applicable states require behavior/implementation/verification, while non-applicable states require a reason and omit empty behavior placeholders. Prefer native Mock; browser intercept only with existing e2e; request-adapter only for a reversible seam. `auto` may select `not-needed` when no project Mock capability exists, while keeping real requests default and recording the gap; `required` cannot. Initial design pass uses the original plan; only exact `request-revision` runs read-only revision plus final review. Small-risk runs one design review only.
9
+ - **`frontend-plan-pi` + conditional design loop**: AC → explicit observable `expectedOutcome`, interactions → explicit `trigger` + `expectedBehavior`, then steps, in-bound files, applicable UI states, reuse, deps, Mock/API strategy, activation/rollback, frozen verify entrypoints, real-integration gap, and exactly one `frontend-implementation-contract-v1` JSON object. IDs plus file paths are not sufficient behavior semantics. Use `uiStates: []` for logic-only changes with no user-visible UI state; do not invent UI states. Applicable states require behavior/implementation/verification, while non-applicable states require a reason and omit empty behavior placeholders. Prefer native Mock; browser intercept only with existing e2e; request-adapter only for a reversible seam. `auto` may select `not-needed` when no project Mock capability exists, while keeping real requests default and recording the gap; `required` cannot. Initial design pass uses the original plan; only exact `request-revision` runs read-only revision plus final review. Small-risk runs one design review only.
10
10
  - **`frontend-prewrite-gate-shell`**: the sole write authorization. Resolve effective plan/review, require exact pass, retain every REQ/BR/AC id, enforce Mock policy, validate schema/source binding, and materialize `contracts/frontend-implementation-contract.json`. Fallback is allowed only when a conditional primary is absent; an existing malformed primary fails closed. Generation-time blocked Mock produces one deterministic blocking shell node and no writer.
11
- - **`frontend-implement-pi`**: sole exclusive writer. Stay in `writeSet`; real requests default-on; Mock reversible, dev/test-only, production-off. Atomic handler/intercept/adapter with consumer+tests. Stop on forbidden paths or guesses. Output changed files, behavior, UI states, styling notes, verification attempted, residual risks. Optional mock-verify when frozen; static+behavior always; behavior must prove page consumption. Skipped-Mock `not-needed` keeps real integration pending unless the real backend path has fresh evidence.
11
+ - **`frontend-implement-pi`**: sole regular exclusive writer and consumer of `frontend-bounded-implement`, not this discovery skill. Stay in `writeSet`; real requests default-on; Mock reversible, dev/test-only, production-off. Atomic handler/intercept/adapter with consumer+tests. Stop on forbidden paths or guesses. First line must be `IMPLEMENTATION_OUTCOME: changed|already-satisfied|blocked`; runtime checks it against the attributed diff. Optional mock-verify when frozen; static+behavior always; behavior must prove page consumption. Skipped-Mock `not-needed` keeps real integration pending unless the real backend path has fresh evidence.
12
12
 
13
13
  ## Contract / trace / stages (M1–M2)
14
14
 
@@ -1,90 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import path from "node:path";
3
- import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
4
- export const DAG_RUN_LIFECYCLES = ["active", "completed", "paused"];
5
- function isPathInside(root, target) {
6
- const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
7
- const normalizedTarget = path.normalize(target);
8
- return (normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot));
9
- }
10
- function normalizeRelativeSegments(relativeSegments) {
11
- if (!Array.isArray(relativeSegments) || relativeSegments.length === 0) {
12
- return null;
13
- }
14
- const segments = [];
15
- for (const segment of relativeSegments) {
16
- if (typeof segment !== "string" || !segment)
17
- return null;
18
- if (segment === "." || segment === "..")
19
- return null;
20
- if (segment.includes("/") || segment.includes("\\") || segment.includes("\0")) {
21
- return null;
22
- }
23
- segments.push(segment);
24
- }
25
- return segments;
26
- }
27
- /**
28
- * Resolve a path under `.harness/dag-runs/{lifecycle}/{dagRunId}/...`.
29
- * Fail closed on invalid ids, path escape, or the same runId appearing in
30
- * multiple lifecycle directories.
31
- */
32
- export function resolveDagRunArtifact(repoRoot, dagRunId, relativeSegments) {
33
- if (!isSafeObservabilityIdentifier(dagRunId)) {
34
- return { ok: false, reason: "invalid-id" };
35
- }
36
- const segments = normalizeRelativeSegments(relativeSegments);
37
- if (!segments) {
38
- return { ok: false, reason: "unsafe-path" };
39
- }
40
- for (const segment of segments) {
41
- if (segment !== "run.json" &&
42
- segment !== "state.json" &&
43
- !isSafeObservabilityIdentifier(segment) &&
44
- !/^[a-zA-Z0-9._-]+$/.test(segment)) {
45
- // Allow known artifact filenames under a validated node id prefix.
46
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(segment)) {
47
- return { ok: false, reason: "unsafe-path" };
48
- }
49
- }
50
- }
51
- const dagRunsRoot = path.resolve(repoRoot, ".harness", "dag-runs");
52
- const matches = [];
53
- for (const lifecycle of DAG_RUN_LIFECYCLES) {
54
- const runDir = path.join(dagRunsRoot, lifecycle, dagRunId);
55
- const absolutePath = path.resolve(runDir, ...segments);
56
- if (!isPathInside(dagRunsRoot, absolutePath))
57
- continue;
58
- const relative = path.relative(dagRunsRoot, absolutePath);
59
- const parts = relative.split(path.sep).filter(Boolean);
60
- if (parts[0] !== lifecycle || parts[1] !== dagRunId)
61
- continue;
62
- if (!existsSync(absolutePath))
63
- continue;
64
- matches.push({
65
- lifecycle,
66
- runDir,
67
- absolutePath,
68
- relativePath: path.join(lifecycle, dagRunId, ...segments),
69
- });
70
- }
71
- if (matches.length === 0) {
72
- return { ok: false, reason: "not-found" };
73
- }
74
- if (matches.length > 1) {
75
- return { ok: false, reason: "ambiguous" };
76
- }
77
- return { ok: true, result: matches[0] };
78
- }
79
- export function resolveDagRunJson(repoRoot, dagRunId) {
80
- return resolveDagRunArtifact(repoRoot, dagRunId, ["run.json"]);
81
- }
82
- export function resolveDagNodePromptRedacted(repoRoot, dagRunId, nodeId) {
83
- if (!isSafeObservabilityIdentifier(nodeId)) {
84
- return { ok: false, reason: "invalid-id" };
85
- }
86
- return resolveDagRunArtifact(repoRoot, dagRunId, [
87
- nodeId,
88
- "prompt.redacted.md",
89
- ]);
90
- }
@@ -1,444 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import path from "node:path";
3
- import { redactSecrets, truncateUtf8Preview, } from "../../shared/preview.js";
4
- import { resolveDagNodeSkills } from "../../workflows/dag/skills.js";
5
- import { resolveDagNodePromptRedacted, resolveDagRunJson, } from "./dag-run-artifacts.js";
6
- export const NODE_INPUT_SCHEMA_VERSION = 1;
7
- export const NODE_INPUT_PREVIEW_MAX_BYTES = 64 * 1024;
8
- const SHELL_GATE_KEYS = [
9
- "verdictGate",
10
- "projectGovernanceGate",
11
- "requirementCoverageGate",
12
- "jsonArtifactGate",
13
- "frontendPrewriteGate",
14
- "frontendLintBaseline",
15
- "frontendVerificationBundle",
16
- "frontendReviewContext",
17
- "backendTestPipeline",
18
- "verifyEvidence",
19
- "repairArtifactGate",
20
- ];
21
- const PROMPT_FINGERPRINT_RE = /<prompt\s+sha256=([a-f0-9]{12})\s+len=(\d+)\s*>/i;
22
- function unavailableBody(dagRunId, nodeId, reason, warnings = []) {
23
- return {
24
- schemaVersion: NODE_INPUT_SCHEMA_VERSION,
25
- dagRunId,
26
- nodeId,
27
- available: false,
28
- availabilityReason: reason,
29
- source: null,
30
- summary: null,
31
- dependency: null,
32
- prompt: null,
33
- executorInput: null,
34
- boundaries: null,
35
- promptFingerprint: null,
36
- warnings,
37
- };
38
- }
39
- function redactString(value) {
40
- if (typeof value !== "string")
41
- return null;
42
- return redactSecrets(value);
43
- }
44
- function redactStringList(values) {
45
- if (!Array.isArray(values))
46
- return [];
47
- return values
48
- .filter((item) => typeof item === "string")
49
- .map((item) => redactSecrets(item));
50
- }
51
- function truncatedField(text, maxBytes = NODE_INPUT_PREVIEW_MAX_BYTES) {
52
- const redacted = redactSecrets(text);
53
- const truncated = truncateUtf8Preview(redacted, maxBytes);
54
- return {
55
- text: truncated,
56
- truncated: truncated !== redacted,
57
- maxBytes,
58
- };
59
- }
60
- export function parsePromptRedactedFingerprint(markdown) {
61
- const match = PROMPT_FINGERPRINT_RE.exec(markdown);
62
- if (!match) {
63
- return { ok: false, error: "fingerprint-format-mismatch" };
64
- }
65
- const sha256Prefix = match[1];
66
- const lengthChars = Number(match[2]);
67
- if (!Number.isSafeInteger(lengthChars) || lengthChars < 0) {
68
- return { ok: false, error: "fingerprint-length-invalid" };
69
- }
70
- return { ok: true, sha256Prefix, lengthChars };
71
- }
72
- function isLikelyDynamicExpandedNode(nodeId, tasks) {
73
- for (const parent of tasks) {
74
- const parentId = typeof parent.id === "string" ? parent.id : "";
75
- const expansion = parent.dynamicExpansion;
76
- if (expansion && typeof expansion === "object") {
77
- const childIdPrefix = typeof expansion.childIdPrefix ===
78
- "string"
79
- ? expansion.childIdPrefix
80
- : "";
81
- if (childIdPrefix) {
82
- const prefix = `${childIdPrefix}-`;
83
- if (nodeId.startsWith(prefix)) {
84
- const suffix = nodeId.slice(prefix.length);
85
- if (/^\d{4}$/.test(suffix) && Number(suffix) >= 1) {
86
- return true;
87
- }
88
- }
89
- }
90
- }
91
- const loop = parent.dynamicLoopUntil;
92
- if (loop && typeof loop === "object" && parentId) {
93
- const prefix = `${parentId}-r`;
94
- if (nodeId.startsWith(prefix)) {
95
- const rest = nodeId.slice(prefix.length);
96
- const dash = rest.indexOf("-");
97
- if (dash > 0) {
98
- const iterationText = rest.slice(0, dash);
99
- if (/^\d+$/.test(iterationText) && Number(iterationText) >= 1) {
100
- return true;
101
- }
102
- }
103
- }
104
- }
105
- }
106
- return false;
107
- }
108
- function shellGateNames(shell) {
109
- const gates = [];
110
- for (const key of SHELL_GATE_KEYS) {
111
- if (shell[key] != null)
112
- gates.push(key);
113
- }
114
- return gates;
115
- }
116
- function resolveModelHint(spec, task) {
117
- const complexity = typeof task.complexity === "string" ? task.complexity : null;
118
- const executorModels = spec.executorModels;
119
- if (complexity &&
120
- executorModels &&
121
- typeof executorModels === "object" &&
122
- !Array.isArray(executorModels)) {
123
- const pi = executorModels.pi;
124
- if (pi && typeof pi === "object" && !Array.isArray(pi)) {
125
- const model = pi[complexity];
126
- if (typeof model === "string" && model.trim()) {
127
- return redactSecrets(model);
128
- }
129
- }
130
- }
131
- const defaults = spec.defaults;
132
- if (defaults && typeof defaults === "object" && !Array.isArray(defaults)) {
133
- const model = defaults.model;
134
- if (typeof model === "string" && model.trim()) {
135
- return redactSecrets(model);
136
- }
137
- }
138
- return null;
139
- }
140
- function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warnings) {
141
- const defaults = spec.defaults && typeof spec.defaults === "object"
142
- ? spec.defaults
143
- : undefined;
144
- const executorDeclared = typeof task.executor === "string";
145
- const executor = executorDeclared
146
- ? String(task.executor)
147
- : "pi";
148
- const executorSource = executorDeclared
149
- ? "task"
150
- : "schema-default";
151
- const writePolicyDeclared = typeof task.writePolicy === "string";
152
- let writePolicy = null;
153
- let writePolicySource = null;
154
- if (writePolicyDeclared) {
155
- writePolicy = String(task.writePolicy);
156
- writePolicySource = "task";
157
- }
158
- else if (typeof defaults?.writePolicy === "string") {
159
- writePolicy = defaults.writePolicy;
160
- writePolicySource = "defaults";
161
- }
162
- const skillSpec = {
163
- defaults,
164
- skillsByRole: spec.skillsByRole && typeof spec.skillsByRole === "object"
165
- ? spec.skillsByRole
166
- : undefined,
167
- };
168
- const skills = resolveDagNodeSkills(skillSpec, {
169
- role: typeof task.role === "string" ? task.role : undefined,
170
- skills: Array.isArray(task.skills)
171
- ? task.skills.filter((s) => typeof s === "string")
172
- : undefined,
173
- }).map((skill) => redactSecrets(skill));
174
- const promptRaw = typeof task.subtask_prompt === "string" ? task.subtask_prompt : "";
175
- const promptBody = truncatedField(promptRaw);
176
- const promptSourceRaw = task.subtask_prompt_source;
177
- let promptSource = null;
178
- if (promptSourceRaw && typeof promptSourceRaw === "object") {
179
- const src = promptSourceRaw;
180
- if (src.type === "markdown" && typeof src.path === "string") {
181
- promptSource = {
182
- type: "markdown",
183
- path: redactSecrets(src.path),
184
- sha256Prefix: typeof src.sha256 === "string" && src.sha256.length >= 12
185
- ? src.sha256.slice(0, 12)
186
- : null,
187
- };
188
- }
189
- }
190
- const shellRaw = task.shell && typeof task.shell === "object" && !Array.isArray(task.shell)
191
- ? task.shell
192
- : null;
193
- const staticRaw = task.static && typeof task.static === "object" && !Array.isArray(task.static)
194
- ? task.static
195
- : null;
196
- let kind = "unknown";
197
- if (executor === "shell" || shellRaw)
198
- kind = "shell";
199
- else if (executor === "static" || staticRaw)
200
- kind = "static";
201
- else if (executor === "pi")
202
- kind = "pi";
203
- const shell = shellRaw
204
- ? {
205
- commands: redactStringList(shellRaw.commands),
206
- preset: typeof shellRaw.preset === "string"
207
- ? redactSecrets(shellRaw.preset)
208
- : null,
209
- cwd: typeof shellRaw.cwd === "string"
210
- ? redactSecrets(shellRaw.cwd)
211
- : null,
212
- timeoutMs: typeof shellRaw.timeoutMs === "number" &&
213
- Number.isFinite(shellRaw.timeoutMs)
214
- ? shellRaw.timeoutMs
215
- : null,
216
- nonZeroExitPolicy: typeof shellRaw.nonZeroExitPolicy === "string"
217
- ? shellRaw.nonZeroExitPolicy
218
- : null,
219
- gates: shellGateNames(shellRaw),
220
- }
221
- : null;
222
- const staticPreview = staticRaw && typeof staticRaw.resultMarkdown === "string"
223
- ? truncatedField(staticRaw.resultMarkdown)
224
- : null;
225
- const staticBlock = staticRaw
226
- ? {
227
- status: typeof staticRaw.status === "string" ? staticRaw.status : null,
228
- resultPreview: staticPreview?.text ?? "",
229
- truncated: staticPreview?.truncated ?? false,
230
- maxBytes: staticPreview?.maxBytes ?? NODE_INPUT_PREVIEW_MAX_BYTES,
231
- }
232
- : null;
233
- return {
234
- schemaVersion: NODE_INPUT_SCHEMA_VERSION,
235
- dagRunId,
236
- nodeId,
237
- available: true,
238
- availabilityReason: "available",
239
- source: {
240
- kind: "frozen-run-spec",
241
- artifact: "run.json#tasks",
242
- lifecycle,
243
- },
244
- summary: {
245
- executor: redactString(executor),
246
- executorSource,
247
- role: typeof task.role === "string" ? redactSecrets(task.role) : null,
248
- complexity: typeof task.complexity === "string"
249
- ? task.complexity
250
- : null,
251
- writePolicy: writePolicy ? redactSecrets(writePolicy) : null,
252
- writePolicySource,
253
- toolProfile: typeof task.toolProfile === "string"
254
- ? redactSecrets(task.toolProfile)
255
- : null,
256
- },
257
- dependency: {
258
- dependsOn: redactStringList(task.depends_on),
259
- dependsPolicy: typeof task.dependsPolicy === "string" ? task.dependsPolicy : null,
260
- failureAwareDependsOn: redactStringList(task.failureAwareDependsOn),
261
- runIf: typeof task.runIf === "string" ? redactSecrets(task.runIf) : null,
262
- },
263
- prompt: {
264
- format: "markdown",
265
- text: promptBody.text,
266
- truncated: promptBody.truncated,
267
- maxBytes: promptBody.maxBytes,
268
- source: promptSource,
269
- },
270
- executorInput: {
271
- kind,
272
- skills,
273
- modelHint: resolveModelHint(spec, task),
274
- shell,
275
- static: staticBlock,
276
- },
277
- boundaries: {
278
- allowedPaths: redactStringList(task.allowedPaths),
279
- forbiddenPaths: redactStringList(task.forbiddenPaths),
280
- writeSet: redactStringList(task.writeSet),
281
- outputContract: typeof task.outputContract === "string"
282
- ? truncatedField(task.outputContract).text
283
- : null,
284
- outputMode: typeof task.outputMode === "string" ? task.outputMode : null,
285
- firstProtocolLine: typeof task.firstProtocolLine === "string"
286
- ? redactSecrets(task.firstProtocolLine)
287
- : null,
288
- },
289
- promptFingerprint: fingerprint,
290
- warnings,
291
- };
292
- }
293
- async function loadPromptFingerprint(repoRoot, dagRunId, nodeId, warnings) {
294
- const resolved = resolveDagNodePromptRedacted(repoRoot, dagRunId, nodeId);
295
- if (!resolved.ok) {
296
- if (resolved.reason === "ambiguous") {
297
- warnings.push("Ambiguous prompt.redacted.md lifecycle copies.");
298
- }
299
- return {
300
- exists: false,
301
- sha256Prefix: null,
302
- lengthChars: null,
303
- artifact: null,
304
- };
305
- }
306
- const artifact = path.posix.join(nodeId, "prompt.redacted.md");
307
- try {
308
- const markdown = await readFile(resolved.result.absolutePath, "utf-8");
309
- const parsed = parsePromptRedactedFingerprint(markdown);
310
- if (!parsed.ok) {
311
- warnings.push(`prompt.redacted.md parse failed: ${parsed.error}`);
312
- return {
313
- exists: true,
314
- sha256Prefix: null,
315
- lengthChars: null,
316
- artifact,
317
- parseError: parsed.error,
318
- };
319
- }
320
- return {
321
- exists: true,
322
- sha256Prefix: parsed.sha256Prefix,
323
- lengthChars: parsed.lengthChars,
324
- artifact,
325
- };
326
- }
327
- catch {
328
- warnings.push("prompt.redacted.md could not be read.");
329
- return {
330
- exists: true,
331
- sha256Prefix: null,
332
- lengthChars: null,
333
- artifact,
334
- parseError: "read-failed",
335
- };
336
- }
337
- }
338
- /**
339
- * Build schemaVersion 1 node-input projection for Observe / Inspect.
340
- * Never returns the raw task object.
341
- */
342
- export async function buildDagNodeInput(repoRoot, dagRunId, nodeId) {
343
- const runResolved = resolveDagRunJson(repoRoot, dagRunId);
344
- if (!runResolved.ok) {
345
- if (runResolved.reason === "invalid-id") {
346
- return {
347
- ok: false,
348
- status: 400,
349
- body: { error: "Invalid dag run or node identifier" },
350
- };
351
- }
352
- if (runResolved.reason === "ambiguous") {
353
- return {
354
- ok: false,
355
- status: 409,
356
- body: { error: "Ambiguous dag run lifecycle" },
357
- };
358
- }
359
- if (runResolved.reason === "not-found") {
360
- return {
361
- ok: false,
362
- status: 404,
363
- body: { error: "DAG run not found" },
364
- };
365
- }
366
- return {
367
- ok: false,
368
- status: 400,
369
- body: { error: "Invalid or unsafe path" },
370
- };
371
- }
372
- let raw;
373
- try {
374
- raw = JSON.parse(await readFile(runResolved.result.absolutePath, "utf-8"));
375
- }
376
- catch {
377
- return {
378
- ok: false,
379
- status: 422,
380
- body: { error: "run.json is unreadable or invalid JSON" },
381
- };
382
- }
383
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
384
- return {
385
- ok: false,
386
- status: 422,
387
- body: { error: "run.json is not a DAG spec object" },
388
- };
389
- }
390
- const spec = raw;
391
- const tasks = Array.isArray(spec.tasks) ? spec.tasks : null;
392
- if (!tasks) {
393
- return {
394
- ok: true,
395
- status: 200,
396
- body: unavailableBody(dagRunId, nodeId, "run-spec-unavailable", [
397
- "run.json has no tasks[] array.",
398
- ]),
399
- };
400
- }
401
- const taskRecords = tasks.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item));
402
- const task = taskRecords.find((item) => typeof item.id === "string" && item.id === nodeId);
403
- if (!task) {
404
- if (isLikelyDynamicExpandedNode(nodeId, taskRecords)) {
405
- return {
406
- ok: true,
407
- status: 200,
408
- body: unavailableBody(dagRunId, nodeId, "unsupported-dynamic-node", [
409
- "Runtime-expanded node input is not projected in schemaVersion 1.",
410
- ]),
411
- };
412
- }
413
- return {
414
- ok: true,
415
- status: 200,
416
- body: unavailableBody(dagRunId, nodeId, "task-not-found"),
417
- };
418
- }
419
- const warnings = [];
420
- const fingerprint = await loadPromptFingerprint(repoRoot, dagRunId, nodeId, warnings);
421
- // Reject unknown nested leakage by projecting only through allowlist helpers.
422
- const body = projectTask(dagRunId, nodeId, runResolved.result.lifecycle, spec, task, fingerprint, warnings);
423
- // Defense-in-depth: ensure raw task never sneaks in.
424
- if ("task" in body) {
425
- return {
426
- ok: false,
427
- status: 422,
428
- body: { error: "projection leaked raw task" },
429
- };
430
- }
431
- return { ok: true, status: 200, body };
432
- }
433
- /** Test helper: ensure response JSON never contains raw task keys. */
434
- export function assertNoRawTaskLeak(body) {
435
- if (!body || typeof body !== "object")
436
- return true;
437
- const record = body;
438
- if ("task" in record)
439
- return false;
440
- if ("subtask_prompt" in record)
441
- return false;
442
- const serialized = JSON.stringify(body);
443
- return !serialized.includes('"subtask_prompt":');
444
- }