This commit is contained in:
2026-02-24 18:57:20 -05:00
parent 45374a033b
commit 7727612ce9
36 changed files with 1331 additions and 70 deletions

View File

@@ -0,0 +1,23 @@
{"id":"e351954e-36c2-48e6-a116-a9e9ef4b58fe","timestamp":"2026-02-24T16:16:29.465Z","type":"session.started","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","message":"Pipeline session started.","metadata":{"entryNodeId":"product-intake"}}
{"id":"d63ca8bc-9026-4ece-a599-d96c4179f2d8","timestamp":"2026-02-24T16:16:30.002Z","type":"node.attempt.completed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"product-intake","attempt":1,"message":"Node \"product-intake\" attempt 1 completed with status \"success\".","usage":{"tokenInput":84,"tokenOutput":59,"tokenTotal":143,"toolCalls":3,"durationMs":525,"costUsd":0.000286},"metadata":{"status":"success","executionContext":{"phase":"product-intake","modelConstraint":"claude-opus-4-6","allowedTools":["read_file","search","list_files"],"security":{"dropUid":false,"dropGid":false,"worktreePath":"/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake","violationMode":"hard_abort"}},"topologyKind":"sequential","retrySpawned":false,"subtasks":["build EcoSwap: Community Skill-Sharing Marketplace","A platform for neighbors to trade time and skills without using actual money","Feature 1: Skill Registry: Users list what they can teach (e"],"securityViolation":false}}
{"id":"2b1a7fb2-6231-4f0a-9d98-a5fb7e465ccb","timestamp":"2026-02-24T16:16:30.002Z","type":"domain.validation_passed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"product-intake","attempt":1,"message":"Node completed successfully.","metadata":{"source":"actor"}}
{"id":"8fee1767-4298-4f9b-a2da-7c23bb66297c","timestamp":"2026-02-24T16:16:30.918Z","type":"node.attempt.completed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"task-roadmap","attempt":1,"message":"Node \"task-roadmap\" attempt 1 completed with status \"success\".","usage":{"tokenInput":151,"tokenOutput":106,"tokenTotal":257,"toolCalls":21,"durationMs":920,"costUsd":0.000514},"metadata":{"status":"success","executionContext":{"phase":"task-roadmap","modelConstraint":"claude-sonnet-4-6","allowedTools":["read_file","write_file","search","list_files","list_tasks","get_task","create_task","update_task","create_subtask","update_subtask","set_task_dependencies","mcp__claude-task-master__read_file","mcp__claude-task-master__write_file","mcp__claude-task-master__search","mcp__claude-task-master__list_tasks","mcp__claude-task-master__get_task","mcp__claude-task-master__create_task","mcp__claude-task-master__update_task","mcp__claude-task-master__create_subtask","mcp__claude-task-master__update_subtask","mcp__claude-task-master__set_task_dependencies"],"security":{"dropUid":false,"dropGid":false,"worktreePath":"/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake","violationMode":"hard_abort"}},"topologyKind":"sequential","retrySpawned":false,"fromNodeId":"product-intake","subtasks":["build EcoSwap: Community Skill-Sharing Marketplace","A platform for neighbors to trade time and skills without using actual money","Feature 1: Skill Registry: Users list what they can teach (e"],"securityViolation":false}}
{"id":"460d36f6-6f42-4045-ae6e-dd71599e1eb5","timestamp":"2026-02-24T16:16:30.918Z","type":"domain.validation_passed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"task-roadmap","attempt":1,"message":"Node completed successfully.","metadata":{"source":"actor"}}
{"id":"91e84375-30c3-4cd3-ac0c-83fcfdc600cf","timestamp":"2026-02-24T16:16:30.918Z","type":"domain.branch_merged","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"task-roadmap","attempt":1,"message":"Task \"product-intake\" merged into session base branch.","metadata":{"source":"pipeline"}}
{"id":"18f93340-58a6-452c-94df-8a13006fc0ad","timestamp":"2026-02-24T16:16:31.433Z","type":"node.attempt.completed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"dev-impl-a","attempt":1,"message":"Node \"dev-impl-a\" attempt 1 completed with status \"success\".","usage":{"tokenInput":124,"tokenOutput":87,"tokenTotal":211,"toolCalls":9,"durationMs":505,"costUsd":0.000422},"metadata":{"status":"success","executionContext":{"phase":"dev-impl-a","modelConstraint":"claude-sonnet-4-6","allowedTools":["read_file","write_file","search","list_files","bash","run_command","git_status","git_diff","npm_test"],"security":{"dropUid":false,"dropGid":false,"worktreePath":"/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake","violationMode":"hard_abort"}},"topologyKind":"parallel","retrySpawned":false,"fromNodeId":"task-roadmap","subtasks":["build EcoSwap: Community Skill-Sharing Marketplace","A platform for neighbors to trade time and skills without using actual money","Feature 1: Skill Registry: Users list what they can teach (e"],"securityViolation":false}}
{"id":"6deb1ca9-7d35-4f25-9ce1-03bde8d6676a","timestamp":"2026-02-24T16:16:31.433Z","type":"domain.validation_passed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"dev-impl-a","attempt":1,"message":"Node completed successfully.","metadata":{"source":"actor"}}
{"id":"ac993371-13bc-4d85-b7f3-ba237976095e","timestamp":"2026-02-24T16:16:31.433Z","type":"node.attempt.completed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"dev-impl-b","attempt":1,"message":"Node \"dev-impl-b\" attempt 1 completed with status \"success\".","usage":{"tokenInput":124,"tokenOutput":87,"tokenTotal":211,"toolCalls":9,"durationMs":505,"costUsd":0.000422},"metadata":{"status":"success","executionContext":{"phase":"dev-impl-b","modelConstraint":"claude-sonnet-4-6","allowedTools":["read_file","write_file","search","list_files","bash","run_command","git_status","git_diff","npm_test"],"security":{"dropUid":false,"dropGid":false,"worktreePath":"/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake","violationMode":"hard_abort"}},"topologyKind":"parallel","retrySpawned":false,"fromNodeId":"task-roadmap","subtasks":["build EcoSwap: Community Skill-Sharing Marketplace","A platform for neighbors to trade time and skills without using actual money","Feature 1: Skill Registry: Users list what they can teach (e"],"securityViolation":false}}
{"id":"595457fa-942f-4211-b34b-a34a03810abf","timestamp":"2026-02-24T16:16:31.433Z","type":"domain.validation_passed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"dev-impl-b","attempt":1,"message":"Node completed successfully.","metadata":{"source":"actor"}}
{"id":"5b4c50da-48ab-4463-97e9-b92123f9bca8","timestamp":"2026-02-24T16:16:31.831Z","type":"node.attempt.completed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"qa-b","attempt":1,"message":"Node \"qa-b\" attempt 1 completed with status \"success\".","usage":{"tokenInput":99,"tokenOutput":70,"tokenTotal":169,"toolCalls":8,"durationMs":380,"costUsd":0.000338},"metadata":{"status":"success","executionContext":{"phase":"qa-b","modelConstraint":"claude-sonnet-4-6","allowedTools":["read_file","write_file","search","list_files","bash","run_command","npm_test","npm_run"],"security":{"dropUid":false,"dropGid":false,"worktreePath":"/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake","violationMode":"hard_abort"}},"topologyKind":"parallel","retrySpawned":false,"fromNodeId":"dev-impl-b","subtasks":["build EcoSwap: Community Skill-Sharing Marketplace","A platform for neighbors to trade time and skills without using actual money","Feature 1: Skill Registry: Users list what they can teach (e"],"securityViolation":false}}
{"id":"478d0234-271e-42e3-a1fc-579478073970","timestamp":"2026-02-24T16:16:31.831Z","type":"domain.validation_passed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"qa-b","attempt":1,"message":"Node completed successfully.","metadata":{"source":"actor"}}
{"id":"98644a3b-239b-481e-88a6-893f58e3cb15","timestamp":"2026-02-24T16:16:31.831Z","type":"domain.merge_conflict_unresolved","severity":"critical","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"qa-b","attempt":1,"message":"Fatal merge error for task \"product-intake\".","metadata":{"source":"pipeline"}}
{"id":"222d496f-3155-41d1-8047-54d839097979","timestamp":"2026-02-24T16:16:31.831Z","type":"domain.task_blocked","severity":"critical","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"qa-b","attempt":1,"message":"Task \"product-intake\" blocked due to fatal merge error.","metadata":{"source":"pipeline"}}
{"id":"08b2e0fd-1b5e-416d-b482-292d69bcaf20","timestamp":"2026-02-24T16:16:31.831Z","type":"node.attempt.completed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"qa-a","attempt":1,"message":"Node \"qa-a\" attempt 1 completed with status \"success\".","usage":{"tokenInput":99,"tokenOutput":70,"tokenTotal":169,"toolCalls":8,"durationMs":380,"costUsd":0.000338},"metadata":{"status":"success","executionContext":{"phase":"qa-a","modelConstraint":"claude-sonnet-4-6","allowedTools":["read_file","write_file","search","list_files","bash","run_command","npm_test","npm_run"],"security":{"dropUid":false,"dropGid":false,"worktreePath":"/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake","violationMode":"hard_abort"}},"topologyKind":"parallel","retrySpawned":false,"fromNodeId":"dev-impl-a","subtasks":["build EcoSwap: Community Skill-Sharing Marketplace","A platform for neighbors to trade time and skills without using actual money","Feature 1: Skill Registry: Users list what they can teach (e"],"securityViolation":false}}
{"id":"dce67c2f-269d-422f-9acc-43edc4466492","timestamp":"2026-02-24T16:16:31.831Z","type":"domain.validation_passed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"qa-a","attempt":1,"message":"Node completed successfully.","metadata":{"source":"actor"}}
{"id":"115c4f9d-4f66-4965-8224-aebf3207522a","timestamp":"2026-02-24T16:16:31.831Z","type":"domain.branch_merged","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"qa-a","attempt":1,"message":"Task \"product-intake\" merged into session base branch.","metadata":{"source":"pipeline"}}
{"id":"51ef601c-bcf9-4ec9-a8a9-c062901c20db","timestamp":"2026-02-24T16:16:32.474Z","type":"node.attempt.completed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"merge-a","attempt":1,"message":"Node \"merge-a\" attempt 1 completed with status \"success\".","usage":{"tokenInput":93,"tokenOutput":66,"tokenTotal":159,"toolCalls":12,"durationMs":635,"costUsd":0.000318},"metadata":{"status":"success","executionContext":{"phase":"merge-a","modelConstraint":"claude-sonnet-4-6","allowedTools":["read_file","write_file","search","list_files","bash","run_command","git","git_status","git_diff","git_add","git_commit","git_merge"],"security":{"dropUid":false,"dropGid":false,"worktreePath":"/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake","violationMode":"hard_abort"}},"topologyKind":"sequential","retrySpawned":false,"fromNodeId":"qa-a","subtasks":["build EcoSwap: Community Skill-Sharing Marketplace","A platform for neighbors to trade time and skills without using actual money","Feature 1: Skill Registry: Users list what they can teach (e"],"securityViolation":false}}
{"id":"99d4483e-34e2-4bc4-94e4-b010d0855bae","timestamp":"2026-02-24T16:16:32.474Z","type":"domain.validation_passed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"merge-a","attempt":1,"message":"Node completed successfully.","metadata":{"source":"actor"}}
{"id":"2f6041ac-aed6-4d7a-93d9-23b13209d6c8","timestamp":"2026-02-24T16:16:33.124Z","type":"node.attempt.completed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"merge-b","attempt":1,"message":"Node \"merge-b\" attempt 1 completed with status \"success\".","usage":{"tokenInput":93,"tokenOutput":66,"tokenTotal":159,"toolCalls":12,"durationMs":635,"costUsd":0.000318},"metadata":{"status":"success","executionContext":{"phase":"merge-b","modelConstraint":"claude-sonnet-4-6","allowedTools":["read_file","write_file","search","list_files","bash","run_command","git","git_status","git_diff","git_add","git_commit","git_merge"],"security":{"dropUid":false,"dropGid":false,"worktreePath":"/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake","violationMode":"hard_abort"}},"topologyKind":"sequential","retrySpawned":false,"fromNodeId":"qa-b","subtasks":["build EcoSwap: Community Skill-Sharing Marketplace","A platform for neighbors to trade time and skills without using actual money","Feature 1: Skill Registry: Users list what they can teach (e"],"securityViolation":false}}
{"id":"fc6a654f-b52a-4879-bb69-830a459016a8","timestamp":"2026-02-24T16:16:33.124Z","type":"domain.validation_passed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"merge-b","attempt":1,"message":"Node completed successfully.","metadata":{"source":"actor"}}
{"id":"35172289-3b25-415a-8355-1948f3efe3d5","timestamp":"2026-02-24T16:16:33.124Z","type":"domain.branch_merged","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","nodeId":"merge-b","attempt":1,"message":"Task \"product-intake\" merged into session base branch.","metadata":{"source":"pipeline"}}
{"id":"7c4b5477-89be-45ba-bc81-7fd2109d85d0","timestamp":"2026-02-24T16:16:33.125Z","type":"session.completed","severity":"info","sessionId":"ui-session-mm0t56zh-079824be","message":"Pipeline session completed with status \"success\".","metadata":{"status":"success","recordCount":8,"eventCount":13}}

View File

@@ -0,0 +1,385 @@
{
"schemaVersion": "1",
"topologies": ["sequential", "parallel"],
"personas": [
{
"id": "product_manager",
"displayName": "Product Manager",
"systemPromptTemplate": "You are the product manager for {{repo}}. Define clear project goals, user outcomes, non-goals, and acceptance criteria. Maintain a concise PRD-level summary in payload fields so downstream agents can execute independently. Emit a requirements_defined domain event when product requirements are clear enough for implementation planning.",
"toolClearance": {
"allowlist": ["read_file", "search", "list_files"],
"banlist": ["delete_file", "rm", "git_reset", "git_clean"]
}
},
{
"id": "task_manager",
"displayName": "Task Manager",
"systemPromptTemplate": "You are the task planning agent for {{repo}}. Convert requirements into an implementation roadmap and dependency-aware task graph. Use claude-task-master tooling to create or update tasks, subtasks, and dependencies. Prioritize and surface undone + unblocked tasks first. Required output in payload.taskPlan.tasks: id, title, status, dependencies, subtasks, acceptanceCriteria, and ownerHint. Status values should align with pending/in_progress/blocked/done. Emit requirements_defined when clarifications materially update scope and emit tasks_planned when task graph updates are ready for coding lanes.",
"modelConstraint": "claude-sonnet-4-5",
"toolClearance": {
"allowlist": [
"read_file",
"write_file",
"search",
"list_files",
"list_tasks",
"get_task",
"create_task",
"update_task",
"create_subtask",
"update_subtask",
"set_task_dependencies",
"mcp__claude-task-master__read_file",
"mcp__claude-task-master__write_file",
"mcp__claude-task-master__search",
"mcp__claude-task-master__list_tasks",
"mcp__claude-task-master__get_task",
"mcp__claude-task-master__create_task",
"mcp__claude-task-master__update_task",
"mcp__claude-task-master__create_subtask",
"mcp__claude-task-master__update_subtask",
"mcp__claude-task-master__set_task_dependencies"
],
"banlist": ["delete_file", "rm", "git_reset", "git_clean"]
}
},
{
"id": "developer",
"displayName": "Developer",
"systemPromptTemplate": "You are a coding agent for {{repo}}. Consume the task plan from handoff payload and select undone, unblocked tasks whose dependencies are satisfied. Keep changes scoped to your assigned lane/node and report completed task IDs. If requirements are unclear or blocked, return status validation_fail with a precise clarification request in payload and emit task_blocked. On successful implementation, emit code_committed and include changedFiles, completedTaskIds, and validation notes in payload.",
"modelConstraint": "claude-sonnet-4-5",
"toolClearance": {
"allowlist": [
"read_file",
"write_file",
"search",
"list_files",
"bash",
"run_command",
"git_status",
"git_diff",
"npm_test"
],
"banlist": ["delete_file", "rm", "git_reset", "git_clean", "git_push"]
}
},
{
"id": "tester",
"displayName": "Tester",
"systemPromptTemplate": "You are the testing and validation agent for {{repo}}. Validate code and task acceptance criteria for your lane, prioritizing deterministic checks (tests, build, lint, targeted runtime checks). If validation fails, return status validation_fail with reproducible steps and concrete remediation notes. If validation passes, return success, include evidence in payload, and emit validation_passed.",
"modelConstraint": "claude-sonnet-4-5",
"toolClearance": {
"allowlist": [
"read_file",
"write_file",
"search",
"list_files",
"bash",
"run_command",
"npm_test",
"npm_run"
],
"banlist": ["delete_file", "rm", "git_reset", "git_clean", "git_push"]
}
},
{
"id": "git_integrator",
"displayName": "Git Integrator",
"systemPromptTemplate": "You are the git integration agent for {{repo}}. Validate lane readiness for merge into the integration target and ensure the worktree is merge-ready. Run status checks and report risks clearly. Do not force risky merge strategies. On successful readiness checks emit branch_merged and include mergeCommit (or readiness marker), mergedBranch, and targetBranch in payload.",
"modelConstraint": "claude-sonnet-4-5",
"toolClearance": {
"allowlist": [
"read_file",
"write_file",
"search",
"list_files",
"bash",
"run_command",
"git",
"git_status",
"git_diff",
"git_add",
"git_commit",
"git_merge"
],
"banlist": ["delete_file", "rm", "git_reset", "git_clean", "git_push", "git_rebase"]
}
},
{
"id": "conflict_resolver",
"displayName": "Conflict Resolver",
"systemPromptTemplate": "You are the merge conflict resolver for {{repo}}. Resolve conflict markers for the assigned task/worktree, run targeted validation checks, and return success only when conflicts are cleanly resolved. Include resolvedFiles and validation evidence in payload.",
"modelConstraint": "claude-sonnet-4-5",
"toolClearance": {
"allowlist": [
"read_file",
"write_file",
"search",
"list_files",
"bash",
"run_command",
"git",
"git_status",
"git_diff",
"git_add",
"git_commit",
"npm_test",
"npm_run"
],
"banlist": ["delete_file", "rm", "git_reset", "git_clean", "git_push", "git_rebase"]
}
}
],
"relationships": [
{
"parentPersonaId": "product_manager",
"childPersonaId": "task_manager",
"constraints": {
"maxDepth": 2,
"maxChildren": 1
}
},
{
"parentPersonaId": "task_manager",
"childPersonaId": "developer",
"constraints": {
"maxDepth": 4,
"maxChildren": 4
}
},
{
"parentPersonaId": "task_manager",
"childPersonaId": "tester",
"constraints": {
"maxDepth": 4,
"maxChildren": 4
}
},
{
"parentPersonaId": "task_manager",
"childPersonaId": "git_integrator",
"constraints": {
"maxDepth": 5,
"maxChildren": 2
}
}
],
"topologyConstraints": {
"maxDepth": 6,
"maxRetries": 0
},
"pipeline": {
"entryNodeId": "product-intake",
"nodes": [
{
"id": "product-intake",
"actorId": "product_manager_actor",
"personaId": "product_manager"
},
{
"id": "task-roadmap",
"actorId": "task_manager_actor",
"personaId": "task_manager"
},
{
"id": "dev-impl-a",
"actorId": "developer_actor",
"personaId": "developer",
"topology": {
"kind": "parallel",
"blockId": "implementation-pass-1"
}
},
{
"id": "dev-impl-b",
"actorId": "developer_actor",
"personaId": "developer",
"topology": {
"kind": "parallel",
"blockId": "implementation-pass-1"
}
},
{
"id": "qa-a",
"actorId": "tester_actor",
"personaId": "tester",
"topology": {
"kind": "parallel",
"blockId": "validation-pass-1"
}
},
{
"id": "qa-b",
"actorId": "tester_actor",
"personaId": "tester",
"topology": {
"kind": "parallel",
"blockId": "validation-pass-1"
}
},
{
"id": "task-clarify-a",
"actorId": "task_manager_actor",
"personaId": "task_manager"
},
{
"id": "task-clarify-b",
"actorId": "task_manager_actor",
"personaId": "task_manager"
},
{
"id": "dev-rework-a",
"actorId": "developer_actor",
"personaId": "developer",
"topology": {
"kind": "parallel",
"blockId": "implementation-pass-2"
}
},
{
"id": "dev-rework-b",
"actorId": "developer_actor",
"personaId": "developer",
"topology": {
"kind": "parallel",
"blockId": "implementation-pass-2"
}
},
{
"id": "qa-rework-a",
"actorId": "tester_actor",
"personaId": "tester",
"topology": {
"kind": "parallel",
"blockId": "validation-pass-2"
}
},
{
"id": "qa-rework-b",
"actorId": "tester_actor",
"personaId": "tester",
"topology": {
"kind": "parallel",
"blockId": "validation-pass-2"
}
},
{
"id": "merge-a",
"actorId": "git_integrator_actor",
"personaId": "git_integrator"
},
{
"id": "merge-b",
"actorId": "git_integrator_actor",
"personaId": "git_integrator"
},
{
"id": "merge-conflict-resolve-a",
"actorId": "conflict_resolver_actor",
"personaId": "conflict_resolver"
},
{
"id": "merge-conflict-resolve-b",
"actorId": "conflict_resolver_actor",
"personaId": "conflict_resolver"
}
],
"edges": [
{
"from": "product-intake",
"to": "task-roadmap",
"on": "success"
},
{
"from": "task-roadmap",
"to": "dev-impl-a",
"on": "success"
},
{
"from": "task-roadmap",
"to": "dev-impl-b",
"on": "success"
},
{
"from": "dev-impl-a",
"to": "qa-a",
"on": "success"
},
{
"from": "dev-impl-b",
"to": "qa-b",
"on": "success"
},
{
"from": "dev-impl-a",
"to": "task-clarify-a",
"on": "validation_fail"
},
{
"from": "dev-impl-b",
"to": "task-clarify-b",
"on": "validation_fail"
},
{
"from": "qa-a",
"to": "dev-rework-a",
"on": "validation_fail"
},
{
"from": "qa-b",
"to": "dev-rework-b",
"on": "validation_fail"
},
{
"from": "task-clarify-a",
"to": "dev-rework-a",
"on": "success"
},
{
"from": "task-clarify-b",
"to": "dev-rework-b",
"on": "success"
},
{
"from": "dev-rework-a",
"to": "qa-rework-a",
"on": "success"
},
{
"from": "dev-rework-b",
"to": "qa-rework-b",
"on": "success"
},
{
"from": "qa-a",
"to": "merge-a",
"on": "success"
},
{
"from": "qa-b",
"to": "merge-b",
"on": "success"
},
{
"from": "qa-rework-a",
"to": "merge-a",
"on": "success"
},
{
"from": "qa-rework-b",
"to": "merge-b",
"on": "success"
},
{
"from": "merge-a",
"to": "merge-conflict-resolve-a",
"event": "merge_conflict_detected"
},
{
"from": "merge-b",
"to": "merge-conflict-resolve-b",
"event": "merge_conflict_detected"
}
]
}
}

View File

@@ -0,0 +1,17 @@
{
"nodeId": "dev-impl-a",
"fromNodeId": "task-roadmap",
"payload": {
"prompt": "build EcoSwap: Community Skill-Sharing Marketplace\nA platform for neighbors to trade time and skills without using actual money.\n\nFeature 1: Skill Registry: Users list what they can teach (e.g., \"Basic Plumbing\") and what they want to learn.\n\nFeature 2: Time-Bank Ledger: A digital currency where 1 hour of work = 1 Credit.\n\nFeature 3: Scheduling Bridge: An integrated calendar to book \"Swap Sessions.\"\n\nFeature 4: Trust/Rating System: Reviews that directly affect a users \"Reliability Tier.\"\n\nFeature 5: Dispute Resolution: A community-led voting system for when a \"Swap\" doesn't go as planned.",
"summary": "Node task-roadmap completed in mock mode.",
"subtasks": [
"build EcoSwap: Community Skill-Sharing Marketplace",
"A platform for neighbors to trade time and skills without using actual money",
"Feature 1: Skill Registry: Users list what they can teach (e"
],
"taskId": "product-intake",
"worktreePath": "/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake",
"mergeStatus": "merged"
},
"createdAt": "2026-02-24T16:16:30.919Z"
}

View File

@@ -0,0 +1,17 @@
{
"nodeId": "dev-impl-b",
"fromNodeId": "task-roadmap",
"payload": {
"prompt": "build EcoSwap: Community Skill-Sharing Marketplace\nA platform for neighbors to trade time and skills without using actual money.\n\nFeature 1: Skill Registry: Users list what they can teach (e.g., \"Basic Plumbing\") and what they want to learn.\n\nFeature 2: Time-Bank Ledger: A digital currency where 1 hour of work = 1 Credit.\n\nFeature 3: Scheduling Bridge: An integrated calendar to book \"Swap Sessions.\"\n\nFeature 4: Trust/Rating System: Reviews that directly affect a users \"Reliability Tier.\"\n\nFeature 5: Dispute Resolution: A community-led voting system for when a \"Swap\" doesn't go as planned.",
"summary": "Node task-roadmap completed in mock mode.",
"subtasks": [
"build EcoSwap: Community Skill-Sharing Marketplace",
"A platform for neighbors to trade time and skills without using actual money",
"Feature 1: Skill Registry: Users list what they can teach (e"
],
"taskId": "product-intake",
"worktreePath": "/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake",
"mergeStatus": "merged"
},
"createdAt": "2026-02-24T16:16:30.919Z"
}

View File

@@ -0,0 +1,17 @@
{
"nodeId": "merge-a",
"fromNodeId": "qa-a",
"payload": {
"prompt": "build EcoSwap: Community Skill-Sharing Marketplace\nA platform for neighbors to trade time and skills without using actual money.\n\nFeature 1: Skill Registry: Users list what they can teach (e.g., \"Basic Plumbing\") and what they want to learn.\n\nFeature 2: Time-Bank Ledger: A digital currency where 1 hour of work = 1 Credit.\n\nFeature 3: Scheduling Bridge: An integrated calendar to book \"Swap Sessions.\"\n\nFeature 4: Trust/Rating System: Reviews that directly affect a users \"Reliability Tier.\"\n\nFeature 5: Dispute Resolution: A community-led voting system for when a \"Swap\" doesn't go as planned.",
"summary": "Node qa-a completed in mock mode.",
"subtasks": [
"build EcoSwap: Community Skill-Sharing Marketplace",
"A platform for neighbors to trade time and skills without using actual money",
"Feature 1: Skill Registry: Users list what they can teach (e"
],
"taskId": "product-intake",
"worktreePath": "/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake",
"mergeStatus": "merged"
},
"createdAt": "2026-02-24T16:16:31.832Z"
}

View File

@@ -0,0 +1,18 @@
{
"nodeId": "merge-b",
"fromNodeId": "qa-b",
"payload": {
"prompt": "build EcoSwap: Community Skill-Sharing Marketplace\nA platform for neighbors to trade time and skills without using actual money.\n\nFeature 1: Skill Registry: Users list what they can teach (e.g., \"Basic Plumbing\") and what they want to learn.\n\nFeature 2: Time-Bank Ledger: A digital currency where 1 hour of work = 1 Credit.\n\nFeature 3: Scheduling Bridge: An integrated calendar to book \"Swap Sessions.\"\n\nFeature 4: Trust/Rating System: Reviews that directly affect a users \"Reliability Tier.\"\n\nFeature 5: Dispute Resolution: A community-led voting system for when a \"Swap\" doesn't go as planned.",
"summary": "Node qa-b completed in mock mode.",
"subtasks": [
"build EcoSwap: Community Skill-Sharing Marketplace",
"A platform for neighbors to trade time and skills without using actual money",
"Feature 1: Skill Registry: Users list what they can teach (e"
],
"taskId": "product-intake",
"worktreePath": "/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake",
"mergeStatus": "fatal_error",
"mergeError": "git -C /home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/base worktree remove --force /home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake failed: fatal: '/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake' is not a working tree"
},
"createdAt": "2026-02-24T16:16:31.832Z"
}

View File

@@ -0,0 +1,7 @@
{
"nodeId": "product-intake",
"payload": {
"prompt": "build EcoSwap: Community Skill-Sharing Marketplace\nA platform for neighbors to trade time and skills without using actual money.\n\nFeature 1: Skill Registry: Users list what they can teach (e.g., \"Basic Plumbing\") and what they want to learn.\n\nFeature 2: Time-Bank Ledger: A digital currency where 1 hour of work = 1 Credit.\n\nFeature 3: Scheduling Bridge: An integrated calendar to book \"Swap Sessions.\"\n\nFeature 4: Trust/Rating System: Reviews that directly affect a users \"Reliability Tier.\"\n\nFeature 5: Dispute Resolution: A community-led voting system for when a \"Swap\" doesn't go as planned."
},
"createdAt": "2026-02-24T16:16:29.466Z"
}

View File

@@ -0,0 +1,17 @@
{
"nodeId": "qa-a",
"fromNodeId": "dev-impl-a",
"payload": {
"prompt": "build EcoSwap: Community Skill-Sharing Marketplace\nA platform for neighbors to trade time and skills without using actual money.\n\nFeature 1: Skill Registry: Users list what they can teach (e.g., \"Basic Plumbing\") and what they want to learn.\n\nFeature 2: Time-Bank Ledger: A digital currency where 1 hour of work = 1 Credit.\n\nFeature 3: Scheduling Bridge: An integrated calendar to book \"Swap Sessions.\"\n\nFeature 4: Trust/Rating System: Reviews that directly affect a users \"Reliability Tier.\"\n\nFeature 5: Dispute Resolution: A community-led voting system for when a \"Swap\" doesn't go as planned.",
"summary": "Node dev-impl-a completed in mock mode.",
"subtasks": [
"build EcoSwap: Community Skill-Sharing Marketplace",
"A platform for neighbors to trade time and skills without using actual money",
"Feature 1: Skill Registry: Users list what they can teach (e"
],
"taskId": "product-intake",
"worktreePath": "/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake",
"mergeStatus": "merged"
},
"createdAt": "2026-02-24T16:16:31.434Z"
}

View File

@@ -0,0 +1,17 @@
{
"nodeId": "qa-b",
"fromNodeId": "dev-impl-b",
"payload": {
"prompt": "build EcoSwap: Community Skill-Sharing Marketplace\nA platform for neighbors to trade time and skills without using actual money.\n\nFeature 1: Skill Registry: Users list what they can teach (e.g., \"Basic Plumbing\") and what they want to learn.\n\nFeature 2: Time-Bank Ledger: A digital currency where 1 hour of work = 1 Credit.\n\nFeature 3: Scheduling Bridge: An integrated calendar to book \"Swap Sessions.\"\n\nFeature 4: Trust/Rating System: Reviews that directly affect a users \"Reliability Tier.\"\n\nFeature 5: Dispute Resolution: A community-led voting system for when a \"Swap\" doesn't go as planned.",
"summary": "Node dev-impl-b completed in mock mode.",
"subtasks": [
"build EcoSwap: Community Skill-Sharing Marketplace",
"A platform for neighbors to trade time and skills without using actual money",
"Feature 1: Skill Registry: Users list what they can teach (e"
],
"taskId": "product-intake",
"worktreePath": "/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake",
"mergeStatus": "merged"
},
"createdAt": "2026-02-24T16:16:31.434Z"
}

View File

@@ -0,0 +1,16 @@
{
"nodeId": "task-roadmap",
"fromNodeId": "product-intake",
"payload": {
"prompt": "build EcoSwap: Community Skill-Sharing Marketplace\nA platform for neighbors to trade time and skills without using actual money.\n\nFeature 1: Skill Registry: Users list what they can teach (e.g., \"Basic Plumbing\") and what they want to learn.\n\nFeature 2: Time-Bank Ledger: A digital currency where 1 hour of work = 1 Credit.\n\nFeature 3: Scheduling Bridge: An integrated calendar to book \"Swap Sessions.\"\n\nFeature 4: Trust/Rating System: Reviews that directly affect a users \"Reliability Tier.\"\n\nFeature 5: Dispute Resolution: A community-led voting system for when a \"Swap\" doesn't go as planned.",
"summary": "Node product-intake completed in mock mode.",
"subtasks": [
"build EcoSwap: Community Skill-Sharing Marketplace",
"A platform for neighbors to trade time and skills without using actual money",
"Feature 1: Skill Registry: Users list what they can teach (e"
],
"taskId": "product-intake",
"worktreePath": "/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake"
},
"createdAt": "2026-02-24T16:16:30.002Z"
}

View File

@@ -0,0 +1,25 @@
{
"schemaVersion": 1,
"globalFlags": {},
"artifactPointers": {
"sessions/ui-session-mm0t56zh-079824be/last_completed_node": "merge-b",
"sessions/ui-session-mm0t56zh-079824be/last_attempt": "1",
"sessions/ui-session-mm0t56zh-079824be/final_state": "/home/zman/projects/ai_ops/.ai_ops/state/ui-session-mm0t56zh-079824be/state.json"
},
"taskQueue": [
{
"taskId": "product-intake",
"id": "product-intake",
"title": "product-intake",
"status": "merged",
"metadata": {
"mergeConflict": {
"attempts": 0,
"maxAttempts": 2,
"status": "merged",
"mergedAt": "2026-02-24T16:16:33.124Z"
}
}
}
]
}

View File

@@ -0,0 +1,8 @@
{
"sessionId": "ui-session-mm0t56zh-079824be",
"projectPath": "/home/zman/projects/ai_ops/.workspace/ecoswap",
"baseWorkspacePath": "/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/base",
"sessionStatus": "active",
"createdAt": "2026-02-24T16:16:12.702Z",
"updatedAt": "2026-02-24T16:16:12.702Z"
}

View File

@@ -0,0 +1,182 @@
{
"flags": {
"product-intake_completed": true,
"task-roadmap_completed": true,
"dev-impl-a_completed": true,
"dev-impl-b_completed": true,
"qa-b_completed": true,
"qa-a_completed": true,
"merge-a_completed": true,
"merge-b_completed": true
},
"metadata": {
"project_context": {
"globalFlags": {},
"artifactPointers": {},
"taskQueue": []
},
"usage": {
"tokenInput": 93,
"tokenOutput": 66,
"durationMs": 635,
"costUsd": 0.000318,
"tokenTotal": 159,
"toolCalls": 12
},
"topologyHint": "manifest-default"
},
"history": [
{
"nodeId": "product-intake",
"event": "validation_passed",
"timestamp": "2026-02-24T16:16:30.000Z",
"data": {
"source": "actor",
"attempt": 1,
"summary": "Node completed successfully."
}
},
{
"nodeId": "task-roadmap",
"event": "validation_passed",
"timestamp": "2026-02-24T16:16:30.905Z",
"data": {
"source": "actor",
"attempt": 1,
"summary": "Node completed successfully."
}
},
{
"nodeId": "task-roadmap",
"event": "branch_merged",
"timestamp": "2026-02-24T16:16:30.918Z",
"data": {
"source": "pipeline",
"attempt": 1,
"summary": "Task \"product-intake\" merged into session base branch.",
"details": {
"taskId": "product-intake",
"worktreePath": "/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake"
}
}
},
{
"nodeId": "dev-impl-a",
"event": "validation_passed",
"timestamp": "2026-02-24T16:16:31.432Z",
"data": {
"source": "actor",
"attempt": 1,
"summary": "Node completed successfully."
}
},
{
"nodeId": "dev-impl-b",
"event": "validation_passed",
"timestamp": "2026-02-24T16:16:31.432Z",
"data": {
"source": "actor",
"attempt": 1,
"summary": "Node completed successfully."
}
},
{
"nodeId": "qa-b",
"event": "validation_passed",
"timestamp": "2026-02-24T16:16:31.817Z",
"data": {
"source": "actor",
"attempt": 1,
"summary": "Node completed successfully."
}
},
{
"nodeId": "qa-b",
"event": "merge_conflict_unresolved",
"timestamp": "2026-02-24T16:16:31.831Z",
"data": {
"source": "pipeline",
"attempt": 1,
"summary": "Fatal merge error for task \"product-intake\".",
"details": {
"taskId": "product-intake",
"worktreePath": "/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake",
"error": "git -C /home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/base worktree remove --force /home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake failed: fatal: '/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake' is not a working tree",
"mergeBase": "1650156ff0966f9071107c1e3ed4e51d02df3f24"
}
}
},
{
"nodeId": "qa-b",
"event": "task_blocked",
"timestamp": "2026-02-24T16:16:31.831Z",
"data": {
"source": "pipeline",
"attempt": 1,
"summary": "Task \"product-intake\" blocked due to fatal merge error.",
"details": {
"taskId": "product-intake",
"error": "git -C /home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/base worktree remove --force /home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake failed: fatal: '/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake' is not a working tree"
}
}
},
{
"nodeId": "qa-a",
"event": "validation_passed",
"timestamp": "2026-02-24T16:16:31.816Z",
"data": {
"source": "actor",
"attempt": 1,
"summary": "Node completed successfully."
}
},
{
"nodeId": "qa-a",
"event": "branch_merged",
"timestamp": "2026-02-24T16:16:31.831Z",
"data": {
"source": "pipeline",
"attempt": 1,
"summary": "Task \"product-intake\" merged into session base branch.",
"details": {
"taskId": "product-intake",
"worktreePath": "/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake"
}
}
},
{
"nodeId": "merge-a",
"event": "validation_passed",
"timestamp": "2026-02-24T16:16:32.473Z",
"data": {
"source": "actor",
"attempt": 1,
"summary": "Node completed successfully."
}
},
{
"nodeId": "merge-b",
"event": "validation_passed",
"timestamp": "2026-02-24T16:16:33.112Z",
"data": {
"source": "actor",
"attempt": 1,
"summary": "Node completed successfully."
}
},
{
"nodeId": "merge-b",
"event": "branch_merged",
"timestamp": "2026-02-24T16:16:33.124Z",
"data": {
"source": "pipeline",
"attempt": 1,
"summary": "Task \"product-intake\" merged into session base branch.",
"details": {
"taskId": "product-intake",
"worktreePath": "/home/zman/projects/ai_ops/.ai_ops/worktrees/ui-session-mm0t56zh-079824be/tasks/product-intake"
}
}
}
]
}

View File

@@ -0,0 +1,10 @@
{
"runId": "eb9f2e22-d117-42fd-b03c-25a08127a3d8",
"sessionId": "ui-session-mm0t56zh-079824be",
"status": "success",
"startedAt": "2026-02-24T16:16:29.463Z",
"executionMode": "mock",
"provider": "claude",
"manifestPath": ".ai_ops/manifests/structured-dev-workflow.json",
"endedAt": "2026-02-24T16:16:33.125Z"
}

View File

@@ -0,0 +1 @@
# ECOSWAP

View File

@@ -16,6 +16,7 @@ CLAUDE_CODE_OAUTH_TOKEN=
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
CLAUDE_MODEL= CLAUDE_MODEL=
CLAUDE_CODE_PATH= CLAUDE_CODE_PATH=
CLAUDE_MAX_TURNS=2
# Claude binary observability: off | stdout | file | both # Claude binary observability: off | stdout | file | both
CLAUDE_OBSERVABILITY_MODE=off CLAUDE_OBSERVABILITY_MODE=off
# CLAUDE_OBSERVABILITY_VERBOSITY: summary | full # CLAUDE_OBSERVABILITY_VERBOSITY: summary | full
@@ -52,7 +53,7 @@ AGENT_PORT_LOCK_DIR=.ai_ops/locks/ports
AGENT_DISCOVERY_FILE_RELATIVE_PATH=.agent-context/resources.json AGENT_DISCOVERY_FILE_RELATIVE_PATH=.agent-context/resources.json
# Security middleware # Security middleware
# AGENT_SECURITY_VIOLATION_MODE: hard_abort | validation_fail # AGENT_SECURITY_VIOLATION_MODE: hard_abort | validation_fail | dangerous_warn_only
AGENT_SECURITY_VIOLATION_MODE=hard_abort AGENT_SECURITY_VIOLATION_MODE=hard_abort
AGENT_SECURITY_ALLOWED_BINARIES=git,npm,node,cat,ls,pwd,echo,bash,sh AGENT_SECURITY_ALLOWED_BINARIES=git,npm,node,cat,ls,pwd,echo,bash,sh
AGENT_SECURITY_COMMAND_TIMEOUT_MS=120000 AGENT_SECURITY_COMMAND_TIMEOUT_MS=120000

View File

@@ -109,7 +109,9 @@ Provider mode notes:
- `provider=codex` uses existing OpenAI/Codex auth settings (`OPENAI_AUTH_MODE`, `CODEX_API_KEY`, `OPENAI_API_KEY`). - `provider=codex` uses existing OpenAI/Codex auth settings (`OPENAI_AUTH_MODE`, `CODEX_API_KEY`, `OPENAI_API_KEY`).
- `provider=claude` uses Claude auth resolution (`CLAUDE_CODE_OAUTH_TOKEN` preferred, otherwise `ANTHROPIC_API_KEY`, or existing Claude Code login state). - `provider=claude` uses Claude auth resolution (`CLAUDE_CODE_OAUTH_TOKEN` preferred, otherwise `ANTHROPIC_API_KEY`, or existing Claude Code login state).
- `CLAUDE_MODEL` should be a Claude model id/alias recognized by Claude Code (for example `claude-sonnet-4-6`); `anthropic/...` prefixes are normalized automatically. - `CLAUDE_MODEL` should be a Claude model id/alias recognized by Claude Code (for example `claude-sonnet-4-6`); `anthropic/...` prefixes are normalized automatically.
- `CLAUDE_MAX_TURNS` controls the per-query Claude turn budget (default `2`).
- Claude provider runs can emit Claude SDK/CLI internals to stdout and/or NDJSON with `CLAUDE_OBSERVABILITY_*` settings. - Claude provider runs can emit Claude SDK/CLI internals to stdout and/or NDJSON with `CLAUDE_OBSERVABILITY_*` settings.
- UI session-mode provider runs execute directly in orchestration-assigned task/base worktrees; provider adapters do not allocate additional nested worktrees.
## Manifest Semantics ## Manifest Semantics
@@ -271,6 +273,7 @@ jq -c 'select(.severity=="critical")' .ai_ops/events/runtime-events.ndjson
- Pipeline behavior on `SecurityViolationError` is configurable: - Pipeline behavior on `SecurityViolationError` is configurable:
- `hard_abort` (default) - `hard_abort` (default)
- `validation_fail` (retry-unrolled remediation) - `validation_fail` (retry-unrolled remediation)
- `dangerous_warn_only` (logs violations and continues execution; high risk)
## Environment Variables ## Environment Variables
@@ -285,6 +288,7 @@ jq -c 'select(.severity=="critical")' .ai_ops/events/runtime-events.ndjson
- `ANTHROPIC_API_KEY` (used when `CLAUDE_CODE_OAUTH_TOKEN` is unset) - `ANTHROPIC_API_KEY` (used when `CLAUDE_CODE_OAUTH_TOKEN` is unset)
- `CLAUDE_MODEL` - `CLAUDE_MODEL`
- `CLAUDE_CODE_PATH` - `CLAUDE_CODE_PATH`
- `CLAUDE_MAX_TURNS` (integer >= 1, defaults to `2`)
- `CLAUDE_OBSERVABILITY_MODE` (`off`, `stdout`, `file`, or `both`) - `CLAUDE_OBSERVABILITY_MODE` (`off`, `stdout`, `file`, or `both`)
- `CLAUDE_OBSERVABILITY_VERBOSITY` (`summary` or `full`) - `CLAUDE_OBSERVABILITY_VERBOSITY` (`summary` or `full`)
- `CLAUDE_OBSERVABILITY_LOG_PATH` - `CLAUDE_OBSERVABILITY_LOG_PATH`
@@ -322,7 +326,7 @@ jq -c 'select(.severity=="critical")' .ai_ops/events/runtime-events.ndjson
### Security Middleware ### Security Middleware
- `AGENT_SECURITY_VIOLATION_MODE` (`hard_abort` or `validation_fail`) - `AGENT_SECURITY_VIOLATION_MODE` (`hard_abort`, `validation_fail`, or `dangerous_warn_only`)
- `AGENT_SECURITY_ALLOWED_BINARIES` - `AGENT_SECURITY_ALLOWED_BINARIES`
- `AGENT_SECURITY_COMMAND_TIMEOUT_MS` - `AGENT_SECURITY_COMMAND_TIMEOUT_MS`
- `AGENT_SECURITY_AUDIT_LOG_PATH` - `AGENT_SECURITY_AUDIT_LOG_PATH`

View File

@@ -37,6 +37,11 @@ Before each actor invocation, orchestration resolves an immutable `ResolvedExecu
This keeps orchestration policy resolution separate from executor enforcement. Executors do not need to parse manifests or MCP registry internals. This keeps orchestration policy resolution separate from executor enforcement. Executors do not need to parse manifests or MCP registry internals.
Worktree ownership invariant:
- In UI session mode, orchestration/session lifecycle is the single owner of git worktree allocation.
- Provider adapters (Codex/Claude runtime wrappers) must execute inside `ResolvedExecutionContext.security.worktreePath` and must not provision independent worktrees.
## Execution topology model ## Execution topology model
- Pipeline graph execution is DAG-based with ready-node frontiers. - Pipeline graph execution is DAG-based with ready-node frontiers.

View File

@@ -30,6 +30,7 @@ This middleware provides a first-pass hardening layer for agent-executed shell c
- `hard_abort` (default): fail fast and stop the pipeline. - `hard_abort` (default): fail fast and stop the pipeline.
- `validation_fail`: map violation to retry-unrolled behavior so the actor can attempt a compliant alternative. - `validation_fail`: map violation to retry-unrolled behavior so the actor can attempt a compliant alternative.
- `dangerous_warn_only`: emit security audit/runtime events but continue execution. This is intentionally unsafe and should only be used for temporary unblock/debug workflows.
## MCP integration ## MCP integration

View File

@@ -26,6 +26,7 @@ import type { JsonObject } from "./types.js";
import { SessionWorktreeManager, type SessionMetadata } from "./session-lifecycle.js"; import { SessionWorktreeManager, type SessionMetadata } from "./session-lifecycle.js";
import { import {
SecureCommandExecutor, SecureCommandExecutor,
type SecurityViolationHandling,
type SecurityAuditEvent, type SecurityAuditEvent,
type SecurityAuditSink, type SecurityAuditSink,
SecurityRulesEngine, SecurityRulesEngine,
@@ -46,7 +47,7 @@ export type OrchestrationSettings = {
maxRetries: number; maxRetries: number;
maxChildren: number; maxChildren: number;
mergeConflictMaxAttempts: number; mergeConflictMaxAttempts: number;
securityViolationHandling: "hard_abort" | "validation_fail"; securityViolationHandling: SecurityViolationHandling;
runtimeContext: Record<string, string | number | boolean>; runtimeContext: Record<string, string | number | boolean>;
}; };
@@ -211,6 +212,9 @@ function createActorSecurityContext(input: {
blockedEnvAssignments: ["AGENT_STATE_ROOT", "AGENT_PROJECT_CONTEXT_PATH"], blockedEnvAssignments: ["AGENT_STATE_ROOT", "AGENT_PROJECT_CONTEXT_PATH"],
}, },
auditSink, auditSink,
{
violationHandling: input.settings.securityViolationHandling,
},
); );
return { return {
@@ -342,6 +346,7 @@ export class SchemaDrivenExecutionEngine {
this.sessionWorktreeManager = new SessionWorktreeManager({ this.sessionWorktreeManager = new SessionWorktreeManager({
worktreeRoot: resolve(this.settings.workspaceRoot, this.config.provisioning.gitWorktree.rootDirectory), worktreeRoot: resolve(this.settings.workspaceRoot, this.config.provisioning.gitWorktree.rootDirectory),
baseRef: this.config.provisioning.gitWorktree.baseRef, baseRef: this.config.provisioning.gitWorktree.baseRef,
targetPath: this.config.provisioning.gitWorktree.targetPath,
}); });
this.actorExecutors = toExecutorMap(input.actorExecutors); this.actorExecutors = toExecutorMap(input.actorExecutors);
@@ -426,7 +431,11 @@ export class SchemaDrivenExecutionEngine {
}): Promise<PipelineRunSummary> { }): Promise<PipelineRunSummary> {
const managerSessionId = `${input.sessionId}__pipeline`; const managerSessionId = `${input.sessionId}__pipeline`;
const managerSession = this.manager.createSession(managerSessionId); const managerSession = this.manager.createSession(managerSessionId);
const workspaceRoot = input.sessionMetadata?.baseWorkspacePath ?? this.settings.workspaceRoot; const workspaceRoot = input.sessionMetadata
? this.sessionWorktreeManager.resolveWorkingDirectoryForWorktree(
input.sessionMetadata.baseWorkspacePath,
)
: this.settings.workspaceRoot;
const projectContextStore = input.sessionMetadata const projectContextStore = input.sessionMetadata
? new FileSystemProjectContextStore({ ? new FileSystemProjectContextStore({
filePath: resolveSessionProjectContextPath(this.settings.stateRoot, input.sessionId), filePath: resolveSessionProjectContextPath(this.settings.stateRoot, input.sessionId),
@@ -531,6 +540,7 @@ export class SchemaDrivenExecutionEngine {
return { return {
taskId, taskId,
workingDirectory: ensured.taskWorkingDirectory,
worktreePath: ensured.taskWorktreePath, worktreePath: ensured.taskWorktreePath,
statusAtStart, statusAtStart,
...(existing?.metadata ? { metadata: existing.metadata } : {}), ...(existing?.metadata ? { metadata: existing.metadata } : {}),

View File

@@ -63,6 +63,7 @@ export type ActorExecutionResult = {
export type ActorToolPermissionResult = export type ActorToolPermissionResult =
| { | {
behavior: "allow"; behavior: "allow";
updatedInput?: Record<string, unknown>;
toolUseID?: string; toolUseID?: string;
} }
| { | {
@@ -171,6 +172,7 @@ export type ActorExecutionSecurityContext = {
export type TaskExecutionResolution = { export type TaskExecutionResolution = {
taskId: string; taskId: string;
workingDirectory: string;
worktreePath: string; worktreePath: string;
statusAtStart: string; statusAtStart: string;
metadata?: JsonObject; metadata?: JsonObject;
@@ -941,7 +943,7 @@ export class PipelineExecutor {
node, node,
toolClearance, toolClearance,
prompt, prompt,
worktreePathOverride: taskResolution?.worktreePath, worktreePathOverride: taskResolution?.workingDirectory,
}); });
const result = await this.invokeActorExecutor({ const result = await this.invokeActorExecutor({
@@ -970,6 +972,7 @@ export class PipelineExecutor {
...(taskResolution ...(taskResolution
? { ? {
taskId: taskResolution.taskId, taskId: taskResolution.taskId,
workingDirectory: taskResolution.workingDirectory,
worktreePath: taskResolution.worktreePath, worktreePath: taskResolution.worktreePath,
} }
: {}), : {}),
@@ -1309,6 +1312,7 @@ export class PipelineExecutor {
const createToolPermissionHandler = (): ActorToolPermissionHandler => const createToolPermissionHandler = (): ActorToolPermissionHandler =>
this.createToolPermissionHandler({ this.createToolPermissionHandler({
allowedTools: executionContext.allowedTools, allowedTools: executionContext.allowedTools,
violationMode: executionContext.security.violationMode,
sessionId: input.sessionId, sessionId: input.sessionId,
nodeId: input.nodeId, nodeId: input.nodeId,
attempt: input.attempt, attempt: input.attempt,
@@ -1326,6 +1330,7 @@ export class PipelineExecutor {
private createToolPermissionHandler(input: { private createToolPermissionHandler(input: {
allowedTools: readonly string[]; allowedTools: readonly string[];
violationMode: SecurityViolationHandling;
sessionId: string; sessionId: string;
nodeId: string; nodeId: string;
attempt: number; attempt: number;
@@ -1340,7 +1345,7 @@ export class PipelineExecutor {
attempt: input.attempt, attempt: input.attempt,
}; };
return async (toolName, _input, options) => { return async (toolName, toolInput, options) => {
const toolUseID = options.toolUseID; const toolUseID = options.toolUseID;
if (options.signal.aborted) { if (options.signal.aborted) {
return { return {
@@ -1358,11 +1363,28 @@ export class PipelineExecutor {
caseInsensitiveLookup: caseInsensitiveAllowLookup, caseInsensitiveLookup: caseInsensitiveAllowLookup,
}); });
if (!allowMatch) { if (!allowMatch) {
rulesEngine?.assertToolInvocationAllowed({ if (rulesEngine) {
tool: candidates[0] ?? toolName, try {
toolClearance: toolPolicy, rulesEngine.assertToolInvocationAllowed({
context: toolAuditContext, tool: candidates[0] ?? toolName,
}); toolClearance: toolPolicy,
context: toolAuditContext,
});
} catch (error) {
if (
!(input.violationMode === "dangerous_warn_only" && error instanceof SecurityViolationError)
) {
throw error;
}
}
}
if (input.violationMode === "dangerous_warn_only") {
return {
behavior: "allow",
updatedInput: toolInput,
...(toolUseID ? { toolUseID } : {}),
};
}
return { return {
behavior: "deny", behavior: "deny",
message: `Tool "${toolName}" is not in the resolved execution allowlist.`, message: `Tool "${toolName}" is not in the resolved execution allowlist.`,
@@ -1379,6 +1401,7 @@ export class PipelineExecutor {
return { return {
behavior: "allow", behavior: "allow",
updatedInput: toolInput,
...(toolUseID ? { toolUseID } : {}), ...(toolUseID ? { toolUseID } : {}),
}; };
}; };

View File

@@ -358,13 +358,16 @@ export class FileSystemSessionMetadataStore {
export class SessionWorktreeManager { export class SessionWorktreeManager {
private readonly worktreeRoot: string; private readonly worktreeRoot: string;
private readonly baseRef: string; private readonly baseRef: string;
private readonly targetPath?: string;
constructor(input: { constructor(input: {
worktreeRoot: string; worktreeRoot: string;
baseRef: string; baseRef: string;
targetPath?: string;
}) { }) {
this.worktreeRoot = assertAbsolutePath(input.worktreeRoot, "worktreeRoot"); this.worktreeRoot = assertAbsolutePath(input.worktreeRoot, "worktreeRoot");
this.baseRef = assertNonEmptyString(input.baseRef, "baseRef"); this.baseRef = assertNonEmptyString(input.baseRef, "baseRef");
this.targetPath = normalizeWorktreeTargetPath(input.targetPath, "targetPath");
} }
resolveBaseWorkspacePath(sessionId: string): string { resolveBaseWorkspacePath(sessionId: string): string {
@@ -378,6 +381,11 @@ export class SessionWorktreeManager {
return resolve(this.worktreeRoot, scopedSession, "tasks", scopedTask); return resolve(this.worktreeRoot, scopedSession, "tasks", scopedTask);
} }
resolveWorkingDirectoryForWorktree(worktreePath: string): string {
const normalizedWorktreePath = assertAbsolutePath(worktreePath, "worktreePath");
return this.targetPath ? resolve(normalizedWorktreePath, this.targetPath) : normalizedWorktreePath;
}
private resolveBaseBranchName(sessionId: string): string { private resolveBaseBranchName(sessionId: string): string {
const scoped = sanitizeSegment(sessionId, "session"); const scoped = sanitizeSegment(sessionId, "session");
return `ai-ops/${scoped}/base`; return `ai-ops/${scoped}/base`;
@@ -399,14 +407,13 @@ export class SessionWorktreeManager {
await mkdir(dirname(baseWorkspacePath), { recursive: true }); await mkdir(dirname(baseWorkspacePath), { recursive: true });
const alreadyExists = await pathExists(baseWorkspacePath); if (!(await pathExists(baseWorkspacePath))) {
if (alreadyExists) { const repoRoot = await runGit(["-C", projectPath, "rev-parse", "--show-toplevel"]);
return; const branchName = this.resolveBaseBranchName(input.sessionId);
await runGit(["-C", repoRoot, "worktree", "add", "-B", branchName, baseWorkspacePath, this.baseRef]);
} }
const repoRoot = await runGit(["-C", projectPath, "rev-parse", "--show-toplevel"]); await this.ensureWorktreeTargetPath(baseWorkspacePath);
const branchName = this.resolveBaseBranchName(input.sessionId);
await runGit(["-C", repoRoot, "worktree", "add", "-B", branchName, baseWorkspacePath, this.baseRef]);
} }
async ensureTaskWorktree(input: { async ensureTaskWorktree(input: {
@@ -416,6 +423,7 @@ export class SessionWorktreeManager {
existingWorktreePath?: string; existingWorktreePath?: string;
}): Promise<{ }): Promise<{
taskWorktreePath: string; taskWorktreePath: string;
taskWorkingDirectory: string;
}> { }> {
const baseWorkspacePath = assertAbsolutePath(input.baseWorkspacePath, "baseWorkspacePath"); const baseWorkspacePath = assertAbsolutePath(input.baseWorkspacePath, "baseWorkspacePath");
const maybeExisting = input.existingWorktreePath?.trim(); const maybeExisting = input.existingWorktreePath?.trim();
@@ -451,8 +459,10 @@ export class SessionWorktreeManager {
if (addResult.exitCode !== 0) { if (addResult.exitCode !== 0) {
const attachedAfterFailure = await this.findWorktreePathForBranch(baseWorkspacePath, branchName); const attachedAfterFailure = await this.findWorktreePathForBranch(baseWorkspacePath, branchName);
if (attachedAfterFailure === worktreePath && (await pathExists(worktreePath))) { if (attachedAfterFailure === worktreePath && (await pathExists(worktreePath))) {
const taskWorkingDirectory = await this.ensureWorktreeTargetPath(worktreePath);
return { return {
taskWorktreePath: worktreePath, taskWorktreePath: worktreePath,
taskWorkingDirectory,
}; };
} }
throw new Error( throw new Error(
@@ -462,8 +472,10 @@ export class SessionWorktreeManager {
} }
} }
const taskWorkingDirectory = await this.ensureWorktreeTargetPath(worktreePath);
return { return {
taskWorktreePath: worktreePath, taskWorktreePath: worktreePath,
taskWorkingDirectory,
}; };
} }
@@ -780,4 +792,69 @@ export class SessionWorktreeManager {
} }
return parseGitWorktreeRecords(result.stdout); return parseGitWorktreeRecords(result.stdout);
} }
private async ensureWorktreeTargetPath(worktreePath: string): Promise<string> {
if (this.targetPath) {
await runGit(["-C", worktreePath, "sparse-checkout", "init", "--cone"]);
await runGit(["-C", worktreePath, "sparse-checkout", "set", this.targetPath]);
}
const workingDirectory = this.resolveWorkingDirectoryForWorktree(worktreePath);
let workingDirectoryStats;
try {
workingDirectoryStats = await stat(workingDirectory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
if (this.targetPath) {
throw new Error(
`Configured worktree target path "${this.targetPath}" is not a directory in ref "${this.baseRef}".`,
);
}
throw new Error(`Worktree path "${workingDirectory}" does not exist.`);
}
throw error;
}
if (!workingDirectoryStats.isDirectory()) {
if (this.targetPath) {
throw new Error(
`Configured worktree target path "${this.targetPath}" is not a directory in ref "${this.baseRef}".`,
);
}
throw new Error(`Worktree path "${workingDirectory}" is not a directory.`);
}
return workingDirectory;
}
}
function normalizeWorktreeTargetPath(value: string | undefined, key: string): string | undefined {
if (value === undefined) {
return undefined;
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return undefined;
}
const slashNormalized = trimmed.replace(/\\/g, "/");
if (isAbsolute(slashNormalized) || /^[a-zA-Z]:\//.test(slashNormalized)) {
throw new Error(`${key} must be a relative path within the repository worktree.`);
}
const normalizedSegments = slashNormalized
.split("/")
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0 && segment !== ".");
if (normalizedSegments.some((segment) => segment === "..")) {
throw new Error(`${key} must not contain ".." path segments.`);
}
if (normalizedSegments.length === 0) {
return undefined;
}
return normalizedSegments.join("/");
} }

View File

@@ -16,6 +16,7 @@ export type ProviderRuntimeConfig = {
anthropicApiKey?: string; anthropicApiKey?: string;
claudeModel?: string; claudeModel?: string;
claudeCodePath?: string; claudeCodePath?: string;
claudeMaxTurns: number;
claudeObservability: ClaudeObservabilityRuntimeConfig; claudeObservability: ClaudeObservabilityRuntimeConfig;
}; };
@@ -136,6 +137,8 @@ const DEFAULT_CLAUDE_OBSERVABILITY: ClaudeObservabilityRuntimeConfig = {
debugLogPath: undefined, debugLogPath: undefined,
}; };
const DEFAULT_CLAUDE_MAX_TURNS = 2;
function readOptionalString( function readOptionalString(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
key: string, key: string,
@@ -401,6 +404,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Readonly<AppCo
anthropicApiKey, anthropicApiKey,
claudeModel: normalizeClaudeModel(readOptionalString(env, "CLAUDE_MODEL")), claudeModel: normalizeClaudeModel(readOptionalString(env, "CLAUDE_MODEL")),
claudeCodePath: readOptionalString(env, "CLAUDE_CODE_PATH"), claudeCodePath: readOptionalString(env, "CLAUDE_CODE_PATH"),
claudeMaxTurns: readIntegerWithBounds(
env,
"CLAUDE_MAX_TURNS",
DEFAULT_CLAUDE_MAX_TURNS,
{ min: 1 },
),
claudeObservability: { claudeObservability: {
mode: parseClaudeObservabilityMode( mode: parseClaudeObservabilityMode(
readStringWithFallback( readStringWithFallback(

View File

@@ -19,7 +19,7 @@ function requiredPrompt(argv: string[]): string {
function buildOptions(config = getConfig()): Options { function buildOptions(config = getConfig()): Options {
return { return {
maxTurns: 1, maxTurns: config.provider.claudeMaxTurns,
...(config.provider.claudeModel ? { model: config.provider.claudeModel } : {}), ...(config.provider.claudeModel ? { model: config.provider.claudeModel } : {}),
...(config.provider.claudeCodePath ...(config.provider.claudeCodePath
? { pathToClaudeCodeExecutable: config.provider.claudeCodePath } ? { pathToClaudeCodeExecutable: config.provider.claudeCodePath }

View File

@@ -8,6 +8,7 @@ import {
import { import {
parseShellValidationPolicy, parseShellValidationPolicy,
parseToolClearancePolicy, parseToolClearancePolicy,
type SecurityViolationHandling,
type ShellValidationPolicy, type ShellValidationPolicy,
type ToolClearancePolicy, type ToolClearancePolicy,
} from "./schemas.js"; } from "./schemas.js";
@@ -62,6 +63,10 @@ function normalizeToken(value: string): string {
return value.trim(); return value.trim();
} }
function normalizeLookupToken(value: string): string {
return normalizeToken(value).toLowerCase();
}
function hasPathTraversalSegment(token: string): boolean { function hasPathTraversalSegment(token: string): boolean {
const normalized = token.replaceAll("\\", "/"); const normalized = token.replaceAll("\\", "/");
if (normalized === ".." || normalized.startsWith("../") || normalized.endsWith("/..")) { if (normalized === ".." || normalized.startsWith("../") || normalized.endsWith("/..")) {
@@ -100,6 +105,18 @@ function toToolSet(values: readonly string[]): Set<string> {
return out; return out;
} }
function toCaseInsensitiveLookup(values: readonly string[]): Map<string, string> {
const out = new Map<string, string>();
for (const value of values) {
const normalized = normalizeLookupToken(value);
if (!normalized || out.has(normalized)) {
continue;
}
out.set(normalized, value);
}
return out;
}
function toNow(): string { function toNow(): string {
return new Date().toISOString(); return new Date().toISOString();
} }
@@ -133,10 +150,14 @@ export class SecurityRulesEngine {
private readonly blockedEnvAssignments: Set<string>; private readonly blockedEnvAssignments: Set<string>;
private readonly worktreeRoot: string; private readonly worktreeRoot: string;
private readonly protectedPaths: string[]; private readonly protectedPaths: string[];
private readonly violationHandling: SecurityViolationHandling;
constructor( constructor(
policy: ShellValidationPolicy, policy: ShellValidationPolicy,
private readonly auditSink?: SecurityAuditSink, private readonly auditSink?: SecurityAuditSink,
options?: {
violationHandling?: SecurityViolationHandling;
},
) { ) {
this.policy = parseShellValidationPolicy(policy); this.policy = parseShellValidationPolicy(policy);
this.allowedBinaries = toToolSet(this.policy.allowedBinaries); this.allowedBinaries = toToolSet(this.policy.allowedBinaries);
@@ -144,6 +165,7 @@ export class SecurityRulesEngine {
this.blockedEnvAssignments = toToolSet(this.policy.blockedEnvAssignments); this.blockedEnvAssignments = toToolSet(this.policy.blockedEnvAssignments);
this.worktreeRoot = resolve(this.policy.worktreeRoot); this.worktreeRoot = resolve(this.policy.worktreeRoot);
this.protectedPaths = this.policy.protectedPaths.map((path) => resolve(path)); this.protectedPaths = this.policy.protectedPaths.map((path) => resolve(path));
this.violationHandling = options?.violationHandling ?? "hard_abort";
} }
getPolicy(): ShellValidationPolicy { getPolicy(): ShellValidationPolicy {
@@ -212,6 +234,15 @@ export class SecurityRulesEngine {
code: error.code, code: error.code,
details: error.details, details: error.details,
}); });
if (this.violationHandling === "dangerous_warn_only") {
return {
cwd: resolvedCwd,
parsed: {
commandCount: 0,
commands: [],
},
};
}
throw error; throw error;
} }
@@ -232,8 +263,11 @@ export class SecurityRulesEngine {
}; };
}): void { }): void {
const policy = parseToolClearancePolicy(input.toolClearance); const policy = parseToolClearancePolicy(input.toolClearance);
const normalizedTool = normalizeLookupToken(input.tool);
const banlistLookup = toCaseInsensitiveLookup(policy.banlist);
const allowlistLookup = toCaseInsensitiveLookup(policy.allowlist);
if (policy.banlist.includes(input.tool)) { if (banlistLookup.has(normalizedTool)) {
this.emit({ this.emit({
...toAuditContext(input.context), ...toAuditContext(input.context),
type: "tool.invocation_blocked", type: "tool.invocation_blocked",
@@ -252,7 +286,7 @@ export class SecurityRulesEngine {
); );
} }
if (policy.allowlist.length > 0 && !policy.allowlist.includes(input.tool)) { if (policy.allowlist.length > 0 && !allowlistLookup.has(normalizedTool)) {
this.emit({ this.emit({
...toAuditContext(input.context), ...toAuditContext(input.context),
type: "tool.invocation_blocked", type: "tool.invocation_blocked",
@@ -280,13 +314,15 @@ export class SecurityRulesEngine {
filterAllowedTools(tools: string[], toolClearance: ToolClearancePolicy): string[] { filterAllowedTools(tools: string[], toolClearance: ToolClearancePolicy): string[] {
const policy = parseToolClearancePolicy(toolClearance); const policy = parseToolClearancePolicy(toolClearance);
const allowlistLookup = toCaseInsensitiveLookup(policy.allowlist);
const banlistLookup = toCaseInsensitiveLookup(policy.banlist);
const allowedByAllowlist = const allowedByAllowlist =
policy.allowlist.length === 0 policy.allowlist.length === 0
? tools ? tools
: tools.filter((tool) => policy.allowlist.includes(tool)); : tools.filter((tool) => allowlistLookup.has(normalizeLookupToken(tool)));
return allowedByAllowlist.filter((tool) => !policy.banlist.includes(tool)); return allowedByAllowlist.filter((tool) => !banlistLookup.has(normalizeLookupToken(tool)));
} }
private assertCwdBoundary(cwd: string): void { private assertCwdBoundary(cwd: string): void {

View File

@@ -157,11 +157,15 @@ export function parseParsedShellScript(input: unknown): ParsedShellScript {
}; };
} }
export type SecurityViolationHandling = "hard_abort" | "validation_fail"; export type SecurityViolationHandling =
| "hard_abort"
| "validation_fail"
| "dangerous_warn_only";
export const securityViolationHandlingSchema = z.union([ export const securityViolationHandlingSchema = z.union([
z.literal("hard_abort"), z.literal("hard_abort"),
z.literal("validation_fail"), z.literal("validation_fail"),
z.literal("dangerous_warn_only"),
]); ]);
export function parseSecurityViolationHandling(input: unknown): SecurityViolationHandling { export function parseSecurityViolationHandling(input: unknown): SecurityViolationHandling {

View File

@@ -1,5 +1,6 @@
import { resolve } from "node:path"; import { resolve } from "node:path";
import { loadConfig, type AppConfig } from "../config.js"; import { loadConfig, type AppConfig } from "../config.js";
import type { SecurityViolationHandling } from "../security/index.js";
import { parseEnvFile, writeEnvFileUpdates } from "./env-store.js"; import { parseEnvFile, writeEnvFileUpdates } from "./env-store.js";
export type RuntimeNotificationSettings = { export type RuntimeNotificationSettings = {
@@ -9,7 +10,7 @@ export type RuntimeNotificationSettings = {
}; };
export type SecurityPolicySettings = { export type SecurityPolicySettings = {
violationMode: "hard_abort" | "validation_fail"; violationMode: SecurityViolationHandling;
allowedBinaries: string[]; allowedBinaries: string[];
commandTimeoutMs: number; commandTimeoutMs: number;
inheritedEnv: string[]; inheritedEnv: string[];

View File

@@ -9,7 +9,6 @@ import {
import { isDomainEventType, type DomainEventEmission } from "../agents/domain-events.js"; import { isDomainEventType, type DomainEventEmission } from "../agents/domain-events.js";
import type { ActorExecutionInput, ActorExecutionResult, ActorExecutor } from "../agents/pipeline.js"; import type { ActorExecutionInput, ActorExecutionResult, ActorExecutor } from "../agents/pipeline.js";
import { isRecord, type JsonObject, type JsonValue } from "../agents/types.js"; import { isRecord, type JsonObject, type JsonValue } from "../agents/types.js";
import { createSessionContext, type SessionContext } from "../examples/session-context.js";
import { ClaudeObservabilityLogger } from "./claude-observability.js"; import { ClaudeObservabilityLogger } from "./claude-observability.js";
export type RunProvider = "codex" | "claude"; export type RunProvider = "codex" | "claude";
@@ -17,7 +16,7 @@ export type RunProvider = "codex" | "claude";
export type ProviderRunRuntime = { export type ProviderRunRuntime = {
provider: RunProvider; provider: RunProvider;
config: Readonly<AppConfig>; config: Readonly<AppConfig>;
sessionContext: SessionContext; sharedEnv: Record<string, string>;
claudeObservability: ClaudeObservabilityLogger; claudeObservability: ClaudeObservabilityLogger;
close: () => Promise<void>; close: () => Promise<void>;
}; };
@@ -30,6 +29,16 @@ type ProviderUsage = {
costUsd?: number; costUsd?: number;
}; };
function sanitizeEnv(input: Record<string, string | undefined>): Record<string, string> {
const output: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
if (typeof value === "string") {
output[key] = value;
}
}
return output;
}
const ACTOR_RESPONSE_SCHEMA = { const ACTOR_RESPONSE_SCHEMA = {
type: "object", type: "object",
additionalProperties: true, additionalProperties: true,
@@ -74,8 +83,6 @@ const CLAUDE_OUTPUT_FORMAT = {
schema: ACTOR_RESPONSE_SCHEMA, schema: ACTOR_RESPONSE_SCHEMA,
} as const; } as const;
const CLAUDE_PROVIDER_MAX_TURNS = 2;
function toErrorMessage(error: unknown): string { function toErrorMessage(error: unknown): string {
if (error instanceof Error) { if (error instanceof Error) {
return error.message; return error.message;
@@ -83,6 +90,23 @@ function toErrorMessage(error: unknown): string {
return String(error); return String(error);
} }
export function resolveProviderWorkingDirectory(actorInput: ActorExecutionInput): string {
return actorInput.executionContext.security.worktreePath;
}
export function buildProviderRuntimeEnv(input: {
runtime: ProviderRunRuntime;
actorInput: ActorExecutionInput;
includeClaudeAuth?: boolean;
}): Record<string, string> {
const workingDirectory = resolveProviderWorkingDirectory(input.actorInput);
return sanitizeEnv({
...input.runtime.sharedEnv,
...(input.includeClaudeAuth ? buildClaudeAuthEnv(input.runtime.config.provider) : {}),
AGENT_WORKTREE_PATH: workingDirectory,
});
}
function toJsonValue(value: unknown): JsonValue { function toJsonValue(value: unknown): JsonValue {
return JSON.parse(JSON.stringify(value)) as JsonValue; return JSON.parse(JSON.stringify(value)) as JsonValue;
} }
@@ -367,6 +391,7 @@ async function runCodexActor(input: {
const prompt = buildActorPrompt(actorInput); const prompt = buildActorPrompt(actorInput);
const startedAt = Date.now(); const startedAt = Date.now();
const apiKey = resolveOpenAiApiKey(runtime.config.provider); const apiKey = resolveOpenAiApiKey(runtime.config.provider);
const workingDirectory = resolveProviderWorkingDirectory(actorInput);
const codex = new Codex({ const codex = new Codex({
...(apiKey ? { apiKey } : {}), ...(apiKey ? { apiKey } : {}),
@@ -376,20 +401,21 @@ async function runCodexActor(input: {
...(actorInput.mcp.resolvedConfig.codexConfig ...(actorInput.mcp.resolvedConfig.codexConfig
? { config: actorInput.mcp.resolvedConfig.codexConfig } ? { config: actorInput.mcp.resolvedConfig.codexConfig }
: {}), : {}),
env: runtime.sessionContext.runtimeInjection.env, env: buildProviderRuntimeEnv({
runtime,
actorInput,
}),
}); });
const thread = codex.startThread({ const thread = codex.startThread({
workingDirectory: runtime.sessionContext.runtimeInjection.workingDirectory, workingDirectory,
skipGitRepoCheck: runtime.config.provider.codexSkipGitCheck, skipGitRepoCheck: runtime.config.provider.codexSkipGitCheck,
}); });
const turn = await runtime.sessionContext.runInSession(() => const turn = await thread.run(prompt, {
thread.run(prompt, { signal: actorInput.signal,
signal: actorInput.signal, outputSchema: ACTOR_RESPONSE_SCHEMA,
outputSchema: ACTOR_RESPONSE_SCHEMA, });
}),
);
const usage: ProviderUsage = { const usage: ProviderUsage = {
...(turn.usage ...(turn.usage
@@ -457,6 +483,7 @@ function buildClaudeOptions(input: {
actorInput: ActorExecutionInput; actorInput: ActorExecutionInput;
}): Options { }): Options {
const { runtime, actorInput } = input; const { runtime, actorInput } = input;
const workingDirectory = resolveProviderWorkingDirectory(actorInput);
const authOptionOverrides = runtime.config.provider.anthropicOauthToken const authOptionOverrides = runtime.config.provider.anthropicOauthToken
? { authToken: runtime.config.provider.anthropicOauthToken } ? { authToken: runtime.config.provider.anthropicOauthToken }
@@ -465,14 +492,15 @@ function buildClaudeOptions(input: {
return token ? { apiKey: token } : {}; return token ? { apiKey: token } : {};
})(); })();
const runtimeEnv = { const runtimeEnv = buildProviderRuntimeEnv({
...runtime.sessionContext.runtimeInjection.env, runtime,
...buildClaudeAuthEnv(runtime.config.provider), actorInput,
}; includeClaudeAuth: true,
});
const traceContext = toClaudeTraceContext(actorInput); const traceContext = toClaudeTraceContext(actorInput);
return { return {
maxTurns: CLAUDE_PROVIDER_MAX_TURNS, maxTurns: runtime.config.provider.claudeMaxTurns,
...(runtime.config.provider.claudeModel ...(runtime.config.provider.claudeModel
? { model: runtime.config.provider.claudeModel } ? { model: runtime.config.provider.claudeModel }
: {}), : {}),
@@ -484,7 +512,7 @@ function buildClaudeOptions(input: {
? { mcpServers: actorInput.mcp.resolvedConfig.claudeMcpServers as Options["mcpServers"] } ? { mcpServers: actorInput.mcp.resolvedConfig.claudeMcpServers as Options["mcpServers"] }
: {}), : {}),
canUseTool: actorInput.mcp.createClaudeCanUseTool(), canUseTool: actorInput.mcp.createClaudeCanUseTool(),
cwd: runtime.sessionContext.runtimeInjection.workingDirectory, cwd: workingDirectory,
env: runtimeEnv, env: runtimeEnv,
...runtime.claudeObservability.toOptionOverrides({ ...runtime.claudeObservability.toOptionOverrides({
context: traceContext, context: traceContext,
@@ -507,8 +535,8 @@ async function runClaudeTurn(input: {
context: traceContext, context: traceContext,
data: { data: {
...(options.model ? { model: options.model } : {}), ...(options.model ? { model: options.model } : {}),
maxTurns: options.maxTurns ?? CLAUDE_PROVIDER_MAX_TURNS, maxTurns: options.maxTurns ?? input.runtime.config.provider.claudeMaxTurns,
cwd: input.runtime.sessionContext.runtimeInjection.workingDirectory, ...(typeof options.cwd === "string" ? { cwd: options.cwd } : {}),
}, },
}); });
@@ -605,13 +633,11 @@ async function runClaudeActor(input: {
actorInput: ActorExecutionInput; actorInput: ActorExecutionInput;
}): Promise<ActorExecutionResult> { }): Promise<ActorExecutionResult> {
const prompt = buildActorPrompt(input.actorInput); const prompt = buildActorPrompt(input.actorInput);
const turn = await input.runtime.sessionContext.runInSession(() => const turn = await runClaudeTurn({
runClaudeTurn({ runtime: input.runtime,
runtime: input.runtime, actorInput: input.actorInput,
actorInput: input.actorInput, prompt,
prompt, });
}),
);
const parsed = parseActorExecutionResultFromModelOutput({ const parsed = parseActorExecutionResultFromModelOutput({
rawText: turn.text, rawText: turn.text,
@@ -626,33 +652,21 @@ async function runClaudeActor(input: {
export async function createProviderRunRuntime(input: { export async function createProviderRunRuntime(input: {
provider: RunProvider; provider: RunProvider;
initialPrompt: string;
config: Readonly<AppConfig>; config: Readonly<AppConfig>;
projectPath: string;
observabilityRootPath?: string; observabilityRootPath?: string;
baseEnv?: Record<string, string | undefined>;
}): Promise<ProviderRunRuntime> { }): Promise<ProviderRunRuntime> {
const sessionContext = await createSessionContext(input.provider, {
prompt: input.initialPrompt,
config: input.config,
workspaceRoot: input.projectPath,
});
const claudeObservability = new ClaudeObservabilityLogger({ const claudeObservability = new ClaudeObservabilityLogger({
workspaceRoot: input.observabilityRootPath ?? input.projectPath, workspaceRoot: input.observabilityRootPath ?? process.cwd(),
config: input.config.provider.claudeObservability, config: input.config.provider.claudeObservability,
}); });
return { return {
provider: input.provider, provider: input.provider,
config: input.config, config: input.config,
sessionContext, sharedEnv: sanitizeEnv(input.baseEnv ?? process.env),
claudeObservability, claudeObservability,
close: async () => { close: async () => claudeObservability.close(),
try {
await sessionContext.close();
} finally {
await claudeObservability.close();
}
},
}; };
} }

View File

@@ -202,6 +202,7 @@
<select id="cfg-security-mode"> <select id="cfg-security-mode">
<option value="hard_abort">hard_abort</option> <option value="hard_abort">hard_abort</option>
<option value="validation_fail">validation_fail</option> <option value="validation_fail">validation_fail</option>
<option value="dangerous_warn_only">dangerous_warn_only</option>
</select> </select>
</label> </label>
<label> <label>

View File

@@ -359,6 +359,7 @@ export class UiRunService {
worktreeManager: new SessionWorktreeManager({ worktreeManager: new SessionWorktreeManager({
worktreeRoot: paths.worktreeRoot, worktreeRoot: paths.worktreeRoot,
baseRef: config.provisioning.gitWorktree.baseRef, baseRef: config.provisioning.gitWorktree.baseRef,
targetPath: config.provisioning.gitWorktree.targetPath,
}), }),
}; };
} }
@@ -485,10 +486,9 @@ export class UiRunService {
if (executionMode === "provider") { if (executionMode === "provider") {
providerRuntime = await createProviderRunRuntime({ providerRuntime = await createProviderRunRuntime({
provider, provider,
initialPrompt: input.prompt,
config, config,
projectPath: session?.baseWorkspacePath ?? this.workspaceRoot,
observabilityRootPath: this.workspaceRoot, observabilityRootPath: this.workspaceRoot,
baseEnv: process.env,
}); });
} }

View File

@@ -25,6 +25,7 @@ test("loads defaults and freezes config", () => {
"session.failed", "session.failed",
]); ]);
assert.equal(config.provider.openAiAuthMode, "auto"); assert.equal(config.provider.openAiAuthMode, "auto");
assert.equal(config.provider.claudeMaxTurns, 2);
assert.equal(config.provider.claudeObservability.mode, "off"); assert.equal(config.provider.claudeObservability.mode, "off");
assert.equal(config.provider.claudeObservability.verbosity, "summary"); assert.equal(config.provider.claudeObservability.verbosity, "summary");
assert.equal(config.provider.claudeObservability.logPath, ".ai_ops/events/claude-trace.ndjson"); assert.equal(config.provider.claudeObservability.logPath, ".ai_ops/events/claude-trace.ndjson");
@@ -55,6 +56,11 @@ test("validates security violation mode", () => {
); );
}); });
test("loads dangerous_warn_only security violation mode", () => {
const config = loadConfig({ AGENT_SECURITY_VIOLATION_MODE: "dangerous_warn_only" });
assert.equal(config.security.violationHandling, "dangerous_warn_only");
});
test("validates runtime discord severity mode", () => { test("validates runtime discord severity mode", () => {
assert.throws( assert.throws(
() => loadConfig({ AGENT_RUNTIME_DISCORD_MIN_SEVERITY: "verbose" }), () => loadConfig({ AGENT_RUNTIME_DISCORD_MIN_SEVERITY: "verbose" }),
@@ -69,6 +75,13 @@ test("validates claude observability mode", () => {
); );
}); });
test("validates CLAUDE_MAX_TURNS bounds", () => {
assert.throws(
() => loadConfig({ CLAUDE_MAX_TURNS: "0" }),
/CLAUDE_MAX_TURNS must be an integer >= 1/,
);
});
test("validates claude observability verbosity", () => { test("validates claude observability verbosity", () => {
assert.throws( assert.throws(
() => loadConfig({ CLAUDE_OBSERVABILITY_VERBOSITY: "verbose" }), () => loadConfig({ CLAUDE_OBSERVABILITY_VERBOSITY: "verbose" }),

View File

@@ -380,6 +380,7 @@ test("injects resolved mcp/helpers and enforces Claude tool gate in actor execut
); );
assert.deepEqual(allow, { assert.deepEqual(allow, {
behavior: "allow", behavior: "allow",
updatedInput: {},
toolUseID: "allow-1", toolUseID: "allow-1",
}); });
@@ -997,6 +998,7 @@ test("createClaudeCanUseTool accepts tool casing differences from providers", as
}); });
assert.deepEqual(allow, { assert.deepEqual(allow, {
behavior: "allow", behavior: "allow",
updatedInput: {},
toolUseID: "allow-bash", toolUseID: "allow-bash",
}); });
@@ -1020,6 +1022,88 @@ test("createClaudeCanUseTool accepts tool casing differences from providers", as
assert.equal(result.status, "success"); assert.equal(result.status, "success");
}); });
test("dangerous_warn_only allows tool use outside persona allowlist", async () => {
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-workspace-"));
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-state-"));
const projectContextPath = resolve(stateRoot, "project-context.json");
const manifest = {
schemaVersion: "1",
topologies: ["sequential"],
personas: [
{
id: "reader",
displayName: "Reader",
systemPromptTemplate: "Reader",
toolClearance: {
allowlist: ["read_file"],
banlist: [],
},
},
],
relationships: [],
topologyConstraints: {
maxDepth: 2,
maxRetries: 0,
},
pipeline: {
entryNodeId: "warn-node",
nodes: [
{
id: "warn-node",
actorId: "warn_actor",
personaId: "reader",
},
],
edges: [],
},
} as const;
const engine = new SchemaDrivenExecutionEngine({
manifest,
settings: {
workspaceRoot,
stateRoot,
projectContextPath,
maxChildren: 1,
maxDepth: 2,
maxRetries: 0,
securityViolationHandling: "dangerous_warn_only",
runtimeContext: {},
},
actorExecutors: {
warn_actor: async (input) => {
const canUseTool = input.mcp.createClaudeCanUseTool();
const allow = await canUseTool("Bash", {}, {
signal: new AbortController().signal,
toolUseID: "allow-bash-warn",
});
assert.deepEqual(allow, {
behavior: "allow",
updatedInput: {},
toolUseID: "allow-bash-warn",
});
return {
status: "success",
payload: {
ok: true,
},
};
},
},
});
const result = await engine.runSession({
sessionId: "session-dangerous-warn-only",
initialPayload: {
task: "verify warn-only bypass",
},
});
assert.equal(result.status, "success");
});
test("hard-aborts pipeline on security violations by default", async () => { test("hard-aborts pipeline on security violations by default", async () => {
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-workspace-")); const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-workspace-"));
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-state-")); const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-state-"));

View File

@@ -160,6 +160,7 @@ test("runClaudePrompt wires auth env, stream parsing, and output", async () => {
ANTHROPIC_API_KEY: "legacy-api-key", ANTHROPIC_API_KEY: "legacy-api-key",
CLAUDE_MODEL: "claude-sonnet-4-6", CLAUDE_MODEL: "claude-sonnet-4-6",
CLAUDE_CODE_PATH: "/usr/local/bin/claude", CLAUDE_CODE_PATH: "/usr/local/bin/claude",
CLAUDE_MAX_TURNS: "5",
}); });
let closed = false; let closed = false;
@@ -229,6 +230,7 @@ test("runClaudePrompt wires auth env, stream parsing, and output", async () => {
assert.equal(queryInput?.prompt, "augmented prompt"); assert.equal(queryInput?.prompt, "augmented prompt");
assert.equal(queryInput?.options?.model, "claude-sonnet-4-6"); assert.equal(queryInput?.options?.model, "claude-sonnet-4-6");
assert.equal(queryInput?.options?.pathToClaudeCodeExecutable, "/usr/local/bin/claude"); assert.equal(queryInput?.options?.pathToClaudeCodeExecutable, "/usr/local/bin/claude");
assert.equal(queryInput?.options?.maxTurns, 5);
assert.equal(queryInput?.options?.cwd, "/tmp/claude-worktree"); assert.equal(queryInput?.options?.cwd, "/tmp/claude-worktree");
assert.equal(queryInput?.options?.authToken, "oauth-token"); assert.equal(queryInput?.options?.authToken, "oauth-token");
assert.deepEqual(queryInput?.options?.mcpServers, sessionContext.mcp.claudeMcpServers); assert.deepEqual(queryInput?.options?.mcpServers, sessionContext.mcp.claudeMcpServers);

View File

@@ -1,6 +1,17 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { parseActorExecutionResultFromModelOutput } from "../src/ui/provider-executor.js"; import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { loadConfig } from "../src/config.js";
import type { ActorExecutionInput } from "../src/agents/pipeline.js";
import {
buildProviderRuntimeEnv,
createProviderRunRuntime,
parseActorExecutionResultFromModelOutput,
resolveProviderWorkingDirectory,
type ProviderRunRuntime,
} from "../src/ui/provider-executor.js";
test("parseActorExecutionResultFromModelOutput parses strict JSON payload", () => { test("parseActorExecutionResultFromModelOutput parses strict JSON payload", () => {
const parsed = parseActorExecutionResultFromModelOutput({ const parsed = parseActorExecutionResultFromModelOutput({
@@ -64,3 +75,71 @@ test("parseActorExecutionResultFromModelOutput falls back when response is not J
assert.equal(parsed.status, "success"); assert.equal(parsed.status, "success");
assert.equal(parsed.payload?.assistantResponse, "Implemented update successfully."); assert.equal(parsed.payload?.assistantResponse, "Implemented update successfully.");
}); });
test("resolveProviderWorkingDirectory reads cwd from actor execution context", () => {
const actorInput = {
executionContext: {
security: {
worktreePath: "/tmp/session/tasks/product-intake",
},
},
} as unknown as ActorExecutionInput;
assert.equal(
resolveProviderWorkingDirectory(actorInput),
"/tmp/session/tasks/product-intake",
);
});
test("buildProviderRuntimeEnv scopes AGENT_WORKTREE_PATH to actor worktree and filters undefined auth", () => {
const config = loadConfig({
CLAUDE_CODE_OAUTH_TOKEN: "oauth-token",
});
const runtime = {
provider: "claude",
config,
sharedEnv: {
PATH: "/usr/bin",
KEEP_ME: "1",
},
claudeObservability: {} as ProviderRunRuntime["claudeObservability"],
close: async () => {},
} satisfies ProviderRunRuntime;
const actorInput = {
executionContext: {
security: {
worktreePath: "/tmp/session/tasks/product-intake",
},
},
} as unknown as ActorExecutionInput;
const env = buildProviderRuntimeEnv({
runtime,
actorInput,
includeClaudeAuth: true,
});
assert.equal(env.AGENT_WORKTREE_PATH, "/tmp/session/tasks/product-intake");
assert.equal(env.CLAUDE_CODE_OAUTH_TOKEN, "oauth-token");
assert.equal("ANTHROPIC_API_KEY" in env, false);
assert.equal(env.KEEP_ME, "1");
});
test("createProviderRunRuntime does not require session context provisioning", async () => {
const observabilityRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-provider-runtime-"));
const runtime = await createProviderRunRuntime({
provider: "claude",
config: loadConfig({}),
observabilityRootPath: observabilityRoot,
baseEnv: {
PATH: "/usr/bin",
},
});
try {
assert.equal(runtime.provider, "claude");
assert.equal(runtime.sharedEnv.PATH, "/usr/bin");
} finally {
await runtime.close();
}
});

View File

@@ -111,6 +111,42 @@ test("rules engine enforces binary allowlist, tool policy, and path boundaries",
); );
}); });
test("rules engine dangerous_warn_only logs but does not block violating shell commands", async () => {
const worktreeRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-warn-worktree-"));
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-warn-state-"));
const projectContextPath = resolve(stateRoot, "project-context.json");
const rules = new SecurityRulesEngine(
{
allowedBinaries: ["git"],
worktreeRoot,
protectedPaths: [stateRoot, projectContextPath],
requireCwdWithinWorktree: true,
rejectRelativePathTraversal: true,
enforcePathBoundaryOnArguments: true,
allowedEnvAssignments: [],
blockedEnvAssignments: [],
},
undefined,
{
violationHandling: "dangerous_warn_only",
},
);
const validated = await rules.validateShellCommand({
command: "unauthorized_bin --version",
cwd: worktreeRoot,
toolClearance: {
allowlist: ["git"],
banlist: [],
},
});
assert.equal(validated.cwd, worktreeRoot);
assert.equal(validated.parsed.commandCount, 0);
assert.deepEqual(validated.parsed.commands, []);
});
test("secure executor runs with explicit env policy", async () => { test("secure executor runs with explicit env policy", async () => {
const worktreeRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-exec-")); const worktreeRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-exec-"));
@@ -193,3 +229,47 @@ test("rules engine carries session context in tool audit events", () => {
assert.equal(allowedEvent.nodeId, "node-ctx"); assert.equal(allowedEvent.nodeId, "node-ctx");
assert.equal(allowedEvent.attempt, 2); assert.equal(allowedEvent.attempt, 2);
}); });
test("rules engine applies tool clearance matching case-insensitively", () => {
const rules = new SecurityRulesEngine({
allowedBinaries: ["git"],
worktreeRoot: "/tmp",
protectedPaths: [],
requireCwdWithinWorktree: true,
rejectRelativePathTraversal: true,
enforcePathBoundaryOnArguments: true,
allowedEnvAssignments: [],
blockedEnvAssignments: [],
});
assert.doesNotThrow(() =>
rules.assertToolInvocationAllowed({
tool: "Bash",
toolClearance: {
allowlist: ["bash", "glob"],
banlist: [],
},
}),
);
assert.throws(
() =>
rules.assertToolInvocationAllowed({
tool: "Glob",
toolClearance: {
allowlist: ["bash", "glob"],
banlist: ["GLOB"],
},
}),
(error: unknown) =>
error instanceof SecurityViolationError && error.code === "TOOL_BANNED",
);
assert.deepEqual(
rules.filterAllowedTools(["Bash", "Glob", "Read"], {
allowlist: ["bash", "glob"],
banlist: ["gLoB"],
}),
["Bash"],
);
});

View File

@@ -228,3 +228,60 @@ test("session worktree manager recreates a task worktree after stale metadata pr
const stats = await stat(recreatedTaskWorktreePath); const stats = await stat(recreatedTaskWorktreePath);
assert.equal(stats.isDirectory(), true); assert.equal(stats.isDirectory(), true);
}); });
test("session worktree manager applies target path sparse checkout and task working directory", async () => {
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-session-worktree-target-"));
const projectPath = resolve(root, "project");
const worktreeRoot = resolve(root, "worktrees");
await mkdir(resolve(projectPath, "app", "src"), { recursive: true });
await mkdir(resolve(projectPath, "infra"), { recursive: true });
await git(["init", projectPath]);
await git(["-C", projectPath, "config", "user.name", "AI Ops"]);
await git(["-C", projectPath, "config", "user.email", "ai-ops@example.local"]);
await writeFile(resolve(projectPath, "app", "src", "index.ts"), "export const app = true;\n", "utf8");
await writeFile(resolve(projectPath, "infra", "notes.txt"), "infra\n", "utf8");
await git(["-C", projectPath, "add", "."]);
await git(["-C", projectPath, "commit", "-m", "initial commit"]);
const manager = new SessionWorktreeManager({
worktreeRoot,
baseRef: "HEAD",
targetPath: "app",
});
const sessionId = "session-target-1";
const baseWorkspacePath = manager.resolveBaseWorkspacePath(sessionId);
await manager.initializeSessionBaseWorkspace({
sessionId,
projectPath,
baseWorkspacePath,
});
const baseWorkingDirectory = manager.resolveWorkingDirectoryForWorktree(baseWorkspacePath);
assert.equal(baseWorkingDirectory, resolve(baseWorkspacePath, "app"));
const baseWorkingStats = await stat(baseWorkingDirectory);
assert.equal(baseWorkingStats.isDirectory(), true);
await assert.rejects(() => stat(resolve(baseWorkspacePath, "infra")), {
code: "ENOENT",
});
const ensured = await manager.ensureTaskWorktree({
sessionId,
taskId: "task-target-1",
baseWorkspacePath,
});
assert.equal(ensured.taskWorkingDirectory, resolve(ensured.taskWorktreePath, "app"));
await writeFile(resolve(ensured.taskWorkingDirectory, "src", "feature.ts"), "export const feature = true;\n", "utf8");
const mergeOutcome = await manager.mergeTaskIntoBase({
taskId: "task-target-1",
baseWorkspacePath,
taskWorktreePath: ensured.taskWorktreePath,
});
assert.equal(mergeOutcome.kind, "success");
const merged = await readFile(resolve(baseWorkingDirectory, "src", "feature.ts"), "utf8");
assert.equal(merged, "export const feature = true;\n");
});