codex-workflow-v2 2.0.0-beta.13.8 → 2.0.0-beta.14
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.
- package/README.md +27 -388
- package/dist/reviewer-runtime-build.json +96 -32
- package/dist/src/alpha6/adoption.d.ts +2 -0
- package/dist/src/alpha6/adoption.js +22 -0
- package/dist/src/alpha6/adoption.js.map +1 -1
- package/dist/src/alpha6/captured-check-evidence.d.ts +51 -0
- package/dist/src/alpha6/captured-check-evidence.js +152 -0
- package/dist/src/alpha6/captured-check-evidence.js.map +1 -0
- package/dist/src/alpha6/component-owner.d.ts +15 -1
- package/dist/src/alpha6/component-owner.js +44 -2
- package/dist/src/alpha6/component-owner.js.map +1 -1
- package/dist/src/alpha6/corrective-decision-boundary.d.ts +4 -0
- package/dist/src/alpha6/corrective-decision-boundary.js +47 -0
- package/dist/src/alpha6/corrective-decision-boundary.js.map +1 -0
- package/dist/src/alpha6/downstream-proof.d.ts +1 -25
- package/dist/src/alpha6/downstream-proof.js +0 -171
- package/dist/src/alpha6/downstream-proof.js.map +1 -1
- package/dist/src/alpha6/failed-step-planning-recovery.d.ts +10 -0
- package/dist/src/alpha6/failed-step-planning-recovery.js +93 -0
- package/dist/src/alpha6/failed-step-planning-recovery.js.map +1 -0
- package/dist/src/alpha6/literal-test-invocation.d.ts +2 -0
- package/dist/src/alpha6/literal-test-invocation.js +106 -0
- package/dist/src/alpha6/literal-test-invocation.js.map +1 -0
- package/dist/src/alpha6/milestone.d.ts +2 -0
- package/dist/src/alpha6/milestone.js +10 -1
- package/dist/src/alpha6/milestone.js.map +1 -1
- package/dist/src/alpha6/navigation-dirty-carryover.d.ts +11 -0
- package/dist/src/alpha6/navigation-dirty-carryover.js +130 -0
- package/dist/src/alpha6/navigation-dirty-carryover.js.map +1 -0
- package/dist/src/alpha6/plan-integrity.js +7 -5
- package/dist/src/alpha6/plan-integrity.js.map +1 -1
- package/dist/src/alpha6/plan-risk.d.ts +2 -0
- package/dist/src/alpha6/plan-risk.js +10 -3
- package/dist/src/alpha6/plan-risk.js.map +1 -1
- package/dist/src/alpha6/remediation.d.ts +6 -21
- package/dist/src/alpha6/remediation.js +161 -476
- package/dist/src/alpha6/remediation.js.map +1 -1
- package/dist/src/alpha6/root-cause-replan-carryover.d.ts +4 -1
- package/dist/src/alpha6/root-cause-replan-carryover.js +29 -5
- package/dist/src/alpha6/root-cause-replan-carryover.js.map +1 -1
- package/dist/src/beta1/project-transaction.d.ts +3 -1
- package/dist/src/beta1/project-transaction.js +6 -2
- package/dist/src/beta1/project-transaction.js.map +1 -1
- package/dist/src/checks/runner.d.ts +26 -0
- package/dist/src/checks/runner.js +223 -0
- package/dist/src/checks/runner.js.map +1 -0
- package/dist/src/checks/task-sync.d.ts +10 -0
- package/dist/src/checks/task-sync.js +41 -0
- package/dist/src/checks/task-sync.js.map +1 -0
- package/dist/src/checks/worker.d.ts +1 -0
- package/dist/src/checks/worker.js +154 -0
- package/dist/src/checks/worker.js.map +1 -0
- package/dist/src/cli-actions.d.ts +2 -2
- package/dist/src/cli-actions.js +3 -15
- package/dist/src/cli-actions.js.map +1 -1
- package/dist/src/cli.js +14 -98
- package/dist/src/cli.js.map +1 -1
- package/dist/src/contracts.d.ts +35 -32
- package/dist/src/dependency-provenance.d.ts +2 -2
- package/dist/src/dependency-provenance.js +12 -69
- package/dist/src/dependency-provenance.js.map +1 -1
- package/dist/src/domain/base-sync-conflict.d.ts +13 -0
- package/dist/src/domain/base-sync-conflict.js +32 -0
- package/dist/src/domain/base-sync-conflict.js.map +1 -0
- package/dist/src/domain/step-start-admission.d.ts +4 -0
- package/dist/src/domain/step-start-admission.js +9 -0
- package/dist/src/domain/step-start-admission.js.map +1 -0
- package/dist/src/domain/virgin-registration.d.ts +3 -0
- package/dist/src/domain/virgin-registration.js +65 -0
- package/dist/src/domain/virgin-registration.js.map +1 -0
- package/dist/src/errors.d.ts +1 -1
- package/dist/src/errors.js.map +1 -1
- package/dist/src/gateway-handshake.js +0 -8
- package/dist/src/gateway-handshake.js.map +1 -1
- package/dist/src/git.d.ts +3 -1
- package/dist/src/git.js +41 -14
- package/dist/src/git.js.map +1 -1
- package/dist/src/graph.js +25 -3
- package/dist/src/graph.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/lifecycle/corrective-replan.js +3 -3
- package/dist/src/lifecycle/corrective-replan.js.map +1 -1
- package/dist/src/navigation-actions.d.ts +9 -0
- package/dist/src/navigation-actions.js +72 -0
- package/dist/src/navigation-actions.js.map +1 -0
- package/dist/src/navigation-update-artifact.d.ts +5 -0
- package/dist/src/navigation-update-artifact.js +227 -0
- package/dist/src/navigation-update-artifact.js.map +1 -0
- package/dist/src/navigation-update.d.ts +39 -0
- package/dist/src/navigation-update.js +82 -0
- package/dist/src/navigation-update.js.map +1 -0
- package/dist/src/observation.js +52 -29
- package/dist/src/observation.js.map +1 -1
- package/dist/src/observed-routes.js +5 -6
- package/dist/src/observed-routes.js.map +1 -1
- package/dist/src/pending-review-update.d.ts +0 -13
- package/dist/src/pending-review-update.js +1 -6
- package/dist/src/pending-review-update.js.map +1 -1
- package/dist/src/reviewer.d.ts +1 -1
- package/dist/src/reviewer.js +20 -17
- package/dist/src/reviewer.js.map +1 -1
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +6 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/state/corrective-replan-executor.d.ts +10 -0
- package/dist/src/state/corrective-replan-executor.js +37 -1
- package/dist/src/state/corrective-replan-executor.js.map +1 -1
- package/dist/src/state/corrective-replan-public-schema.js +15 -2
- package/dist/src/state/corrective-replan-public-schema.js.map +1 -1
- package/dist/src/state/corrective-replan-public.js +6 -5
- package/dist/src/state/corrective-replan-public.js.map +1 -1
- package/dist/src/state/corrective-replan-transaction.d.ts +1 -0
- package/dist/src/state/corrective-replan-transaction.js +12 -11
- package/dist/src/state/corrective-replan-transaction.js.map +1 -1
- package/dist/src/state/corrective-yield-executor.js +2 -2
- package/dist/src/state/corrective-yield-executor.js.map +1 -1
- package/dist/src/state/corrective-yield-transaction.d.ts +1 -0
- package/dist/src/state/corrective-yield-transaction.js +11 -10
- package/dist/src/state/corrective-yield-transaction.js.map +1 -1
- package/dist/src/state/lock.d.ts +4 -0
- package/dist/src/state/lock.js +21 -0
- package/dist/src/state/lock.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/src/workflow-blocker-route.d.ts +8 -0
- package/dist/src/workflow-blocker-route.js +106 -0
- package/dist/src/workflow-blocker-route.js.map +1 -0
- package/dist/src/workflow.d.ts +48 -63
- package/dist/src/workflow.js +698 -1470
- package/dist/src/workflow.js.map +1 -1
- package/docs/autonomy-guardrails.md +26 -304
- package/docs/decisions.md +14 -112
- package/docs/development-flow.md +35 -238
- package/docs/project-memory.md +31 -50
- package/docs/release-app-evidence.md +190 -0
- package/docs/release.md +95 -363
- package/docs/updating-existing-project.md +19 -717
- package/package.json +11 -13
- package/plugins/codex-workflow-gateway/.codex-plugin/plugin.json +2 -2
- package/plugins/codex-workflow-gateway/references/chat-dispatch.md +73 -197
- package/plugins/codex-workflow-gateway/references/codebase-memory-routing.md +57 -0
- package/plugins/codex-workflow-gateway/references/protocol.md +42 -445
- package/plugins/codex-workflow-gateway/scripts/chat-dispatch.mjs +7 -1
- package/plugins/codex-workflow-gateway/scripts/chat-model-policy.mjs +10 -8
- package/plugins/codex-workflow-gateway/scripts/chat-registry.mjs +23 -5
- package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +66 -740
- package/references/state-machine.md +4 -4
- package/roles/technical-planner.md +1 -1
- package/schemas/corrective-decision-event.schema.json +3 -1
- package/schemas/project-knowledge-map.schema.json +3 -1
- package/schemas/remediation-event.schema.json +10 -1
- package/schemas/task.schema.json +46 -2
- package/schemas/transition-payloads.schema.json +16 -1
- package/src/alpha6/adoption.ts +1123 -0
- package/src/alpha6/captured-check-evidence.ts +132 -0
- package/src/alpha6/check-support-anchor.ts +128 -0
- package/src/alpha6/component-owner.ts +168 -0
- package/src/alpha6/corrective-decision-boundary.ts +39 -0
- package/src/alpha6/downstream-proof.ts +520 -0
- package/src/alpha6/failed-step-planning-recovery.ts +88 -0
- package/src/alpha6/handoff.ts +1443 -0
- package/src/alpha6/journal.ts +473 -0
- package/src/alpha6/literal-test-invocation.ts +84 -0
- package/src/alpha6/mechanical-feasibility.ts +488 -0
- package/src/alpha6/milestone.ts +2192 -0
- package/src/alpha6/navigation-dirty-carryover.ts +154 -0
- package/src/alpha6/npm-check-contract.ts +47 -0
- package/src/alpha6/plan-integrity.ts +298 -0
- package/src/alpha6/plan-risk.ts +1480 -0
- package/src/alpha6/preexecution-replan.ts +187 -0
- package/src/alpha6/remediation-cause.ts +98 -0
- package/src/alpha6/remediation.ts +2438 -0
- package/src/alpha6/review.ts +1198 -0
- package/src/alpha6/root-cause-replan-carryover.ts +491 -0
- package/src/alpha6/store-sidecars.ts +335 -0
- package/src/alpha7/autonomy.ts +411 -0
- package/src/alpha7/corrective-recovery.ts +1332 -0
- package/src/artifacts.ts +130 -0
- package/src/beta1/project-transaction.ts +355 -0
- package/src/change-explanation.ts +153 -0
- package/src/checks/runner.ts +245 -0
- package/src/checks/task-sync.ts +41 -0
- package/src/checks/worker.ts +149 -0
- package/src/cli-actions.ts +107 -0
- package/src/cli.ts +1457 -0
- package/src/contracts.ts +1494 -0
- package/src/credential-output.ts +89 -0
- package/src/credential-transport.ts +215 -0
- package/src/delegation.ts +190 -0
- package/src/dependency-provenance.ts +472 -0
- package/src/diagnostics.ts +93 -0
- package/src/domain/base-sync-conflict.ts +35 -0
- package/src/domain/completed-step-carryover.ts +76 -0
- package/src/domain/discovery.ts +27 -0
- package/src/domain/plan-semantics.ts +58 -0
- package/src/domain/step-start-admission.ts +10 -0
- package/src/domain/validation.ts +177 -0
- package/src/domain/virgin-registration.ts +46 -0
- package/src/errors.ts +24 -0
- package/src/fs-utils.ts +61 -0
- package/src/gateway-handshake.ts +95 -0
- package/src/git.ts +183 -0
- package/src/graph.ts +342 -0
- package/src/historical-step-provenance.ts +136 -0
- package/src/index.ts +23 -0
- package/src/lifecycle/canonical-hash.ts +28 -0
- package/src/lifecycle/catalog.ts +202 -0
- package/src/lifecycle/compiler-inspection.ts +29 -0
- package/src/lifecycle/core-static-readiness.ts +132 -0
- package/src/lifecycle/corrective-replan-authority.ts +136 -0
- package/src/lifecycle/corrective-replan-binding-manifest.ts +51 -0
- package/src/lifecycle/corrective-replan-credential-core.ts +408 -0
- package/src/lifecycle/corrective-replan-credential-schema.ts +54 -0
- package/src/lifecycle/corrective-replan-credentials.ts +48 -0
- package/src/lifecycle/corrective-replan.ts +843 -0
- package/src/lifecycle/evaluator.ts +48 -0
- package/src/lifecycle/fingerprint.ts +488 -0
- package/src/lifecycle/immutable.ts +8 -0
- package/src/lifecycle/implementation-table.ts +118 -0
- package/src/lifecycle/index.ts +8 -0
- package/src/lifecycle/schema-artifact.ts +263 -0
- package/src/lifecycle/semantic-registry.ts +572 -0
- package/src/lifecycle/types.ts +838 -0
- package/src/memory.ts +273 -0
- package/src/migration.ts +161 -0
- package/src/navigation-actions.ts +70 -0
- package/src/navigation-update-artifact.ts +198 -0
- package/src/navigation-update.ts +121 -0
- package/src/observation.ts +225 -0
- package/src/observed-routes.ts +660 -0
- package/src/operational-contract.ts +125 -0
- package/src/pending-review-update.ts +175 -0
- package/src/repository.ts +99 -0
- package/src/reviewer.ts +1879 -0
- package/src/runtime.ts +6 -0
- package/src/state/corrective-replan-executor.ts +818 -0
- package/src/state/corrective-replan-public-schema.ts +83 -0
- package/src/state/corrective-replan-public.ts +908 -0
- package/src/state/corrective-replan-transaction.ts +949 -0
- package/src/state/corrective-yield-executor.ts +327 -0
- package/src/state/corrective-yield-transaction.ts +730 -0
- package/src/state/lock.ts +902 -0
- package/src/state/store.ts +567 -0
- package/src/transition-core.ts +330 -0
- package/src/ulid.ts +24 -0
- package/src/version.ts +2 -0
- package/src/workflow-blocker-route.ts +109 -0
- package/src/workflow.ts +10172 -0
- package/docs/alpha7.1-implementation-brief.md +0 -268
- package/docs/alpha7.2-corrective-context-refresh-brief.md +0 -484
- package/docs/alpha7.2.1-remediation-recovery-brief.md +0 -86
- package/docs/beta1-stabilization-brief.md +0 -165
- package/docs/beta11-plan-integrity-recovery-brief.md +0 -38
- package/docs/beta13.2-signal-review-recovery.md +0 -38
- package/docs/beta2-initial-assembly-navigation-brief.md +0 -616
- package/docs/change-model.md +0 -118
- package/docs/delegated-approval.md +0 -254
- package/docs/lifecycle/state-machine-stabilization.md +0 -641
- package/docs/pdf/README.md +0 -24
- package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +0 -0
- package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +0 -0
- package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +0 -0
- package/docs/pdf/requirements.txt +0 -1
- package/docs/pdf/sources/codex-workflow-v2-architecture-ru.md +0 -478
- package/docs/pdf/sources/codex-workflow-v2-chat-only-guide-ru.md +0 -508
- package/docs/pdf/sources/codex-workflow-v2-technical-reference-ru.md +0 -778
- package/docs/pending-review-update.md +0 -15
- package/docs/problem-briefs/01-pre-implementation-integrity.md +0 -482
- package/docs/problem-briefs/02-minimal-step-integrity.md +0 -411
- package/docs/problem-briefs/03-minimal-agent-context-integrity.md +0 -358
- package/docs/problem-briefs/04-task-dependency-and-structural-replacement-integrity.md +0 -573
- package/docs/problem-briefs/BRIEF-TEMPLATE.md +0 -56
- package/docs/problem-briefs/README.md +0 -120
- package/docs/problem-briefs/evidence/p01-mechanical-feasibility-corpus.md +0 -90
- package/docs/problem-briefs/evidence/signal-v4-pre-m3-replay.md +0 -246
- package/docs/split-required-recovery.md +0 -47
- package/docs/stable-release-defect-register.md +0 -730
- package/docs/validation-report.md +0 -182
- package/scripts/generate-pdf-docs.py +0 -524
- package/scripts/run-pdf-docs.mjs +0 -62
package/src/artifacts.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { DiscoveryState, TaskPlanInput, TaskState } from './contracts.js';
|
|
2
|
+
|
|
3
|
+
function list(values: string[], fallback = 'None.'): string {
|
|
4
|
+
return values.length > 0 ? values.map((value) => `- ${value}`).join('\n') : fallback;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function renderBrief(discovery: DiscoveryState, title: string): string {
|
|
8
|
+
return [
|
|
9
|
+
`# ${title} Brief`,
|
|
10
|
+
'',
|
|
11
|
+
'## Goal',
|
|
12
|
+
'',
|
|
13
|
+
discovery.goal,
|
|
14
|
+
'',
|
|
15
|
+
'## Observable Outcome',
|
|
16
|
+
'',
|
|
17
|
+
discovery.outcome ?? '',
|
|
18
|
+
'',
|
|
19
|
+
'## Requirements',
|
|
20
|
+
'',
|
|
21
|
+
...discovery.acceptance.map((value, index) => `- REQ-${String(index + 1).padStart(3, '0')}: ${value}`),
|
|
22
|
+
'',
|
|
23
|
+
'## Scope',
|
|
24
|
+
'',
|
|
25
|
+
list(discovery.scope),
|
|
26
|
+
'',
|
|
27
|
+
'## Out of Scope',
|
|
28
|
+
'',
|
|
29
|
+
list(discovery.outOfScope),
|
|
30
|
+
'',
|
|
31
|
+
'## Constraints',
|
|
32
|
+
'',
|
|
33
|
+
list(discovery.constraints),
|
|
34
|
+
'',
|
|
35
|
+
'## Acceptance Scenarios',
|
|
36
|
+
'',
|
|
37
|
+
...discovery.acceptance.map((value, index) => `- AC-${String(index + 1).padStart(3, '0')}: ${value}`),
|
|
38
|
+
'',
|
|
39
|
+
'## Assumptions',
|
|
40
|
+
'',
|
|
41
|
+
list(discovery.assumptions),
|
|
42
|
+
'',
|
|
43
|
+
'## Non-Blocking Unknowns',
|
|
44
|
+
'',
|
|
45
|
+
list(discovery.unknowns.filter((value) => !discovery.blockingUnknowns.includes(value))),
|
|
46
|
+
'',
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function renderPlan(task: TaskState, plan: TaskPlanInput): string {
|
|
51
|
+
const lines = [
|
|
52
|
+
`# ${task.id} Plan`,
|
|
53
|
+
'',
|
|
54
|
+
'## Objective',
|
|
55
|
+
'',
|
|
56
|
+
plan.objective,
|
|
57
|
+
'',
|
|
58
|
+
'## Requirements',
|
|
59
|
+
'',
|
|
60
|
+
...plan.requirements.map((value) => `- ${value}`),
|
|
61
|
+
'',
|
|
62
|
+
'## Acceptance',
|
|
63
|
+
'',
|
|
64
|
+
...plan.acceptance.map((value) => `- ${value}`),
|
|
65
|
+
'',
|
|
66
|
+
'## Risks',
|
|
67
|
+
'',
|
|
68
|
+
list(plan.risks),
|
|
69
|
+
'',
|
|
70
|
+
'## Project Knowledge Impact',
|
|
71
|
+
'',
|
|
72
|
+
`- Impact: ${plan.knowledgeImpact}`,
|
|
73
|
+
`- Reason: ${plan.knowledgeImpactReason}`,
|
|
74
|
+
`- Knowledge map: revision ${plan.knowledgeMapRevision}, hash ${plan.knowledgeMapHash}`,
|
|
75
|
+
`- Graph use: ${plan.graphUse}`,
|
|
76
|
+
...plan.knowledgeTargets.map((target) => `- ${target.category}: ${target.path} — ${target.reason} (${target.requirementIds.join(', ')})`),
|
|
77
|
+
'',
|
|
78
|
+
'## Production Edges',
|
|
79
|
+
'',
|
|
80
|
+
`- Required: ${plan.requiresProductionEdges === true ? 'yes' : 'no'}`,
|
|
81
|
+
...(plan.productionEdges ?? []).map((edge) =>
|
|
82
|
+
`- ${edge.ownerStepId} -> ${edge.consumerStepId}; check: ${edge.checkRef}`),
|
|
83
|
+
...((plan.productionEdges ?? []).length === 0 ? ['- none'] : []),
|
|
84
|
+
'',
|
|
85
|
+
|
|
86
|
+
'## Steps',
|
|
87
|
+
'',
|
|
88
|
+
];
|
|
89
|
+
for (const step of plan.steps) {
|
|
90
|
+
lines.push(
|
|
91
|
+
`### ${step.id}: ${step.title}`,
|
|
92
|
+
'',
|
|
93
|
+
step.objective,
|
|
94
|
+
'',
|
|
95
|
+
`- Requirements: ${step.requirements.join(', ')}`,
|
|
96
|
+
`- Dependencies: ${step.dependencies.join(', ') || 'none'}`,
|
|
97
|
+
`- Expected outputs: ${step.expectedOutputs.join(', ')}`,
|
|
98
|
+
`- Allowed writes: ${step.allowedWrites.join(', ')}`,
|
|
99
|
+
`- Forbidden scope: ${step.forbiddenScope.join(', ') || 'none'}`,
|
|
100
|
+
`- Checks: ${step.checks.join(' ; ') || 'UNVERIFIED'}`,
|
|
101
|
+
'',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return lines.join('\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function renderResult(task: TaskState, summary: string, limitations: string[]): string {
|
|
108
|
+
return [
|
|
109
|
+
`# ${task.id} Result`,
|
|
110
|
+
'',
|
|
111
|
+
'## Summary',
|
|
112
|
+
'',
|
|
113
|
+
summary,
|
|
114
|
+
'',
|
|
115
|
+
'## Completed Steps',
|
|
116
|
+
'',
|
|
117
|
+
...task.steps.filter((step) => step.status === 'completed').map((step) => `- ${step.id}: ${step.title}`),
|
|
118
|
+
'',
|
|
119
|
+
'## Commits',
|
|
120
|
+
'',
|
|
121
|
+
...task.steps
|
|
122
|
+
.filter((step) => step.evidence)
|
|
123
|
+
.map((step) => `- ${step.id}: ${step.evidence?.commitSha ?? 'missing'}`),
|
|
124
|
+
'',
|
|
125
|
+
'## Limitations',
|
|
126
|
+
'',
|
|
127
|
+
list(limitations),
|
|
128
|
+
'',
|
|
129
|
+
].join('\n');
|
|
130
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { WorkflowError } from '../errors.js';
|
|
6
|
+
import { ensureDirectory, writeJsonAtomic, writeTextAtomic } from '../fs-utils.js';
|
|
7
|
+
|
|
8
|
+
type TransactionPhase = 'prepared' | 'applying' | 'applied' | 'rolled_back';
|
|
9
|
+
|
|
10
|
+
interface Snapshot {
|
|
11
|
+
exists: boolean;
|
|
12
|
+
contentHash: string | null;
|
|
13
|
+
contentBase64: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface JournalTarget {
|
|
17
|
+
path: string;
|
|
18
|
+
preimage: Snapshot;
|
|
19
|
+
staged: Snapshot;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ProjectTransactionJournal {
|
|
23
|
+
schemaVersion: 1;
|
|
24
|
+
transactionId: string;
|
|
25
|
+
kind: string;
|
|
26
|
+
projectId: string;
|
|
27
|
+
phase: TransactionPhase;
|
|
28
|
+
recoveryMode: 'roll-forward' | 'roll-back';
|
|
29
|
+
ownerPid: number;
|
|
30
|
+
ownerHostname: string;
|
|
31
|
+
targets: JournalTarget[];
|
|
32
|
+
preparedAt: string;
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ProjectTransactionTarget {
|
|
37
|
+
path: string;
|
|
38
|
+
content: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ProjectTransactionOptions {
|
|
42
|
+
projectRoot: string;
|
|
43
|
+
projectId: string;
|
|
44
|
+
transactionId: string;
|
|
45
|
+
kind: string;
|
|
46
|
+
targets: ProjectTransactionTarget[];
|
|
47
|
+
now: Date;
|
|
48
|
+
validate?: () => void;
|
|
49
|
+
afterTargetWrite?: (index: number, target: ProjectTransactionTarget) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const TRANSACTION_ID = /^[a-z0-9][a-z0-9-]{7,127}$/;
|
|
53
|
+
const activeProjectRoots = new Set<string>();
|
|
54
|
+
|
|
55
|
+
export function applyProjectTransaction(options: ProjectTransactionOptions): void {
|
|
56
|
+
recoverProjectTransactions(options.projectRoot, options.projectId);
|
|
57
|
+
validateTransactionId(options.transactionId);
|
|
58
|
+
if (!options.kind.trim()) throw new WorkflowError('INVALID_ARGUMENT', 'Project transaction kind is required.');
|
|
59
|
+
if (options.targets.length === 0) throw new WorkflowError('INVALID_ARGUMENT', 'Project transaction requires targets.');
|
|
60
|
+
const paths = options.targets.map((target) => normalizeTargetPath(target.path));
|
|
61
|
+
if (new Set(paths).size !== paths.length) {
|
|
62
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Project transaction target paths must be unique.');
|
|
63
|
+
}
|
|
64
|
+
const journal: ProjectTransactionJournal = {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
transactionId: options.transactionId,
|
|
67
|
+
kind: options.kind.trim(),
|
|
68
|
+
projectId: options.projectId,
|
|
69
|
+
phase: 'prepared',
|
|
70
|
+
recoveryMode: 'roll-forward',
|
|
71
|
+
ownerPid: process.pid,
|
|
72
|
+
ownerHostname: os.hostname(),
|
|
73
|
+
targets: options.targets.map((target, index) => ({
|
|
74
|
+
path: paths[index]!,
|
|
75
|
+
preimage: captureSnapshot(resolveTarget(options.projectRoot, paths[index]!)),
|
|
76
|
+
staged: snapshotFromContent(target.content),
|
|
77
|
+
})),
|
|
78
|
+
preparedAt: options.now.toISOString(),
|
|
79
|
+
updatedAt: options.now.toISOString(),
|
|
80
|
+
};
|
|
81
|
+
writeJournal(options.projectRoot, journal);
|
|
82
|
+
const activeKey = path.resolve(options.projectRoot);
|
|
83
|
+
activeProjectRoots.add(activeKey);
|
|
84
|
+
try {
|
|
85
|
+
writeJournal(options.projectRoot, { ...journal, phase: 'applying', updatedAt: options.now.toISOString() });
|
|
86
|
+
journal.targets.forEach((target, index) => {
|
|
87
|
+
restoreSnapshot(resolveTarget(options.projectRoot, target.path), target.staged);
|
|
88
|
+
options.afterTargetWrite?.(index, options.targets[index]!);
|
|
89
|
+
});
|
|
90
|
+
options.validate?.();
|
|
91
|
+
writeJournal(options.projectRoot, { ...journal, phase: 'applied', updatedAt: options.now.toISOString() });
|
|
92
|
+
removeJournal(options.projectRoot, options.transactionId);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
rollback(options.projectRoot, journal);
|
|
95
|
+
throw error;
|
|
96
|
+
} finally {
|
|
97
|
+
activeProjectRoots.delete(activeKey);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function recoverProjectTransactions(projectRoot: string, projectId: string): string[] {
|
|
102
|
+
if (activeProjectRoots.has(path.resolve(projectRoot))) return [];
|
|
103
|
+
const directory = transactionDirectory(projectRoot);
|
|
104
|
+
if (!existsSync(directory)) return [];
|
|
105
|
+
const recovered: string[] = [];
|
|
106
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).filter((item) => item.isFile() && item.name.endsWith('.json'))) {
|
|
107
|
+
const file = path.join(directory, entry.name);
|
|
108
|
+
const journal = parseJournal(file, projectRoot, projectId);
|
|
109
|
+
if ((journal.phase === 'prepared' || journal.phase === 'applying') && journal.ownerHostname === os.hostname() && processIsAlive(journal.ownerPid)) {
|
|
110
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'A project transaction is still owned by a live process.', {
|
|
111
|
+
transactionId: journal.transactionId,
|
|
112
|
+
kind: journal.kind,
|
|
113
|
+
ownerPid: journal.ownerPid,
|
|
114
|
+
requiredAction: 'Wait for the active command to finish; after its owner exits, obtain fresh next and run the advertised recovery.',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (journal.phase === 'prepared' || journal.phase === 'rolled_back') {
|
|
118
|
+
rollback(projectRoot, journal);
|
|
119
|
+
recovered.push(journal.transactionId);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (journal.phase === 'applied') {
|
|
123
|
+
if (journal.recoveryMode === 'roll-forward') {
|
|
124
|
+
for (const target of journal.targets) restoreSnapshot(resolveTarget(projectRoot, target.path), target.staged);
|
|
125
|
+
}
|
|
126
|
+
removeJournal(projectRoot, journal.transactionId);
|
|
127
|
+
recovered.push(journal.transactionId);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (journal.recoveryMode === 'roll-back') {
|
|
131
|
+
rollback(projectRoot, journal);
|
|
132
|
+
recovered.push(journal.transactionId);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
for (const target of journal.targets) {
|
|
136
|
+
const current = captureSnapshot(resolveTarget(projectRoot, target.path));
|
|
137
|
+
if (!snapshotsEqual(current, target.preimage) && !snapshotsEqual(current, target.staged)) {
|
|
138
|
+
throw new WorkflowError('STATE_CORRUPT', 'Project transaction target diverged during recovery.', {
|
|
139
|
+
transactionId: journal.transactionId,
|
|
140
|
+
target: target.path,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const target of journal.targets) restoreSnapshot(resolveTarget(projectRoot, target.path), target.staged);
|
|
145
|
+
removeJournal(projectRoot, journal.transactionId);
|
|
146
|
+
recovered.push(journal.transactionId);
|
|
147
|
+
}
|
|
148
|
+
return recovered;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function runRollbackProjectTransaction<T>(options: {
|
|
152
|
+
projectRoot: string;
|
|
153
|
+
projectId: string;
|
|
154
|
+
transactionId: string;
|
|
155
|
+
kind: string;
|
|
156
|
+
targetPaths: string[];
|
|
157
|
+
now: Date;
|
|
158
|
+
mutate: () => T;
|
|
159
|
+
validate?: (result: T) => void;
|
|
160
|
+
}): T {
|
|
161
|
+
recoverProjectTransactions(options.projectRoot, options.projectId);
|
|
162
|
+
validateTransactionId(options.transactionId);
|
|
163
|
+
const paths = options.targetPaths.map(normalizeTargetPath);
|
|
164
|
+
if (paths.length === 0 || new Set(paths).size !== paths.length) {
|
|
165
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Rollback project transaction requires unique targets.');
|
|
166
|
+
}
|
|
167
|
+
const targets = paths.map((targetPath) => {
|
|
168
|
+
const preimage = captureSnapshot(resolveTarget(options.projectRoot, targetPath));
|
|
169
|
+
return { path: targetPath, preimage, staged: preimage };
|
|
170
|
+
});
|
|
171
|
+
const journal: ProjectTransactionJournal = {
|
|
172
|
+
schemaVersion: 1,
|
|
173
|
+
transactionId: options.transactionId,
|
|
174
|
+
kind: options.kind,
|
|
175
|
+
projectId: options.projectId,
|
|
176
|
+
phase: 'prepared',
|
|
177
|
+
recoveryMode: 'roll-back',
|
|
178
|
+
ownerPid: process.pid,
|
|
179
|
+
ownerHostname: os.hostname(),
|
|
180
|
+
targets,
|
|
181
|
+
preparedAt: options.now.toISOString(),
|
|
182
|
+
updatedAt: options.now.toISOString(),
|
|
183
|
+
};
|
|
184
|
+
const activeKey = path.resolve(options.projectRoot);
|
|
185
|
+
writeJournal(options.projectRoot, journal);
|
|
186
|
+
activeProjectRoots.add(activeKey);
|
|
187
|
+
try {
|
|
188
|
+
writeJournal(options.projectRoot, { ...journal, phase: 'applying', updatedAt: options.now.toISOString() });
|
|
189
|
+
const result = options.mutate();
|
|
190
|
+
options.validate?.(result);
|
|
191
|
+
writeJournal(options.projectRoot, { ...journal, phase: 'applied', updatedAt: options.now.toISOString() });
|
|
192
|
+
removeJournal(options.projectRoot, journal.transactionId);
|
|
193
|
+
return result;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
rollback(options.projectRoot, journal);
|
|
196
|
+
throw error;
|
|
197
|
+
} finally {
|
|
198
|
+
activeProjectRoots.delete(activeKey);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function listProjectTransactions(projectRoot: string, projectId: string): Array<Pick<ProjectTransactionJournal, 'transactionId' | 'kind' | 'phase' | 'preparedAt'> & { ownerActive: boolean }> {
|
|
203
|
+
const directory = transactionDirectory(projectRoot);
|
|
204
|
+
if (!existsSync(directory)) return [];
|
|
205
|
+
return readdirSync(directory, { withFileTypes: true })
|
|
206
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
207
|
+
.map((entry) => parseJournal(path.join(directory, entry.name), projectRoot, projectId))
|
|
208
|
+
.map(({ transactionId, kind, phase, preparedAt, ownerPid, ownerHostname }) => ({
|
|
209
|
+
transactionId, kind, phase, preparedAt,
|
|
210
|
+
ownerActive: (phase === 'prepared' || phase === 'applying')
|
|
211
|
+
&& ownerHostname === os.hostname() && processIsAlive(ownerPid),
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function rollback(projectRoot: string, journal: ProjectTransactionJournal): void {
|
|
216
|
+
for (const target of journal.targets) restoreSnapshot(resolveTarget(projectRoot, target.path), target.preimage);
|
|
217
|
+
writeJournal(projectRoot, { ...journal, phase: 'rolled_back', updatedAt: new Date().toISOString() });
|
|
218
|
+
removeJournal(projectRoot, journal.transactionId);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function transactionDirectory(projectRoot: string): string {
|
|
222
|
+
return path.join(projectRoot, '.transactions-beta1');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function journalPath(projectRoot: string, transactionId: string): string {
|
|
226
|
+
validateTransactionId(transactionId);
|
|
227
|
+
return path.join(transactionDirectory(projectRoot), `${transactionId}.json`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function writeJournal(projectRoot: string, journal: ProjectTransactionJournal): void {
|
|
231
|
+
ensureDirectory(transactionDirectory(projectRoot));
|
|
232
|
+
writeJsonAtomic(journalPath(projectRoot, journal.transactionId), journal);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function removeJournal(projectRoot: string, transactionId: string): void {
|
|
236
|
+
rmSync(journalPath(projectRoot, transactionId), { force: true });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseJournal(file: string, projectRoot: string, projectId: string): ProjectTransactionJournal {
|
|
240
|
+
let value: unknown;
|
|
241
|
+
try {
|
|
242
|
+
value = JSON.parse(readFileSync(file, 'utf8'));
|
|
243
|
+
} catch (error) {
|
|
244
|
+
throw new WorkflowError('STATE_CORRUPT', `Cannot read project transaction journal: ${file}`, {
|
|
245
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const journal = value as Partial<ProjectTransactionJournal>;
|
|
249
|
+
if (journal.schemaVersion !== 1 || journal.projectId !== projectId || !journal.transactionId || !journal.kind
|
|
250
|
+
|| !['roll-forward', 'roll-back'].includes(journal.recoveryMode ?? '')
|
|
251
|
+
|| !Number.isInteger(journal.ownerPid) || (journal.ownerPid ?? 0) < 1 || !journal.ownerHostname
|
|
252
|
+
|| !['prepared', 'applying', 'applied', 'rolled_back'].includes(journal.phase ?? '') || !Array.isArray(journal.targets)) {
|
|
253
|
+
throw new WorkflowError('STATE_CORRUPT', `Invalid project transaction journal: ${file}`);
|
|
254
|
+
}
|
|
255
|
+
validateTransactionId(journal.transactionId);
|
|
256
|
+
const expectedFile = journalPath(projectRoot, journal.transactionId);
|
|
257
|
+
if (path.resolve(file) !== path.resolve(expectedFile)) {
|
|
258
|
+
throw new WorkflowError('STATE_CORRUPT', `Project transaction journal filename mismatch: ${file}`);
|
|
259
|
+
}
|
|
260
|
+
for (const target of journal.targets) {
|
|
261
|
+
normalizeTargetPath(target.path);
|
|
262
|
+
validateSnapshot(target.preimage);
|
|
263
|
+
validateSnapshot(target.staged);
|
|
264
|
+
}
|
|
265
|
+
return journal as ProjectTransactionJournal;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function normalizeTargetPath(value: string): string {
|
|
269
|
+
const normalized = value.split(path.sep).join('/');
|
|
270
|
+
if (!normalized || path.isAbsolute(normalized) || normalized.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
271
|
+
throw new WorkflowError('INVALID_ARGUMENT', `Invalid project transaction target: ${value}`);
|
|
272
|
+
}
|
|
273
|
+
if (normalized === '.transactions-beta1' || normalized.startsWith('.transactions-beta1/')) {
|
|
274
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Project transaction cannot target its journal directory.');
|
|
275
|
+
}
|
|
276
|
+
return normalized;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function resolveTarget(projectRoot: string, relativePath: string): string {
|
|
280
|
+
const root = path.resolve(projectRoot);
|
|
281
|
+
const target = path.resolve(root, normalizeTargetPath(relativePath));
|
|
282
|
+
if (!target.startsWith(`${root}${path.sep}`)) throw new WorkflowError('INVALID_ARGUMENT', 'Project transaction target escaped project root.');
|
|
283
|
+
let current = root;
|
|
284
|
+
for (const part of path.relative(root, path.dirname(target)).split(path.sep).filter(Boolean)) {
|
|
285
|
+
current = path.join(current, part);
|
|
286
|
+
if (existsSync(current) && lstatSync(current).isSymbolicLink()) {
|
|
287
|
+
throw new WorkflowError('STATE_CORRUPT', `Project transaction target traverses a symlink: ${relativePath}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (existsSync(target) && lstatSync(target).isSymbolicLink()) {
|
|
291
|
+
throw new WorkflowError('STATE_CORRUPT', `Project transaction target is a symlink: ${relativePath}`);
|
|
292
|
+
}
|
|
293
|
+
return target;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function captureSnapshot(file: string): Snapshot {
|
|
297
|
+
if (!existsSync(file)) return { exists: false, contentHash: null, contentBase64: null };
|
|
298
|
+
const content = readFileSync(file);
|
|
299
|
+
return { exists: true, contentHash: sha256(content), contentBase64: content.toString('base64') };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function snapshotFromContent(content: string | null): Snapshot {
|
|
303
|
+
if (content === null) return { exists: false, contentHash: null, contentBase64: null };
|
|
304
|
+
const normalized = Buffer.from(content.endsWith('\n') ? content : `${content}\n`);
|
|
305
|
+
return { exists: true, contentHash: sha256(normalized), contentBase64: normalized.toString('base64') };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function restoreSnapshot(file: string, snapshot: Snapshot): void {
|
|
309
|
+
validateSnapshot(snapshot);
|
|
310
|
+
if (!snapshot.exists) {
|
|
311
|
+
rmSync(file, { force: true });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
writeTextAtomic(file, Buffer.from(snapshot.contentBase64!, 'base64').toString('utf8'));
|
|
315
|
+
if (!snapshotsEqual(captureSnapshot(file), snapshot)) {
|
|
316
|
+
throw new WorkflowError('STATE_CORRUPT', `Project transaction snapshot restore failed: ${file}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function validateSnapshot(snapshot: Snapshot): void {
|
|
321
|
+
if (!snapshot || typeof snapshot !== 'object' || typeof snapshot.exists !== 'boolean') {
|
|
322
|
+
throw new WorkflowError('STATE_CORRUPT', 'Invalid project transaction snapshot.');
|
|
323
|
+
}
|
|
324
|
+
if (!snapshot.exists) {
|
|
325
|
+
if (snapshot.contentHash !== null || snapshot.contentBase64 !== null) throw new WorkflowError('STATE_CORRUPT', 'Absent snapshot contains data.');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (!snapshot.contentHash || !/^[a-f0-9]{64}$/.test(snapshot.contentHash) || snapshot.contentBase64 === null) {
|
|
329
|
+
throw new WorkflowError('STATE_CORRUPT', 'Existing project transaction snapshot is incomplete.');
|
|
330
|
+
}
|
|
331
|
+
if (sha256(Buffer.from(snapshot.contentBase64, 'base64')) !== snapshot.contentHash) {
|
|
332
|
+
throw new WorkflowError('STATE_CORRUPT', 'Project transaction snapshot hash mismatch.');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function snapshotsEqual(left: Snapshot, right: Snapshot): boolean {
|
|
337
|
+
return left.exists === right.exists && left.contentHash === right.contentHash;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function sha256(content: Buffer): string {
|
|
341
|
+
return createHash('sha256').update(content).digest('hex');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function validateTransactionId(value: string): void {
|
|
345
|
+
if (!TRANSACTION_ID.test(value)) throw new WorkflowError('INVALID_ARGUMENT', `Invalid project transaction id: ${value}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function processIsAlive(pid: number): boolean {
|
|
349
|
+
try {
|
|
350
|
+
process.kill(pid, 0);
|
|
351
|
+
return true;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'EPERM');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, realpathSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { TaskState } from './contracts.js';
|
|
5
|
+
import { pathAllowed } from './domain/validation.js';
|
|
6
|
+
import { WorkflowError } from './errors.js';
|
|
7
|
+
import { currentBranch, headCommit, resolveRepositoryIdentity, runGit } from './repository.js';
|
|
8
|
+
import { hashDirtyWorktree } from './alpha6/downstream-proof.js';
|
|
9
|
+
|
|
10
|
+
export interface ChangeExplanationInput {
|
|
11
|
+
reason: string;
|
|
12
|
+
paths: string[];
|
|
13
|
+
intent?: 'implementation' | 'membership' | 'semantic-scope';
|
|
14
|
+
expectedRevision?: number;
|
|
15
|
+
expectedPlanHash?: string;
|
|
16
|
+
executionContext?: { cwd: string; writeRoot: string; expectedBranch: string };
|
|
17
|
+
externalDecision?: 'denied' | 'unknown';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Blocker = { layer: string; code: string; resolution: string };
|
|
21
|
+
const object = (value: unknown): Record<string, unknown> => value !== null && typeof value === 'object'
|
|
22
|
+
&& !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
23
|
+
|
|
24
|
+
function validate(input: ChangeExplanationInput) {
|
|
25
|
+
const keys = ['reason', 'paths', 'intent', 'expectedRevision', 'expectedPlanHash', 'executionContext', 'externalDecision'];
|
|
26
|
+
if (!input || typeof input !== 'object' || Object.keys(input).some(key => !keys.includes(key))
|
|
27
|
+
|| typeof input.reason !== 'string' || !input.reason.trim() || input.reason.length > 4000
|
|
28
|
+
|| !Array.isArray(input.paths) || input.paths.length > 256
|
|
29
|
+
|| input.paths.some(p => typeof p !== 'string' || !p || p.includes('\\') || p.includes('\0')
|
|
30
|
+
|| /[*?\[\]{}]/.test(p) || path.isAbsolute(p) || p.split('/').some(x => !x || x === '.' || x === '..'))
|
|
31
|
+
|| (input.intent !== undefined && !['implementation', 'membership', 'semantic-scope'].includes(input.intent))
|
|
32
|
+
|| (input.externalDecision !== undefined && !['denied', 'unknown'].includes(input.externalDecision))
|
|
33
|
+
|| (input.expectedRevision !== undefined && (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 1))
|
|
34
|
+
|| (input.expectedPlanHash !== undefined && !/^[a-f0-9]{64}$/.test(input.expectedPlanHash))) {
|
|
35
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Expected a closed change explanation with reason and normalized repository-relative paths.');
|
|
36
|
+
}
|
|
37
|
+
const ctx = input.executionContext;
|
|
38
|
+
if (ctx !== undefined && (!ctx || typeof ctx !== 'object'
|
|
39
|
+
|| Object.keys(ctx).sort().join(',') !== 'cwd,expectedBranch,writeRoot'
|
|
40
|
+
|| [ctx.cwd, ctx.writeRoot, ctx.expectedBranch].some(x => typeof x !== 'string' || !x.trim())
|
|
41
|
+
|| !path.isAbsolute(ctx.cwd) || !path.isAbsolute(ctx.writeRoot))) {
|
|
42
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'executionContext requires absolute cwd/writeRoot and expectedBranch.');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolve existing ancestors as well as non-existing destinations; do not read through symlinks.
|
|
47
|
+
function outsideRoot(root: string, file: string): boolean {
|
|
48
|
+
let ancestor = path.join(root, file);
|
|
49
|
+
while (!existsSync(ancestor)) {
|
|
50
|
+
try { if (lstatSync(ancestor).isSymbolicLink()) return true; } catch { /* missing path */ }
|
|
51
|
+
const parent = path.dirname(ancestor);
|
|
52
|
+
if (parent === ancestor) return true;
|
|
53
|
+
ancestor = parent;
|
|
54
|
+
}
|
|
55
|
+
const relative = path.relative(root, realpathSync(ancestor));
|
|
56
|
+
return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Explanation only: never issues a credential, writes state, or grants permission to apply a patch. */
|
|
60
|
+
export function explainChange(input: ChangeExplanationInput, repository: string,
|
|
61
|
+
task: TaskState | undefined, next: Record<string, unknown>) {
|
|
62
|
+
validate(input);
|
|
63
|
+
const identity = resolveRepositoryIdentity(repository);
|
|
64
|
+
const root = realpathSync(identity.repositoryRoot);
|
|
65
|
+
const blockers: Blocker[] = [];
|
|
66
|
+
const add = (layer: string, code: string, resolution: string) => blockers.push({ layer, code, resolution });
|
|
67
|
+
const dirty = [...new Set([
|
|
68
|
+
...runGit(root, ['diff', 'HEAD', '--name-only', '-z']).split('\0'),
|
|
69
|
+
...runGit(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0'),
|
|
70
|
+
].filter(Boolean))].sort();
|
|
71
|
+
const paths = [...new Set([...input.paths, ...dirty])].sort();
|
|
72
|
+
const escaped = paths.filter(p => outsideRoot(root, p));
|
|
73
|
+
if (escaped.length) add('execution-context', 'WRITE_PATH_ESCAPES_CHECKOUT', 'Resolve the symlink or target mismatch before proposing a write.');
|
|
74
|
+
let contextState = 'not-supplied';
|
|
75
|
+
if (input.executionContext) {
|
|
76
|
+
contextState = 'matched';
|
|
77
|
+
try {
|
|
78
|
+
const ctx = input.executionContext;
|
|
79
|
+
const observed = resolveRepositoryIdentity(ctx.cwd);
|
|
80
|
+
if (realpathSync(observed.repositoryRoot) !== root || realpathSync(ctx.writeRoot) !== root
|
|
81
|
+
|| currentBranch(root) !== ctx.expectedBranch
|
|
82
|
+
|| (task?.taskBranch !== null && task?.taskBranch !== undefined && ctx.expectedBranch !== task.taskBranch)) {
|
|
83
|
+
contextState = 'mismatch';
|
|
84
|
+
add('execution-context', 'EXECUTION_CONTEXT_MISMATCH', 'Reconcile the existing Worker checkout and fresh Task branch; do not retry in another chat or silently change the target.');
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
contextState = 'unverified';
|
|
88
|
+
add('execution-context', 'EXECUTION_CONTEXT_UNVERIFIED', 'Obtain a readable current checkout observation.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (input.externalDecision === 'denied') add('external-approval', 'EXTERNAL_OPERATION_DENIED',
|
|
92
|
+
'Preserve the denial. Prepare safe evidence or obtain the exact external authorization; a Workflow grant does not override it.');
|
|
93
|
+
if (input.expectedRevision !== undefined && input.expectedRevision !== task?.revision
|
|
94
|
+
|| input.expectedPlanHash !== undefined && input.expectedPlanHash !== task?.planHash) {
|
|
95
|
+
add('integrity', 'STALE_CHANGE_BINDING', 'Refresh the proposal from the current Task revision and Plan.');
|
|
96
|
+
}
|
|
97
|
+
const action = typeof next.action === 'string' ? next.action : 'blocked';
|
|
98
|
+
if (next.status === 'observation-blocked' || next.status === 'repair-required') {
|
|
99
|
+
add('integrity', 'CORE_OBSERVATION_UNAVAILABLE', 'Complete the exact Core observation/recovery route before changing work.');
|
|
100
|
+
}
|
|
101
|
+
const step = task?.steps.find(s => s.id === next.stepId)
|
|
102
|
+
?? task?.steps.find(s => s.status === 'in_progress' || s.status === 'failed');
|
|
103
|
+
const outOfPlan = paths.filter(p => !step || !pathAllowed(p, step.allowedWrites) || pathAllowed(p, step.forbiddenScope));
|
|
104
|
+
const recovery = object(next.planIntegrityRecovery);
|
|
105
|
+
const supporting = recovery.state === 'eligible'
|
|
106
|
+
&& (action === 'task plan-integrity-recover' || next.blockedAction === 'task plan-integrity-recover')
|
|
107
|
+
&& recovery.boundedEffect === 'record-machine-derived-check-support-scope';
|
|
108
|
+
const evidence = object(recovery.evidence);
|
|
109
|
+
const scope = Array.isArray(evidence.scopeExtensions) ? evidence.scopeExtensions.filter((x): x is string => typeof x === 'string') : [];
|
|
110
|
+
const bounded = supporting && outOfPlan.every(p => scope.includes(p));
|
|
111
|
+
let kind = 'implementation';
|
|
112
|
+
let decisionBasis = 'No automatic conclusion about semantic safety; follow the current Core route.';
|
|
113
|
+
if (input.intent === 'semantic-scope') {
|
|
114
|
+
kind = 'semantic-scope';
|
|
115
|
+
add('workflow', 'HUMAN_SEMANTIC_DECISION', 'Resolve the changed outcome or acceptance with the user through the scope-change route.');
|
|
116
|
+
} else if (input.intent === 'membership' || next.auditedRemediation) {
|
|
117
|
+
kind = 'membership';
|
|
118
|
+
decisionBasis = 'Membership is separate from Step implementation. Use only the advertised bounded topology route or a separately confirmed scope change.';
|
|
119
|
+
} else if (bounded) {
|
|
120
|
+
kind = 'bounded-plan-amendment';
|
|
121
|
+
decisionBasis = 'Core derived an exact check-support extension. Use its existing recovery; do not edit allowedWrites or grant authority manually.';
|
|
122
|
+
} else if (/replan|corrective-yield|downstream-proof/.test(action) || recovery.boundedEffect === 'record-replan-required-decision') {
|
|
123
|
+
kind = 'corrective-plan';
|
|
124
|
+
decisionBasis = 'Core identified a Plan/recovery conflict; preserve historical evidence and the existing Task.';
|
|
125
|
+
} else if (action === 'task context-refresh' || action === 'task knowledge-rebind') {
|
|
126
|
+
kind = 'context-refresh';
|
|
127
|
+
decisionBasis = 'Context refresh is independent of Plan correction and cannot acknowledge a Plan conflict.';
|
|
128
|
+
} else if (outOfPlan.length) {
|
|
129
|
+
kind = 'plan-review';
|
|
130
|
+
add('workflow', 'UNPROVED_SCOPE_EXTENSION', 'Establish a Core-supported extension or revise the Plan. File count and user-declared intent are not safety evidence.');
|
|
131
|
+
}
|
|
132
|
+
const delegated = Array.isArray(next.delegatedApprovalOptions) && next.delegatedApprovalOptions.length > 0;
|
|
133
|
+
const derived = object(next.correctiveDerivedApproval).humanApprovalRequired === false;
|
|
134
|
+
const humanGate = !!next.requiredHumanGate || ['task authorize', 'task accept', 'milestone authorize', 'milestone accept', 'project-memory approve'].includes(action);
|
|
135
|
+
if (humanGate && !delegated && !derived) add('workflow', 'CURRENT_HUMAN_GATE', 'Use the current exact human gate; this explanation issues no authority.');
|
|
136
|
+
const head = headCommit(root);
|
|
137
|
+
const binding = { projectId: identity.projectId, repositoryRoot: root, taskId: task?.id ?? null,
|
|
138
|
+
revision: task?.revision ?? null, planHash: task?.planHash ?? null, head,
|
|
139
|
+
proposedPaths: [...new Set(input.paths)].sort(), observedDirtyPaths: dirty, proposedPathBytesHash: escaped.length ? null : hashDirtyWorktree(root, paths),
|
|
140
|
+
requestHash: createHash('sha256').update(JSON.stringify(input)).digest('hex'), existingAction: action };
|
|
141
|
+
return { kind: 'change-explanation', version: 1, readOnly: true, authority: 'none', changeKind: kind,
|
|
142
|
+
decisionBasis, binding, proposalHash: createHash('sha256').update(JSON.stringify(binding)).digest('hex'),
|
|
143
|
+
executionContext: { state: contextState, externallyEnforced: false }, blockers,
|
|
144
|
+
existingRoute: { action, blockedByExplanation: blockers.length > 0, recheckNextBeforeMutation: true },
|
|
145
|
+
proposedPaths: paths, outsideCurrentStep: outOfPlan,
|
|
146
|
+
boundedAmendment: bounded && kind === 'bounded-plan-amendment' ? { route: 'task plan-integrity-recover', prerequisite: action === 'task plan-integrity-recover' ? null : action, scopeExtensions: scope, effect: recovery.boundedEffect } : null,
|
|
147
|
+
requiresUser: blockers.some(b => ['EXTERNAL_OPERATION_DENIED', 'HUMAN_SEMANTIC_DECISION', 'CURRENT_HUMAN_GATE'].includes(b.code)) ? true : null,
|
|
148
|
+
validationNeeded: ['Fresh status and next; current revision, Plan, HEAD and file bytes.',
|
|
149
|
+
'Current Worker checkout, branch and writer credential; external approval remains independent.',
|
|
150
|
+
'Preserve requirements, acceptance, checks and historical evidence; review the exact diff.'],
|
|
151
|
+
limitations: ['Not an apply command or proof of external permission.',
|
|
152
|
+
'Unspecified risks and blockers may remain. No inference of semantic safety from paths or diff size.'] };
|
|
153
|
+
}
|