@tea-agent/loop-agent 0.16.26 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -1
- 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 +55 -7
- package/dist/executors/pi-executor.js +206 -13
- package/dist/executors/pi-sdk-executor.js +21 -6
- package/dist/executors/shell-executor.js +55 -8
- package/dist/executors/shell-presets.js +16 -3
- 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 +266 -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-BEIdBogJ.js +9 -0
- package/dist/worker/console/static/assets/index-Rt0TqimP.css +1 -0
- package/dist/worker/console/static/index.html +13 -0
- package/dist/worker/console/vite.config.js +27 -0
- package/dist/worker/materialize/harness-task-materializer.js +34 -0
- package/dist/worker/observe/health.js +57 -0
- package/dist/worker/observe/routes.js +16 -9
- 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 +269 -116
- package/dist/workflows/dag/node-execution.js +25 -1
- package/dist/workflows/dag/retry-policy.js +16 -1
- package/dist/workflows/dag/runner.js +9 -0
- package/dist/workflows/dag/task-contract-binding.js +138 -0
- package/dist/workflows/dag/types.js +74 -10
- package/dist/workflows/dag/validate.js +25 -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 +49 -2
- 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,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";
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { MANAGED_TASK_CONFIG_FIELDS } from "./constants.js";
|
|
2
|
+
function maxRisk(a, b) {
|
|
3
|
+
const order = ["routine", "elevated", "blocking"];
|
|
4
|
+
return order[Math.max(order.indexOf(a), order.indexOf(b))];
|
|
5
|
+
}
|
|
6
|
+
function setDiff(before, after) {
|
|
7
|
+
const b = new Set(before);
|
|
8
|
+
const a = new Set(after);
|
|
9
|
+
return {
|
|
10
|
+
added: after.filter((x) => !b.has(x)),
|
|
11
|
+
removed: before.filter((x) => !a.has(x)),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Classify Draft vs current facts (design §5.3.6 risk table).
|
|
16
|
+
*/
|
|
17
|
+
export function diffDraftAgainstCurrent(input) {
|
|
18
|
+
const risks = [];
|
|
19
|
+
const changedManagedFields = [];
|
|
20
|
+
const cfg = input.currentConfig;
|
|
21
|
+
if (input.requirementChanged) {
|
|
22
|
+
risks.push({
|
|
23
|
+
code: "REQUIREMENT_CHANGED",
|
|
24
|
+
level: "routine",
|
|
25
|
+
message: "requirement markdown projection changes",
|
|
26
|
+
path: "source/需求.md",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (input.constraintsChanged) {
|
|
30
|
+
risks.push({
|
|
31
|
+
code: "CONSTRAINTS_CHANGED",
|
|
32
|
+
level: "routine",
|
|
33
|
+
message: "constraints markdown projection changes",
|
|
34
|
+
path: "source/执行约束.md",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (cfg) {
|
|
38
|
+
if (input.draft.title !== cfg.title) {
|
|
39
|
+
changedManagedFields.push("title");
|
|
40
|
+
risks.push({
|
|
41
|
+
code: "TITLE_CHANGED",
|
|
42
|
+
level: "routine",
|
|
43
|
+
message: "title change",
|
|
44
|
+
path: "task.json#title",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (input.draft.taskKind !== cfg.taskKind) {
|
|
48
|
+
changedManagedFields.push("taskKind");
|
|
49
|
+
risks.push({
|
|
50
|
+
code: "TASK_KIND_CHANGED",
|
|
51
|
+
level: "elevated",
|
|
52
|
+
message: `taskKind ${cfg.taskKind} → ${input.draft.taskKind}`,
|
|
53
|
+
path: "task.json#taskKind",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const allowed = setDiff(cfg.allowedPaths, input.draft.constraints.allowedPaths);
|
|
57
|
+
if (allowed.added.length > 0) {
|
|
58
|
+
changedManagedFields.push("allowedPaths");
|
|
59
|
+
risks.push({
|
|
60
|
+
code: "ALLOWED_PATHS_EXPANDED",
|
|
61
|
+
level: "elevated",
|
|
62
|
+
message: `expand allowedPaths: ${allowed.added.join(", ")}`,
|
|
63
|
+
path: "task.json#allowedPaths",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (allowed.removed.length > 0) {
|
|
67
|
+
if (!changedManagedFields.includes("allowedPaths")) {
|
|
68
|
+
changedManagedFields.push("allowedPaths");
|
|
69
|
+
}
|
|
70
|
+
risks.push({
|
|
71
|
+
code: "ALLOWED_PATHS_NARROWED",
|
|
72
|
+
level: "routine",
|
|
73
|
+
message: `narrow allowedPaths: ${allowed.removed.join(", ")}`,
|
|
74
|
+
path: "task.json#allowedPaths",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const forbidden = setDiff(cfg.forbiddenPaths, input.draft.constraints.forbiddenPaths);
|
|
78
|
+
if (forbidden.removed.length > 0) {
|
|
79
|
+
changedManagedFields.push("forbiddenPaths");
|
|
80
|
+
risks.push({
|
|
81
|
+
code: "FORBIDDEN_PATHS_REMOVED",
|
|
82
|
+
level: "elevated",
|
|
83
|
+
message: `remove forbiddenPaths: ${forbidden.removed.join(", ")}`,
|
|
84
|
+
path: "task.json#forbiddenPaths",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (forbidden.added.length > 0) {
|
|
88
|
+
if (!changedManagedFields.includes("forbiddenPaths")) {
|
|
89
|
+
changedManagedFields.push("forbiddenPaths");
|
|
90
|
+
}
|
|
91
|
+
risks.push({
|
|
92
|
+
code: "FORBIDDEN_PATHS_EXPANDED",
|
|
93
|
+
level: "routine",
|
|
94
|
+
message: `expand forbiddenPaths: ${forbidden.added.join(", ")}`,
|
|
95
|
+
path: "task.json#forbiddenPaths",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const beforeVerify = new Set(cfg.verifyCommands.map((c) => `${c.label}|${c.command}`));
|
|
99
|
+
const afterVerify = input.draft.verification.commands.map((c) => `${c.label}|${c.command}`);
|
|
100
|
+
const verifyRemoved = [...beforeVerify].filter((v) => !afterVerify.includes(v));
|
|
101
|
+
const verifyAdded = afterVerify.filter((v) => !beforeVerify.has(v));
|
|
102
|
+
if (verifyRemoved.length > 0) {
|
|
103
|
+
changedManagedFields.push("verifyCommands");
|
|
104
|
+
risks.push({
|
|
105
|
+
code: "VERIFY_WEAKENED",
|
|
106
|
+
level: "elevated",
|
|
107
|
+
message: `remove/weaken verification: ${verifyRemoved.join("; ")}`,
|
|
108
|
+
path: "task.json#verifyCommands",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (verifyAdded.length > 0) {
|
|
112
|
+
if (!changedManagedFields.includes("verifyCommands")) {
|
|
113
|
+
changedManagedFields.push("verifyCommands");
|
|
114
|
+
}
|
|
115
|
+
risks.push({
|
|
116
|
+
code: "VERIFY_ADDED",
|
|
117
|
+
level: "routine",
|
|
118
|
+
message: `add verification: ${verifyAdded.join("; ")}`,
|
|
119
|
+
path: "task.json#verifyCommands",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const draftHard = input.draft.hardConstraints ?? input.draft.constraints.invariants ?? [];
|
|
123
|
+
if (JSON.stringify(draftHard) !== JSON.stringify(cfg.hardConstraints ?? [])) {
|
|
124
|
+
changedManagedFields.push("hardConstraints");
|
|
125
|
+
risks.push({
|
|
126
|
+
code: "HARD_CONSTRAINTS_CHANGED",
|
|
127
|
+
level: "routine",
|
|
128
|
+
message: "hardConstraints changed",
|
|
129
|
+
path: "task.json#hardConstraints",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (input.draft.featureId !== undefined &&
|
|
133
|
+
input.draft.featureId !== cfg.featureId) {
|
|
134
|
+
changedManagedFields.push("featureId");
|
|
135
|
+
risks.push({
|
|
136
|
+
code: "FEATURE_ID_CHANGED",
|
|
137
|
+
level: "routine",
|
|
138
|
+
message: "featureId changed",
|
|
139
|
+
path: "task.json#featureId",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
for (const field of MANAGED_TASK_CONFIG_FIELDS) {
|
|
145
|
+
changedManagedFields.push(field);
|
|
146
|
+
}
|
|
147
|
+
risks.push({
|
|
148
|
+
code: "NO_CURRENT_CONFIG",
|
|
149
|
+
level: "elevated",
|
|
150
|
+
message: "no current task.json to diff against",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (input.currentStatus === "externally-modified" ||
|
|
154
|
+
input.currentStatus === "transaction-incomplete") {
|
|
155
|
+
risks.push({
|
|
156
|
+
code: "STATUS_BLOCKS_ROUTINE_APPLY",
|
|
157
|
+
level: "blocking",
|
|
158
|
+
message: `current effectiveStatus=${input.currentStatus}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
let highest = "routine";
|
|
162
|
+
for (const risk of risks) {
|
|
163
|
+
highest = maxRisk(highest, risk.level);
|
|
164
|
+
}
|
|
165
|
+
void highest;
|
|
166
|
+
return {
|
|
167
|
+
taskId: input.taskId,
|
|
168
|
+
currentRevision: input.currentRevision,
|
|
169
|
+
currentStatus: input.currentStatus,
|
|
170
|
+
risks,
|
|
171
|
+
changedManagedFields: [...new Set(changedManagedFields)],
|
|
172
|
+
requirementChanged: input.requirementChanged,
|
|
173
|
+
constraintsChanged: input.constraintsChanged,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** Compatibility alias */
|
|
177
|
+
export const diffTaskContractLegacy = diffDraftAgainstCurrent;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { CANONICALIZER_VERSION, PROJECTION_VERSION, TASK_CONFIG_SCHEMA_VERSION, } from "./constants.js";
|
|
3
|
+
import { canonicalizeJsonValue } from "./canonicalize.js";
|
|
4
|
+
export function sha256Utf8(content) {
|
|
5
|
+
return createHash("sha256").update(content).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
export function sha256OfCanonicalJson(value) {
|
|
8
|
+
return sha256Utf8(canonicalizeJsonValue(value));
|
|
9
|
+
}
|
|
10
|
+
export function computeTaskConfigSha256(taskConfig) {
|
|
11
|
+
return sha256OfCanonicalJson(taskConfig);
|
|
12
|
+
}
|
|
13
|
+
export function buildCanonicalHashMaterial(input) {
|
|
14
|
+
return {
|
|
15
|
+
projectionVersion: input.projectionVersion ?? PROJECTION_VERSION,
|
|
16
|
+
canonicalizerVersion: input.canonicalizerVersion ?? CANONICALIZER_VERSION,
|
|
17
|
+
taskConfigSchemaVersion: input.taskConfigSchemaVersion ?? TASK_CONFIG_SCHEMA_VERSION,
|
|
18
|
+
sourceHashes: input.sourceHashes,
|
|
19
|
+
taskConfigSha256: input.taskConfigSha256,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* canonicalHash over { projectionVersion, canonicalizerVersion,
|
|
24
|
+
* taskConfigSchemaVersion, sourceHashes, taskConfigSha256 }.
|
|
25
|
+
* contract.json itself is never part of this hash.
|
|
26
|
+
*/
|
|
27
|
+
export function computeCanonicalHash(input) {
|
|
28
|
+
return sha256OfCanonicalJson(buildCanonicalHashMaterial(input));
|
|
29
|
+
}
|
|
30
|
+
export function computeSourceHashes(input) {
|
|
31
|
+
const sourceHashes = {
|
|
32
|
+
requirementSha256: sha256Utf8(input.requirementBytes),
|
|
33
|
+
constraintsSha256: sha256Utf8(input.constraintsBytes),
|
|
34
|
+
};
|
|
35
|
+
if (input.referenceManifestBytes) {
|
|
36
|
+
sourceHashes.referenceManifestSha256 = sha256Utf8(input.referenceManifestBytes);
|
|
37
|
+
}
|
|
38
|
+
return sourceHashes;
|
|
39
|
+
}
|
|
40
|
+
export function refMatchesObserved(ref, observedCanonicalHash) {
|
|
41
|
+
return ref.canonicalHash === observedCanonicalHash;
|
|
42
|
+
}
|