@tea-agent/loop-agent 0.16.25 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +14 -3
- package/dist/cli/command-definitions.js +43 -0
- package/dist/cli/program.js +26 -0
- package/dist/commands/dag-approve.js +4 -0
- package/dist/commands/dag-resume.js +1 -0
- package/dist/commands/dag-validate.js +6 -0
- package/dist/commands/operator.js +44 -0
- package/dist/commands/task-contract.js +271 -0
- package/dist/executors/dag-pi-executor.js +118 -16
- package/dist/executors/pi-executor.js +206 -13
- package/dist/executors/pi-sdk-executor.js +21 -6
- package/dist/executors/shell-executor.js +85 -8
- package/dist/executors/shell-presets.js +16 -3
- package/dist/executors/shell-write-guard.js +64 -2
- package/dist/shared/operator/capabilities.js +255 -0
- package/dist/shared/operator/envelope.js +59 -0
- package/dist/shared/operator/index.js +4 -0
- package/dist/shared/operator/registry.js +38 -0
- package/dist/shared/operator/types.js +5 -0
- package/dist/task/contract/adopt.js +166 -0
- package/dist/task/contract/apply.js +326 -0
- package/dist/task/contract/canonicalize.js +60 -0
- package/dist/task/contract/constants.js +29 -0
- package/dist/task/contract/diff.js +177 -0
- package/dist/task/contract/hash.js +42 -0
- package/dist/task/contract/import-revision.js +96 -0
- package/dist/task/contract/index.js +17 -0
- package/dist/task/contract/journal.js +155 -0
- package/dist/task/contract/lock.js +153 -0
- package/dist/task/contract/observe.js +296 -0
- package/dist/task/contract/paths.js +19 -0
- package/dist/task/contract/project.js +170 -0
- package/dist/task/contract/recover.js +312 -0
- package/dist/task/contract/request-ledger.js +37 -0
- package/dist/task/contract/schema.js +151 -0
- package/dist/task/contract/transaction.js +160 -0
- package/dist/task/contract/types.js +1 -0
- package/dist/task/contract/validate-draft.js +106 -0
- package/dist/task/index.js +3 -0
- package/dist/task/operator/capabilities.js +6 -0
- package/dist/task/operator/envelope.js +2 -0
- package/dist/task/operator/index.js +5 -0
- package/dist/task/operator/registry.js +2 -0
- package/dist/task/operator/types.js +1 -0
- package/dist/task/runtime.js +5 -1
- package/dist/task/source-references.js +7 -0
- package/dist/worker/cli.js +150 -32
- package/dist/worker/console/app-data.js +185 -0
- package/dist/worker/console/dag-confirmation.js +313 -0
- package/dist/worker/console/doctor.js +169 -0
- package/dist/worker/console/draft-store.js +80 -0
- package/dist/worker/console/index.js +15 -0
- package/dist/worker/console/interview/assessment.js +67 -0
- package/dist/worker/console/interview/session.js +100 -0
- package/dist/worker/console/interview/tools.js +109 -0
- package/dist/worker/console/loopback.js +16 -0
- package/dist/worker/console/observe-health-match.js +174 -0
- package/dist/worker/console/observe-link.js +33 -0
- package/dist/worker/console/operation-runner.js +166 -0
- package/dist/worker/console/operation-sse.js +158 -0
- package/dist/worker/console/operation-store.js +147 -0
- package/dist/worker/console/operator-actions.js +769 -0
- package/dist/worker/console/pi-readiness.js +94 -0
- package/dist/worker/console/recovery-cta.js +133 -0
- package/dist/worker/console/repo-fingerprint.js +29 -0
- package/dist/worker/console/resource-loader.js +95 -0
- package/dist/worker/console/routes.js +368 -0
- package/dist/worker/console/security.js +126 -0
- package/dist/worker/console/server.js +149 -0
- package/dist/worker/console/sibling-controller.js +28 -0
- package/dist/worker/console/static/assets/index-CbnMgdWa.js +9 -0
- package/dist/worker/console/static/assets/index-Dnj0RVs8.css +1 -0
- package/dist/worker/console/static/index.html +13 -0
- package/dist/worker/console/vite.config.js +27 -0
- package/dist/worker/delivery/git-transaction.js +43 -8
- package/dist/worker/observe/health.js +57 -0
- package/dist/worker/observe/paths.js +81 -0
- package/dist/worker/observe/routes.js +142 -27
- package/dist/worker/observe/spec-evidence.js +84 -0
- package/dist/worker/observe/static/api.js +23 -0
- package/dist/worker/observe/static/state.js +26 -0
- package/dist/worker/observe/static/styles.css +10 -0
- package/dist/worker/observe/static/views/dag-inspector.js +173 -6
- package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
- package/dist/workflows/dag/frontend-repair.js +1 -10
- package/dist/workflows/dag/init-hybrid.js +372 -115
- package/dist/workflows/dag/node-execution.js +57 -6
- package/dist/workflows/dag/project-governance-context.js +508 -0
- package/dist/workflows/dag/prompt.js +46 -1
- package/dist/workflows/dag/retry-policy.js +16 -1
- package/dist/workflows/dag/runner.js +9 -0
- package/dist/workflows/dag/skill-snapshot.js +1 -0
- package/dist/workflows/dag/task-contract-binding.js +138 -0
- package/dist/workflows/dag/types.js +84 -10
- package/dist/workflows/dag/validate.js +53 -7
- package/docs/README.md +2 -0
- package/docs/architecture/evolution.md +2 -0
- package/docs/architecture/system-overview.md +6 -0
- package/docs/architecture/worker-and-feature.md +7 -0
- package/docs/templates/agent-dag.schema.json +64 -2
- package/docs/templates/agent-dag.supervised-implementation.json +1 -0
- package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
- package/docs/templates/backend-test-dag.json +26 -154
- package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
- package/package.json +8 -2
- package/skills/agent-worker/SKILL.md +1 -0
- package/skills/agent-worker/references/agent-worker-operator.md +3 -2
- package/skills/frontend-design-review/SKILL.md +25 -16
- package/skills/frontend-implementation/references/node-contracts.md +5 -5
- package/skills/loop-agent/references/command-reference.md +48 -1
- package/skills/loop-agent/references/hybrid-dag.md +4 -4
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { CANONICALIZER_VERSION, PROJECTION_VERSION, TASK_CONFIG_SCHEMA_VERSION, TASK_CONTRACT_REF_SCHEMA_VERSION, } from "./constants.js";
|
|
2
|
+
import { ContractMutationError } from "./apply.js";
|
|
3
|
+
import { computeCanonicalHash, computeSourceHashes, computeTaskConfigSha256, } from "./hash.js";
|
|
4
|
+
import { acquireTaskContractLock, assertLockHeld } from "./lock.js";
|
|
5
|
+
import { loadObservedTaskBytes, logicalRevisionForState, observeTaskContractState, readStoredContractRef, } from "./observe.js";
|
|
6
|
+
import { serializeTaskConfigForDisk } from "./project.js";
|
|
7
|
+
import { appendRequestLedgerEntry, findRequestLedgerEntry, } from "./request-ledger.js";
|
|
8
|
+
import { finalizeTransaction, runJournaledContractCommit, } from "./transaction.js";
|
|
9
|
+
import { getTaskPaths, parseTaskConfigForRuntime } from "../runtime.js";
|
|
10
|
+
/**
|
|
11
|
+
* Adopt current on-disk 需求.md / 执行约束.md / task.json as next managed revision.
|
|
12
|
+
* Does not rewrite reference bytes. Requires deterministic readable facts.
|
|
13
|
+
*/
|
|
14
|
+
export async function adoptTaskContract(input) {
|
|
15
|
+
const existing = await findRequestLedgerEntry({
|
|
16
|
+
repoRoot: input.repoRoot,
|
|
17
|
+
taskId: input.taskId,
|
|
18
|
+
requestId: input.requestId,
|
|
19
|
+
});
|
|
20
|
+
if (existing) {
|
|
21
|
+
if (existing.requestPayloadSha256 !== input.requestPayloadSha256) {
|
|
22
|
+
throw new ContractMutationError("REQUEST_ID_REUSE_CONFLICT", `requestId ${input.requestId} was used with a different payload hash`);
|
|
23
|
+
}
|
|
24
|
+
const ref = await readStoredContractRef(getTaskPaths(input.repoRoot, input.taskId).contractPath);
|
|
25
|
+
if (!ref) {
|
|
26
|
+
throw new ContractMutationError("OPERATION_NEEDS_RECONCILE", "request ledger hit but contract.json missing");
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
ref,
|
|
30
|
+
changedFiles: [],
|
|
31
|
+
idempotentReplay: true,
|
|
32
|
+
txId: existing.txId,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const lock = await acquireTaskContractLock({
|
|
36
|
+
repoRoot: input.repoRoot,
|
|
37
|
+
taskId: input.taskId,
|
|
38
|
+
requestId: input.requestId,
|
|
39
|
+
operation: "contract-adopt",
|
|
40
|
+
});
|
|
41
|
+
try {
|
|
42
|
+
const existingUnderLock = await findRequestLedgerEntry({
|
|
43
|
+
repoRoot: input.repoRoot,
|
|
44
|
+
taskId: input.taskId,
|
|
45
|
+
requestId: input.requestId,
|
|
46
|
+
});
|
|
47
|
+
if (existingUnderLock) {
|
|
48
|
+
if (existingUnderLock.requestPayloadSha256 !== input.requestPayloadSha256) {
|
|
49
|
+
throw new ContractMutationError("REQUEST_ID_REUSE_CONFLICT", `requestId ${input.requestId} was used with a different payload hash`);
|
|
50
|
+
}
|
|
51
|
+
const ref = await readStoredContractRef(getTaskPaths(input.repoRoot, input.taskId).contractPath);
|
|
52
|
+
if (!ref) {
|
|
53
|
+
throw new ContractMutationError("OPERATION_NEEDS_RECONCILE", "request ledger hit but contract.json missing");
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
ref,
|
|
57
|
+
changedFiles: [],
|
|
58
|
+
idempotentReplay: true,
|
|
59
|
+
txId: existingUnderLock.txId,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const state = await observeTaskContractState(input.repoRoot, input.taskId);
|
|
63
|
+
if (state.effectiveStatus === "transaction-incomplete") {
|
|
64
|
+
throw new ContractMutationError("TRANSACTION_INCOMPLETE", "unfinished transaction; run task contract recover first");
|
|
65
|
+
}
|
|
66
|
+
const currentRevision = logicalRevisionForState(state);
|
|
67
|
+
if (input.expectedRevision !== currentRevision) {
|
|
68
|
+
throw new ContractMutationError("REVISION_CONFLICT", `expectedRevision ${input.expectedRevision} does not match current ${currentRevision}`, { currentRevision, expectedRevision: input.expectedRevision });
|
|
69
|
+
}
|
|
70
|
+
const bytes = await loadObservedTaskBytes(input.repoRoot, input.taskId);
|
|
71
|
+
if (bytes.requirementBytes === null ||
|
|
72
|
+
bytes.constraintsBytes === null ||
|
|
73
|
+
bytes.taskConfigRaw === null ||
|
|
74
|
+
bytes.taskConfigSha256 === null) {
|
|
75
|
+
throw new ContractMutationError("INVALID_INPUT", "adopt requires existing 需求.md, 执行约束.md, and task.json");
|
|
76
|
+
}
|
|
77
|
+
const observedHash = state.observedCanonicalHash;
|
|
78
|
+
if (!observedHash) {
|
|
79
|
+
throw new ContractMutationError("OBSERVED_HASH_CONFLICT", "unable to observe current canonical hash for adopt");
|
|
80
|
+
}
|
|
81
|
+
if (input.expectedObservedHash !== observedHash) {
|
|
82
|
+
throw new ContractMutationError("OBSERVED_HASH_CONFLICT", "expectedObservedHash does not match current observed hash", {
|
|
83
|
+
expectedObservedHash: input.expectedObservedHash,
|
|
84
|
+
observedCanonicalHash: observedHash,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const fullConfig = parseTaskConfigForRuntime(bytes.taskConfigRaw);
|
|
88
|
+
const taskConfigContent = serializeTaskConfigForDisk(fullConfig);
|
|
89
|
+
const sourceHashes = computeSourceHashes({
|
|
90
|
+
requirementBytes: bytes.requirementBytes,
|
|
91
|
+
constraintsBytes: bytes.constraintsBytes,
|
|
92
|
+
referenceManifestBytes: bytes.referenceManifestBytes,
|
|
93
|
+
});
|
|
94
|
+
const taskConfigSha256 = computeTaskConfigSha256(fullConfig);
|
|
95
|
+
const canonicalHash = computeCanonicalHash({
|
|
96
|
+
sourceHashes,
|
|
97
|
+
taskConfigSha256,
|
|
98
|
+
});
|
|
99
|
+
// Sanity: observed should equal recomputed
|
|
100
|
+
if (canonicalHash !== observedHash) {
|
|
101
|
+
throw new ContractMutationError("OBSERVED_HASH_CONFLICT", "recomputed canonical hash diverged during adopt");
|
|
102
|
+
}
|
|
103
|
+
const nextRevision = currentRevision + 1;
|
|
104
|
+
const nextRef = {
|
|
105
|
+
schemaVersion: TASK_CONTRACT_REF_SCHEMA_VERSION,
|
|
106
|
+
taskId: input.taskId,
|
|
107
|
+
revision: nextRevision,
|
|
108
|
+
projectionVersion: PROJECTION_VERSION,
|
|
109
|
+
canonicalizerVersion: CANONICALIZER_VERSION,
|
|
110
|
+
taskConfigSchemaVersion: TASK_CONFIG_SCHEMA_VERSION,
|
|
111
|
+
sourceHashes,
|
|
112
|
+
taskConfigSha256,
|
|
113
|
+
canonicalHash,
|
|
114
|
+
updatedAt: new Date().toISOString(),
|
|
115
|
+
lastRequestId: input.requestId,
|
|
116
|
+
};
|
|
117
|
+
// Adopt rewrites contract.json and re-writes same source/config bytes
|
|
118
|
+
// (stable content) so journal protocol is uniform.
|
|
119
|
+
await assertLockHeld(lock);
|
|
120
|
+
const { txId, ref } = await runJournaledContractCommit({
|
|
121
|
+
repoRoot: input.repoRoot,
|
|
122
|
+
taskId: input.taskId,
|
|
123
|
+
operation: "adopt",
|
|
124
|
+
requestId: input.requestId,
|
|
125
|
+
requestPayloadSha256: input.requestPayloadSha256,
|
|
126
|
+
expectedRevision: input.expectedRevision,
|
|
127
|
+
expectedObservedCanonicalHash: input.expectedObservedHash,
|
|
128
|
+
nextRevision,
|
|
129
|
+
fencingToken: lock.record.fencingToken,
|
|
130
|
+
targets: {
|
|
131
|
+
requirementContent: bytes.requirementBytes,
|
|
132
|
+
constraintsContent: bytes.constraintsBytes,
|
|
133
|
+
taskConfigContent,
|
|
134
|
+
contractRef: nextRef,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
await appendRequestLedgerEntry({
|
|
138
|
+
repoRoot: input.repoRoot,
|
|
139
|
+
taskId: input.taskId,
|
|
140
|
+
entry: {
|
|
141
|
+
schemaVersion: 1,
|
|
142
|
+
requestId: input.requestId,
|
|
143
|
+
requestPayloadSha256: input.requestPayloadSha256,
|
|
144
|
+
txId,
|
|
145
|
+
revision: ref.revision,
|
|
146
|
+
canonicalHash: ref.canonicalHash,
|
|
147
|
+
recordedAt: new Date().toISOString(),
|
|
148
|
+
resultSummary: { ok: true, operation: "adopt" },
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
await finalizeTransaction({
|
|
152
|
+
repoRoot: input.repoRoot,
|
|
153
|
+
taskId: input.taskId,
|
|
154
|
+
txId,
|
|
155
|
+
});
|
|
156
|
+
return {
|
|
157
|
+
ref,
|
|
158
|
+
changedFiles: ["contract.json"],
|
|
159
|
+
idempotentReplay: false,
|
|
160
|
+
txId,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
await lock.release();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CANONICALIZER_VERSION, PROJECTION_VERSION, TASK_CONFIG_SCHEMA_VERSION, TASK_CONTRACT_REF_SCHEMA_VERSION, } from "./constants.js";
|
|
4
|
+
import { diffDraftAgainstCurrent } from "./diff.js";
|
|
5
|
+
import { computeCanonicalHash, computeSourceHashes, computeTaskConfigSha256, sha256Utf8, } from "./hash.js";
|
|
6
|
+
import { acquireTaskContractLock, assertLockHeld } from "./lock.js";
|
|
7
|
+
import { loadObservedTaskBytes, logicalRevisionForState, observeTaskContractState, readStoredContractRef, } from "./observe.js";
|
|
8
|
+
import { mergeManagedTaskConfigFields, projectConstraintsMarkdown, projectRequirementMarkdown, serializeTaskConfigForDisk, } from "./project.js";
|
|
9
|
+
import { appendRequestLedgerEntry, findRequestLedgerEntry, } from "./request-ledger.js";
|
|
10
|
+
import { parseTaskContractDraft, validateDraftBusinessRules, } from "./schema.js";
|
|
11
|
+
import { finalizeTransaction, runJournaledContractCommit, } from "./transaction.js";
|
|
12
|
+
import { getTaskPaths, parseTaskConfigForRuntime } from "../runtime.js";
|
|
13
|
+
import { getSourceManifestPath, readSourceManifest, } from "../source-references.js";
|
|
14
|
+
export class ContractMutationError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
details;
|
|
17
|
+
constructor(code, message, details) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "ContractMutationError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.details = details;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function loadDraftFromPath(inputPath, repoRoot) {
|
|
25
|
+
const absolute = path.isAbsolute(inputPath)
|
|
26
|
+
? inputPath
|
|
27
|
+
: path.resolve(repoRoot, inputPath);
|
|
28
|
+
let rawText;
|
|
29
|
+
try {
|
|
30
|
+
rawText = await readFile(absolute, "utf-8");
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error &&
|
|
34
|
+
typeof error === "object" &&
|
|
35
|
+
"code" in error &&
|
|
36
|
+
error.code === "ENOENT") {
|
|
37
|
+
throw new ContractMutationError("NOT_FOUND", `draft input not found: ${absolute}`);
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
let raw;
|
|
42
|
+
try {
|
|
43
|
+
raw = JSON.parse(rawText);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw new ContractMutationError("INVALID_INPUT", `draft is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
47
|
+
}
|
|
48
|
+
let draft;
|
|
49
|
+
try {
|
|
50
|
+
draft = parseTaskContractDraft(raw);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw new ContractMutationError("INVALID_INPUT", `draft schema validation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
}
|
|
55
|
+
const businessErrors = validateDraftBusinessRules(draft);
|
|
56
|
+
if (businessErrors.length > 0) {
|
|
57
|
+
throw new ContractMutationError("INVALID_INPUT", businessErrors.join("; "));
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
draft,
|
|
61
|
+
payloadSha256: sha256Utf8(rawText),
|
|
62
|
+
rawText,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export async function validateDraftForTask(input) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
const warnings = [];
|
|
68
|
+
if (input.draft.taskId !== input.taskId) {
|
|
69
|
+
errors.push(`draft.taskId "${input.draft.taskId}" does not match --task "${input.taskId}"`);
|
|
70
|
+
}
|
|
71
|
+
const paths = getTaskPaths(input.repoRoot, input.taskId);
|
|
72
|
+
try {
|
|
73
|
+
await readFile(paths.taskConfigPath, "utf-8");
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
errors.push(`task not found: ${input.taskId}`);
|
|
77
|
+
}
|
|
78
|
+
// apply must not rewrite reference bytes; only verify manifest if present.
|
|
79
|
+
const manifest = await readSourceManifest(paths.sourceDir);
|
|
80
|
+
if (manifest && input.draft.references) {
|
|
81
|
+
for (const ref of input.draft.references) {
|
|
82
|
+
const found = manifest.documents.some((doc) => doc.materializedPath === ref.ref ||
|
|
83
|
+
doc.materializedPath.endsWith(ref.ref) ||
|
|
84
|
+
ref.ref.endsWith(doc.materializedPath));
|
|
85
|
+
if (!found && ref.ref.includes("references/")) {
|
|
86
|
+
warnings.push(`draft reference "${ref.ref}" not found in source-manifest.json (apply will not create it)`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const business = validateDraftBusinessRules(input.draft);
|
|
91
|
+
errors.push(...business);
|
|
92
|
+
if (errors.length > 0)
|
|
93
|
+
return { ok: false, errors };
|
|
94
|
+
return { ok: true, warnings };
|
|
95
|
+
}
|
|
96
|
+
export async function diffTaskContract(input) {
|
|
97
|
+
const state = await observeTaskContractState(input.repoRoot, input.taskId);
|
|
98
|
+
const bytes = await loadObservedTaskBytes(input.repoRoot, input.taskId);
|
|
99
|
+
let currentConfig = null;
|
|
100
|
+
if (bytes.taskConfigRaw) {
|
|
101
|
+
currentConfig = parseTaskConfigForRuntime(bytes.taskConfigRaw);
|
|
102
|
+
}
|
|
103
|
+
const projectedReq = projectRequirementMarkdown(input.draft);
|
|
104
|
+
const projectedConstraints = projectConstraintsMarkdown(input.draft);
|
|
105
|
+
return diffDraftAgainstCurrent({
|
|
106
|
+
taskId: input.taskId,
|
|
107
|
+
draft: input.draft,
|
|
108
|
+
currentConfig,
|
|
109
|
+
currentStatus: state.effectiveStatus,
|
|
110
|
+
currentRevision: logicalRevisionForState(state),
|
|
111
|
+
requirementChanged: projectedReq !== (bytes.requirementBytes ?? ""),
|
|
112
|
+
constraintsChanged: projectedConstraints !== (bytes.constraintsBytes ?? ""),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function emptyObservedHashPlaceholder() {
|
|
116
|
+
// For legacy revision 0 with incomplete files, clients may pass a sentinel.
|
|
117
|
+
// Empty string is invalid; we accept "0".repeat(64) as "no observed content".
|
|
118
|
+
return "0".repeat(64);
|
|
119
|
+
}
|
|
120
|
+
export async function applyTaskContract(input) {
|
|
121
|
+
const validation = await validateDraftForTask({
|
|
122
|
+
repoRoot: input.repoRoot,
|
|
123
|
+
taskId: input.taskId,
|
|
124
|
+
draft: input.draft,
|
|
125
|
+
});
|
|
126
|
+
if (!validation.ok) {
|
|
127
|
+
throw new ContractMutationError("INVALID_INPUT", validation.errors.join("; "));
|
|
128
|
+
}
|
|
129
|
+
// Idempotency pre-check (before lock is fine for read; re-check under lock).
|
|
130
|
+
const existing = await findRequestLedgerEntry({
|
|
131
|
+
repoRoot: input.repoRoot,
|
|
132
|
+
taskId: input.taskId,
|
|
133
|
+
requestId: input.requestId,
|
|
134
|
+
});
|
|
135
|
+
if (existing) {
|
|
136
|
+
if (existing.requestPayloadSha256 !== input.requestPayloadSha256) {
|
|
137
|
+
throw new ContractMutationError("REQUEST_ID_REUSE_CONFLICT", `requestId ${input.requestId} was used with a different payload hash`, {
|
|
138
|
+
previousPayloadSha256: existing.requestPayloadSha256,
|
|
139
|
+
newPayloadSha256: input.requestPayloadSha256,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const ref = await readStoredContractRef(getTaskPaths(input.repoRoot, input.taskId).contractPath);
|
|
143
|
+
if (!ref) {
|
|
144
|
+
throw new ContractMutationError("OPERATION_NEEDS_RECONCILE", "request ledger hit but contract.json missing");
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
ref,
|
|
148
|
+
changedFiles: [],
|
|
149
|
+
idempotentReplay: true,
|
|
150
|
+
txId: existing.txId,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const lock = await acquireTaskContractLock({
|
|
154
|
+
repoRoot: input.repoRoot,
|
|
155
|
+
taskId: input.taskId,
|
|
156
|
+
requestId: input.requestId,
|
|
157
|
+
operation: "contract-apply",
|
|
158
|
+
});
|
|
159
|
+
try {
|
|
160
|
+
// Re-check ledger under lock
|
|
161
|
+
const existingUnderLock = await findRequestLedgerEntry({
|
|
162
|
+
repoRoot: input.repoRoot,
|
|
163
|
+
taskId: input.taskId,
|
|
164
|
+
requestId: input.requestId,
|
|
165
|
+
});
|
|
166
|
+
if (existingUnderLock) {
|
|
167
|
+
if (existingUnderLock.requestPayloadSha256 !== input.requestPayloadSha256) {
|
|
168
|
+
throw new ContractMutationError("REQUEST_ID_REUSE_CONFLICT", `requestId ${input.requestId} was used with a different payload hash`);
|
|
169
|
+
}
|
|
170
|
+
const ref = await readStoredContractRef(getTaskPaths(input.repoRoot, input.taskId).contractPath);
|
|
171
|
+
if (!ref) {
|
|
172
|
+
throw new ContractMutationError("OPERATION_NEEDS_RECONCILE", "request ledger hit but contract.json missing");
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
ref,
|
|
176
|
+
changedFiles: [],
|
|
177
|
+
idempotentReplay: true,
|
|
178
|
+
txId: existingUnderLock.txId,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const state = await observeTaskContractState(input.repoRoot, input.taskId);
|
|
182
|
+
if (state.effectiveStatus === "transaction-incomplete") {
|
|
183
|
+
throw new ContractMutationError("TRANSACTION_INCOMPLETE", "unfinished transaction; run task contract recover first", { errors: state.observationErrors });
|
|
184
|
+
}
|
|
185
|
+
const currentRevision = logicalRevisionForState(state);
|
|
186
|
+
if (input.expectedRevision !== currentRevision) {
|
|
187
|
+
throw new ContractMutationError("REVISION_CONFLICT", `expectedRevision ${input.expectedRevision} does not match current ${currentRevision}`, { currentRevision, expectedRevision: input.expectedRevision });
|
|
188
|
+
}
|
|
189
|
+
const observedHash = state.observedCanonicalHash ??
|
|
190
|
+
(currentRevision === 0 ? emptyObservedHashPlaceholder() : null);
|
|
191
|
+
if (!observedHash) {
|
|
192
|
+
throw new ContractMutationError("OBSERVED_HASH_CONFLICT", "unable to observe current canonical hash");
|
|
193
|
+
}
|
|
194
|
+
// Allow all-zero hash only for revision 0 incomplete observe
|
|
195
|
+
const expected = input.expectedObservedHash;
|
|
196
|
+
if (expected !== observedHash) {
|
|
197
|
+
// If client passes zero hash at rev0 and we also have no hash, accept when both zero
|
|
198
|
+
if (!(currentRevision === 0 &&
|
|
199
|
+
expected === emptyObservedHashPlaceholder() &&
|
|
200
|
+
(observedHash === emptyObservedHashPlaceholder() ||
|
|
201
|
+
!state.observedCanonicalHash))) {
|
|
202
|
+
// For rev0 when files exist, observedHash is real — must match.
|
|
203
|
+
if (state.observedCanonicalHash) {
|
|
204
|
+
throw new ContractMutationError("OBSERVED_HASH_CONFLICT", `expectedObservedHash does not match current observed hash`, {
|
|
205
|
+
expectedObservedHash: expected,
|
|
206
|
+
observedCanonicalHash: state.observedCanonicalHash,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (expected !== emptyObservedHashPlaceholder()) {
|
|
210
|
+
throw new ContractMutationError("OBSERVED_HASH_CONFLICT", `expectedObservedHash does not match current observed hash`, {
|
|
211
|
+
expectedObservedHash: expected,
|
|
212
|
+
observedCanonicalHash: observedHash,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (state.effectiveStatus === "externally-modified" &&
|
|
218
|
+
state.ref &&
|
|
219
|
+
input.expectedRevision === state.ref.revision) {
|
|
220
|
+
// Caller must pass the *observed* hash (drift) and matching revision to repair via apply.
|
|
221
|
+
// If they pass the stale ref.canonicalHash, reject.
|
|
222
|
+
if (expected === state.ref.canonicalHash) {
|
|
223
|
+
throw new ContractMutationError("EXTERNALLY_MODIFIED", "task is externally-modified; pass expectedObservedHash of current on-disk facts (not stale contract hash), or use adopt");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const bytes = await loadObservedTaskBytes(input.repoRoot, input.taskId);
|
|
227
|
+
if (!bytes.taskConfigRaw) {
|
|
228
|
+
throw new ContractMutationError("NOT_FOUND", `task.json missing for ${input.taskId}`);
|
|
229
|
+
}
|
|
230
|
+
const currentConfig = parseTaskConfigForRuntime(bytes.taskConfigRaw);
|
|
231
|
+
const mergedConfig = mergeManagedTaskConfigFields(currentConfig, input.draft);
|
|
232
|
+
// Re-parse through schema to ensure full TaskConfig shape for hash stability
|
|
233
|
+
const fullConfig = parseTaskConfigForRuntime(mergedConfig);
|
|
234
|
+
const taskConfigContent = serializeTaskConfigForDisk(fullConfig);
|
|
235
|
+
const requirementContent = projectRequirementMarkdown(input.draft);
|
|
236
|
+
const constraintsContent = projectConstraintsMarkdown(input.draft);
|
|
237
|
+
// Validate references manifest hash if present (do not modify)
|
|
238
|
+
const paths = getTaskPaths(input.repoRoot, input.taskId);
|
|
239
|
+
const manifestText = await readFile(getSourceManifestPath(paths.sourceDir), "utf-8").catch(() => null);
|
|
240
|
+
const sourceHashes = computeSourceHashes({
|
|
241
|
+
requirementBytes: requirementContent,
|
|
242
|
+
constraintsBytes: constraintsContent,
|
|
243
|
+
referenceManifestBytes: manifestText,
|
|
244
|
+
});
|
|
245
|
+
const taskConfigSha256 = computeTaskConfigSha256(fullConfig);
|
|
246
|
+
const canonicalHash = computeCanonicalHash({
|
|
247
|
+
sourceHashes,
|
|
248
|
+
taskConfigSha256,
|
|
249
|
+
});
|
|
250
|
+
const nextRevision = currentRevision + 1;
|
|
251
|
+
const nextRef = {
|
|
252
|
+
schemaVersion: TASK_CONTRACT_REF_SCHEMA_VERSION,
|
|
253
|
+
taskId: input.taskId,
|
|
254
|
+
revision: nextRevision,
|
|
255
|
+
projectionVersion: PROJECTION_VERSION,
|
|
256
|
+
canonicalizerVersion: CANONICALIZER_VERSION,
|
|
257
|
+
taskConfigSchemaVersion: TASK_CONFIG_SCHEMA_VERSION,
|
|
258
|
+
sourceHashes,
|
|
259
|
+
taskConfigSha256,
|
|
260
|
+
canonicalHash,
|
|
261
|
+
updatedAt: new Date().toISOString(),
|
|
262
|
+
lastRequestId: input.requestId,
|
|
263
|
+
};
|
|
264
|
+
await assertLockHeld(lock);
|
|
265
|
+
const { txId, ref } = await runJournaledContractCommit({
|
|
266
|
+
repoRoot: input.repoRoot,
|
|
267
|
+
taskId: input.taskId,
|
|
268
|
+
operation: "apply",
|
|
269
|
+
requestId: input.requestId,
|
|
270
|
+
requestPayloadSha256: input.requestPayloadSha256,
|
|
271
|
+
expectedRevision: input.expectedRevision,
|
|
272
|
+
expectedObservedCanonicalHash: expected,
|
|
273
|
+
nextRevision,
|
|
274
|
+
fencingToken: lock.record.fencingToken,
|
|
275
|
+
targets: {
|
|
276
|
+
requirementContent,
|
|
277
|
+
constraintsContent,
|
|
278
|
+
taskConfigContent,
|
|
279
|
+
contractRef: nextRef,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
await appendRequestLedgerEntry({
|
|
283
|
+
repoRoot: input.repoRoot,
|
|
284
|
+
taskId: input.taskId,
|
|
285
|
+
entry: {
|
|
286
|
+
schemaVersion: 1,
|
|
287
|
+
requestId: input.requestId,
|
|
288
|
+
requestPayloadSha256: input.requestPayloadSha256,
|
|
289
|
+
txId,
|
|
290
|
+
revision: ref.revision,
|
|
291
|
+
canonicalHash: ref.canonicalHash,
|
|
292
|
+
recordedAt: new Date().toISOString(),
|
|
293
|
+
resultSummary: { ok: true, operation: "apply" },
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
await finalizeTransaction({
|
|
297
|
+
repoRoot: input.repoRoot,
|
|
298
|
+
taskId: input.taskId,
|
|
299
|
+
txId,
|
|
300
|
+
});
|
|
301
|
+
const diff = await diffDraftAgainstCurrent({
|
|
302
|
+
taskId: input.taskId,
|
|
303
|
+
draft: input.draft,
|
|
304
|
+
currentConfig,
|
|
305
|
+
currentStatus: state.effectiveStatus,
|
|
306
|
+
currentRevision,
|
|
307
|
+
requirementChanged: true,
|
|
308
|
+
constraintsChanged: true,
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
ref,
|
|
312
|
+
changedFiles: [
|
|
313
|
+
"source/需求.md",
|
|
314
|
+
"source/执行约束.md",
|
|
315
|
+
"task.json",
|
|
316
|
+
"contract.json",
|
|
317
|
+
],
|
|
318
|
+
idempotentReplay: false,
|
|
319
|
+
txId,
|
|
320
|
+
risks: diff.risks,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
await lock.release();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic canonicalization rules (design §5.3.2):
|
|
3
|
+
* - UTF-8, LF line endings, single trailing newline for text projections
|
|
4
|
+
* - TaskConfig full canonical JSON: sorted object keys, preserve arrays,
|
|
5
|
+
* paths with `/`, no Unicode normalization, unknown fields rejected by schema
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeNewlinesToLf(text) {
|
|
8
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Canonical text projection: LF only + exactly one trailing newline.
|
|
12
|
+
* Empty string becomes a single newline.
|
|
13
|
+
*/
|
|
14
|
+
export function canonicalizeTextProjection(text) {
|
|
15
|
+
const lf = normalizeNewlinesToLf(text);
|
|
16
|
+
const withoutTrailing = lf.replace(/\n+$/u, "");
|
|
17
|
+
return `${withoutTrailing}\n`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Detect whether external bytes differ from LF-canonical form.
|
|
21
|
+
* External CRLF is NOT silently normalized for hash observation.
|
|
22
|
+
*/
|
|
23
|
+
export function isExternallyNonCanonicalText(rawUtf8) {
|
|
24
|
+
return rawUtf8 !== canonicalizeTextProjection(rawUtf8);
|
|
25
|
+
}
|
|
26
|
+
function isPlainObject(value) {
|
|
27
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Deep-sort object keys by Unicode code point; arrays keep order.
|
|
31
|
+
* Used for TaskConfig and hash material.
|
|
32
|
+
*/
|
|
33
|
+
export function sortKeysDeep(value) {
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
return value.map((item) => sortKeysDeep(item));
|
|
36
|
+
}
|
|
37
|
+
if (isPlainObject(value)) {
|
|
38
|
+
const keys = Object.keys(value).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const key of keys) {
|
|
41
|
+
out[key] = sortKeysDeep(value[key]);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Canonical JSON string: sorted keys, no whitespace, UTF-8 safe.
|
|
49
|
+
* null is distinct from missing (caller must not drop keys).
|
|
50
|
+
*/
|
|
51
|
+
export function canonicalizeJsonValue(value) {
|
|
52
|
+
return JSON.stringify(sortKeysDeep(value));
|
|
53
|
+
}
|
|
54
|
+
/** Path separators forced to `/` without Unicode normalization. */
|
|
55
|
+
export function canonicalizePath(pathValue) {
|
|
56
|
+
return pathValue.replace(/\\/g, "/");
|
|
57
|
+
}
|
|
58
|
+
export function canonicalizePathArray(paths) {
|
|
59
|
+
return paths.map(canonicalizePath);
|
|
60
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Fixed version constants for Task Contract projection/hashing. */
|
|
2
|
+
export const TASK_CONTRACT_REF_SCHEMA_VERSION = 1;
|
|
3
|
+
export const TASK_CONTRACT_DRAFT_SCHEMA_VERSION = 1;
|
|
4
|
+
export const PROJECTION_VERSION = 1;
|
|
5
|
+
export const CANONICALIZER_VERSION = 1;
|
|
6
|
+
export const TASK_CONFIG_SCHEMA_VERSION = 1;
|
|
7
|
+
/** Aliases used by schema / hash modules */
|
|
8
|
+
export const TASK_CONTRACT_PROJECTION_VERSION = PROJECTION_VERSION;
|
|
9
|
+
export const TASK_CONTRACT_CANONICALIZER_VERSION = CANONICALIZER_VERSION;
|
|
10
|
+
/** Managed task.json fields that Draft apply may patch/merge. */
|
|
11
|
+
export const MANAGED_TASK_CONFIG_FIELDS = [
|
|
12
|
+
"title",
|
|
13
|
+
"taskKind",
|
|
14
|
+
"featureId",
|
|
15
|
+
"allowedPaths",
|
|
16
|
+
"forbiddenPaths",
|
|
17
|
+
"hardConstraints",
|
|
18
|
+
"verifyCommands",
|
|
19
|
+
];
|
|
20
|
+
export const CONTRACT_FILE_NAME = "contract.json";
|
|
21
|
+
export const TRANSACTIONS_DIR_NAME = ".transactions";
|
|
22
|
+
export const REQUEST_LEDGER_FILE_NAME = "request-ledger.jsonl";
|
|
23
|
+
export const TASK_LOCK_FILE_NAME = ".contract.lock";
|
|
24
|
+
/** Aliases used by paths.ts helpers */
|
|
25
|
+
export const CONTRACT_JSON_BASENAME = CONTRACT_FILE_NAME;
|
|
26
|
+
export const TRANSACTIONS_DIRNAME = TRANSACTIONS_DIR_NAME;
|
|
27
|
+
export const REQUEST_LEDGER_BASENAME = REQUEST_LEDGER_FILE_NAME;
|
|
28
|
+
export const REQUIREMENT_FILE_NAME = "需求.md";
|
|
29
|
+
export const CONSTRAINTS_FILE_NAME = "执行约束.md";
|