@kylecheng3146/agent-ops 0.0.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/LICENSE +21 -0
- package/README.md +83 -0
- package/SECURITY.md +22 -0
- package/dist/packages/cli/src/args.js +322 -0
- package/dist/packages/cli/src/bin.js +290 -0
- package/dist/packages/cli/src/cli.js +80 -0
- package/dist/packages/cli/src/commands/config.js +8 -0
- package/dist/packages/cli/src/commands/doctor.js +32 -0
- package/dist/packages/cli/src/commands/index.js +16 -0
- package/dist/packages/cli/src/commands/init.js +65 -0
- package/dist/packages/cli/src/commands/review.js +71 -0
- package/dist/packages/cli/src/commands/task.js +141 -0
- package/dist/packages/cli/src/commands/trust.js +48 -0
- package/dist/packages/cli/src/commands/uninstall.js +58 -0
- package/dist/packages/cli/src/commands/update.js +58 -0
- package/dist/packages/cli/src/commands/verify.js +108 -0
- package/dist/packages/cli/src/output.js +53 -0
- package/dist/packages/cli/src/plan-output.js +28 -0
- package/dist/packages/cli/src/wizard.js +75 -0
- package/dist/runtime/src/adapters/claude/config.js +109 -0
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/claude/input.js +38 -0
- package/dist/runtime/src/adapters/claude/output.js +30 -0
- package/dist/runtime/src/adapters/codex/config.js +93 -0
- package/dist/runtime/src/adapters/codex/events.js +8 -0
- package/dist/runtime/src/adapters/codex/input.js +41 -0
- package/dist/runtime/src/adapters/codex/output.js +24 -0
- package/dist/runtime/src/config/explain.js +34 -0
- package/dist/runtime/src/config/load.js +25 -0
- package/dist/runtime/src/config/merge.js +170 -0
- package/dist/runtime/src/config/migrate.js +61 -0
- package/dist/runtime/src/contracts.js +1 -0
- package/dist/runtime/src/discovery/go.js +145 -0
- package/dist/runtime/src/discovery/index.js +40 -0
- package/dist/runtime/src/discovery/make.js +162 -0
- package/dist/runtime/src/discovery/node.js +175 -0
- package/dist/runtime/src/discovery/python.js +163 -0
- package/dist/runtime/src/discovery/rust.js +159 -0
- package/dist/runtime/src/discovery/types.js +1 -0
- package/dist/runtime/src/fs/hash.js +19 -0
- package/dist/runtime/src/fs/managed-block.js +90 -0
- package/dist/runtime/src/fs/manifest.js +24 -0
- package/dist/runtime/src/fs/mutation-worker.js +185 -0
- package/dist/runtime/src/fs/paths.js +96 -0
- package/dist/runtime/src/fs/transaction.js +498 -0
- package/dist/runtime/src/guardrails/destructive.js +207 -0
- package/dist/runtime/src/guardrails/evaluate.js +9 -0
- package/dist/runtime/src/guardrails/exceptions.js +49 -0
- package/dist/runtime/src/guardrails/secrets.js +97 -0
- package/dist/runtime/src/guardrails/types.js +9 -0
- package/dist/runtime/src/hooks/dispatch.js +78 -0
- package/dist/runtime/src/hooks/events.js +1 -0
- package/dist/runtime/src/hooks/hook-entry.js +19 -0
- package/dist/runtime/src/hooks/normalize.js +59 -0
- package/dist/runtime/src/hooks/output.js +12 -0
- package/dist/runtime/src/hooks/shell.js +138 -0
- package/dist/runtime/src/hooks/stop-verify.js +70 -0
- package/dist/runtime/src/install/apply.js +70 -0
- package/dist/runtime/src/install/doctor.js +196 -0
- package/dist/runtime/src/install/harness.js +87 -0
- package/dist/runtime/src/install/ownership.js +84 -0
- package/dist/runtime/src/install/plan.js +257 -0
- package/dist/runtime/src/install/profiles.js +28 -0
- package/dist/runtime/src/install/types.js +1 -0
- package/dist/runtime/src/install/uninstall.js +206 -0
- package/dist/runtime/src/install/update.js +123 -0
- package/dist/runtime/src/logging/local-log.js +158 -0
- package/dist/runtime/src/registry/npm.js +141 -0
- package/dist/runtime/src/review/claude-runner.js +4 -0
- package/dist/runtime/src/review/codex-runner.js +4 -0
- package/dist/runtime/src/review/packet.js +10 -0
- package/dist/runtime/src/review/result.js +24 -0
- package/dist/runtime/src/review/roles.js +3 -0
- package/dist/runtime/src/review/runner.js +45 -0
- package/dist/runtime/src/schema/validate.js +584 -0
- package/dist/runtime/src/security/permissions.js +654 -0
- package/dist/runtime/src/security/redact.js +41 -0
- package/dist/runtime/src/security/trust.js +209 -0
- package/dist/runtime/src/task/render.js +43 -0
- package/dist/runtime/src/task/service.js +235 -0
- package/dist/runtime/src/task/store.js +265 -0
- package/dist/runtime/src/verify/change-surface.js +86 -0
- package/dist/runtime/src/verify/evidence.js +89 -0
- package/dist/runtime/src/verify/fingerprint.js +67 -0
- package/dist/runtime/src/verify/scope.js +69 -0
- package/dist/runtime/src/verify/service.js +217 -0
- package/dist/runtime/src/verify/spawn.js +326 -0
- package/dist/runtime/src/verify/test-count.js +148 -0
- package/docs/en/spec/README.md +13 -0
- package/docs/en/spec/acceptance-and-evidence.md +21 -0
- package/docs/en/spec/delegation.md +21 -0
- package/docs/en/spec/guardrails.md +21 -0
- package/docs/en/spec/harness-adapters.md +21 -0
- package/docs/en/spec/judgment.md +21 -0
- package/docs/en/spec/loop-engineering.md +23 -0
- package/docs/en/spec/maintenance.md +21 -0
- package/docs/en/spec/review.md +21 -0
- package/docs/en/spec/troubleshooting.md +21 -0
- package/docs/zh-TW/spec/README.md +13 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +23 -0
- package/docs/zh-TW/spec/delegation.md +23 -0
- package/docs/zh-TW/spec/guardrails.md +23 -0
- package/docs/zh-TW/spec/harness-adapters.md +23 -0
- package/docs/zh-TW/spec/judgment.md +23 -0
- package/docs/zh-TW/spec/loop-engineering.md +23 -0
- package/docs/zh-TW/spec/maintenance.md +23 -0
- package/docs/zh-TW/spec/review.md +23 -0
- package/docs/zh-TW/spec/troubleshooting.md +23 -0
- package/package.json +41 -0
- package/schemas/config.schema.json +231 -0
- package/schemas/evidence.schema.json +115 -0
- package/schemas/manifest.schema.json +116 -0
- package/schemas/task.schema.json +59 -0
- package/templates/common/AGENTS.block.md +3 -0
- package/templates/common/CLAUDE.block.md +3 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
4
|
+
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "./permissions.js";
|
|
5
|
+
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
6
|
+
const BINDING_FIELDS = [
|
|
7
|
+
"canonicalPath",
|
|
8
|
+
"remoteIdentity",
|
|
9
|
+
"configHash",
|
|
10
|
+
"runtimeHash"
|
|
11
|
+
];
|
|
12
|
+
function isRecord(value) {
|
|
13
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function hasOnlyKeys(value, allowed) {
|
|
16
|
+
const allowedKeys = new Set(allowed);
|
|
17
|
+
return Object.keys(value).every((key) => allowedKeys.has(key));
|
|
18
|
+
}
|
|
19
|
+
function normalizedRepositoryPath(path, caseInsensitive) {
|
|
20
|
+
const normalized = path
|
|
21
|
+
.replace(/^\/+/, "")
|
|
22
|
+
.replace(/\/+$/, "")
|
|
23
|
+
.replace(/\.git$/i, "");
|
|
24
|
+
return caseInsensitive ? normalized.toLowerCase() : normalized;
|
|
25
|
+
}
|
|
26
|
+
function isCaseInsensitiveHost(host) {
|
|
27
|
+
return host === "github.com";
|
|
28
|
+
}
|
|
29
|
+
export function normalizeRemoteIdentity(remoteUrl) {
|
|
30
|
+
const value = remoteUrl.trim();
|
|
31
|
+
if (value.length === 0 || value.includes("\0")) {
|
|
32
|
+
throw new AgentOpsError("TRUST_REMOTE_INVALID", "Git remote identity must be a non-empty value.");
|
|
33
|
+
}
|
|
34
|
+
const scpLike = /^(?:[^@\s/:]+@)?([^:\s/]+):(.+)$/.exec(value);
|
|
35
|
+
if (!value.includes("://") &&
|
|
36
|
+
!/^[A-Za-z]:[\\/]/.test(value) &&
|
|
37
|
+
scpLike !== null) {
|
|
38
|
+
const host = scpLike[1];
|
|
39
|
+
const repository = scpLike[2];
|
|
40
|
+
if (host !== undefined && repository !== undefined) {
|
|
41
|
+
const normalizedHost = host.toLowerCase();
|
|
42
|
+
return `${normalizedHost}/${normalizedRepositoryPath(repository, isCaseInsensitiveHost(normalizedHost))}`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const url = new URL(value);
|
|
47
|
+
if (url.hostname.length > 0) {
|
|
48
|
+
const normalizedHost = url.hostname.toLowerCase();
|
|
49
|
+
const repository = normalizedRepositoryPath(url.pathname, isCaseInsensitiveHost(normalizedHost));
|
|
50
|
+
if (repository.length === 0) {
|
|
51
|
+
throw new AgentOpsError("TRUST_REMOTE_INVALID", "Git remote must include a repository path.");
|
|
52
|
+
}
|
|
53
|
+
const port = url.port.length === 0 ? "" : `:${url.port}`;
|
|
54
|
+
return `${normalizedHost}${port}/${repository}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error instanceof AgentOpsError) {
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const local = value.replace(/\\/g, "/").replace(/\.git$/i, "");
|
|
63
|
+
if (local.length === 0) {
|
|
64
|
+
throw new AgentOpsError("TRUST_REMOTE_INVALID", "Git remote identity could not be normalized.");
|
|
65
|
+
}
|
|
66
|
+
return `local:${process.platform === "win32" ? local.toLowerCase() : local}`;
|
|
67
|
+
}
|
|
68
|
+
function assertBinding(binding) {
|
|
69
|
+
if (!isAbsolute(binding.canonicalPath) ||
|
|
70
|
+
binding.canonicalPath.includes("\0") ||
|
|
71
|
+
binding.remoteIdentity.trim().length === 0 ||
|
|
72
|
+
binding.remoteIdentity.includes("\0") ||
|
|
73
|
+
!HASH_PATTERN.test(binding.configHash) ||
|
|
74
|
+
!HASH_PATTERN.test(binding.runtimeHash)) {
|
|
75
|
+
throw new AgentOpsError("TRUST_BINDING_INVALID", "Trust binding fields are invalid.");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function calculateTrustBinding(input) {
|
|
79
|
+
const canonicalPath = await realpath(resolve(input.repositoryPath));
|
|
80
|
+
const status = await lstat(canonicalPath);
|
|
81
|
+
if (!status.isDirectory()) {
|
|
82
|
+
throw new AgentOpsError("TRUST_REPOSITORY_INVALID", "Trust can be granted only to a repository directory.");
|
|
83
|
+
}
|
|
84
|
+
const binding = {
|
|
85
|
+
canonicalPath,
|
|
86
|
+
remoteIdentity: normalizeRemoteIdentity(input.remoteUrl),
|
|
87
|
+
configHash: input.configHash,
|
|
88
|
+
runtimeHash: input.runtimeHash
|
|
89
|
+
};
|
|
90
|
+
assertBinding(binding);
|
|
91
|
+
return binding;
|
|
92
|
+
}
|
|
93
|
+
function sameBinding(left, right) {
|
|
94
|
+
return BINDING_FIELDS.every((field) => left[field] === right[field]);
|
|
95
|
+
}
|
|
96
|
+
function parseBinding(value) {
|
|
97
|
+
if (!isRecord(value) ||
|
|
98
|
+
!hasOnlyKeys(value, BINDING_FIELDS) ||
|
|
99
|
+
typeof value.canonicalPath !== "string" ||
|
|
100
|
+
typeof value.remoteIdentity !== "string" ||
|
|
101
|
+
typeof value.configHash !== "string" ||
|
|
102
|
+
typeof value.runtimeHash !== "string") {
|
|
103
|
+
throw new AgentOpsError("TRUST_STORE_INVALID", "Trust store contains an invalid binding.");
|
|
104
|
+
}
|
|
105
|
+
const binding = {
|
|
106
|
+
canonicalPath: value.canonicalPath,
|
|
107
|
+
remoteIdentity: value.remoteIdentity,
|
|
108
|
+
configHash: value.configHash,
|
|
109
|
+
runtimeHash: value.runtimeHash
|
|
110
|
+
};
|
|
111
|
+
assertBinding(binding);
|
|
112
|
+
return binding;
|
|
113
|
+
}
|
|
114
|
+
function parseTrustStore(source) {
|
|
115
|
+
if (source === null) {
|
|
116
|
+
return { schemaVersion: 1, records: [] };
|
|
117
|
+
}
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(source);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
throw new AgentOpsError("TRUST_STORE_INVALID", "Trust store is not valid JSON.", { cause: error });
|
|
124
|
+
}
|
|
125
|
+
if (!isRecord(parsed) ||
|
|
126
|
+
!hasOnlyKeys(parsed, ["records", "schemaVersion"]) ||
|
|
127
|
+
parsed.schemaVersion !== 1 ||
|
|
128
|
+
!Array.isArray(parsed.records)) {
|
|
129
|
+
throw new AgentOpsError("TRUST_STORE_INVALID", "Trust store has an unsupported structure.");
|
|
130
|
+
}
|
|
131
|
+
const records = parsed.records.map((value) => {
|
|
132
|
+
if (!isRecord(value) ||
|
|
133
|
+
!hasOnlyKeys(value, ["binding", "grantedAt"]) ||
|
|
134
|
+
typeof value.grantedAt !== "string" ||
|
|
135
|
+
!Number.isFinite(Date.parse(value.grantedAt))) {
|
|
136
|
+
throw new AgentOpsError("TRUST_STORE_INVALID", "Trust store contains an invalid record.");
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
binding: parseBinding(value.binding),
|
|
140
|
+
grantedAt: value.grantedAt
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
return { schemaVersion: 1, records };
|
|
144
|
+
}
|
|
145
|
+
function mismatchFields(stored, current) {
|
|
146
|
+
return BINDING_FIELDS.filter((field) => stored[field] !== current[field]);
|
|
147
|
+
}
|
|
148
|
+
function matchScore(left, right) {
|
|
149
|
+
return BINDING_FIELDS.reduce((score, field) => score + (left[field] === right[field] ? 1 : 0), 0);
|
|
150
|
+
}
|
|
151
|
+
export class FileTrustStore {
|
|
152
|
+
#anchorDirectory;
|
|
153
|
+
#path;
|
|
154
|
+
constructor(path, anchorDirectory) {
|
|
155
|
+
this.#path = path;
|
|
156
|
+
this.#anchorDirectory = anchorDirectory;
|
|
157
|
+
}
|
|
158
|
+
async #read() {
|
|
159
|
+
return parseTrustStore(await readPrivateFile(this.#path, this.#anchorDirectory));
|
|
160
|
+
}
|
|
161
|
+
async #write(store) {
|
|
162
|
+
await writePrivateFile(this.#path, `${JSON.stringify(store, null, 2)}\n`, this.#anchorDirectory);
|
|
163
|
+
}
|
|
164
|
+
async status(binding) {
|
|
165
|
+
assertBinding(binding);
|
|
166
|
+
const store = await this.#read();
|
|
167
|
+
const exact = store.records.find((record) => sameBinding(record.binding, binding));
|
|
168
|
+
if (exact !== undefined) {
|
|
169
|
+
return { status: "TRUSTED", mismatchedFields: [] };
|
|
170
|
+
}
|
|
171
|
+
const related = store.records
|
|
172
|
+
.filter((record) => record.binding.canonicalPath === binding.canonicalPath ||
|
|
173
|
+
record.binding.remoteIdentity === binding.remoteIdentity)
|
|
174
|
+
.sort((left, right) => matchScore(right.binding, binding) -
|
|
175
|
+
matchScore(left.binding, binding))[0];
|
|
176
|
+
if (related === undefined) {
|
|
177
|
+
return { status: "UNTRUSTED", mismatchedFields: [] };
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
status: "STALE",
|
|
181
|
+
mismatchedFields: mismatchFields(related.binding, binding)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
async grant(binding, grantedAt = new Date().toISOString()) {
|
|
185
|
+
assertBinding(binding);
|
|
186
|
+
if (!Number.isFinite(Date.parse(grantedAt))) {
|
|
187
|
+
throw new AgentOpsError("TRUST_TIMESTAMP_INVALID", "Trust grant timestamp must be an ISO-compatible timestamp.");
|
|
188
|
+
}
|
|
189
|
+
await withPrivateFileLock(this.#path, this.#anchorDirectory, async () => {
|
|
190
|
+
const store = await this.#read();
|
|
191
|
+
const records = store.records.filter((record) => record.binding.canonicalPath !== binding.canonicalPath &&
|
|
192
|
+
record.binding.remoteIdentity !== binding.remoteIdentity);
|
|
193
|
+
records.push({ binding: structuredClone(binding), grantedAt });
|
|
194
|
+
await this.#write({ schemaVersion: 1, records });
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async revoke(binding) {
|
|
198
|
+
assertBinding(binding);
|
|
199
|
+
return await withPrivateFileLock(this.#path, this.#anchorDirectory, async () => {
|
|
200
|
+
const store = await this.#read();
|
|
201
|
+
const records = store.records.filter((record) => !sameBinding(record.binding, binding));
|
|
202
|
+
if (records.length === store.records.length) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
await this.#write({ schemaVersion: 1, records });
|
|
206
|
+
return true;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function safeTaskText(value) {
|
|
2
|
+
return value.replace(/[\u0000-\u001F\u007F-\u009F\u202A-\u202E\u2066-\u2069]/gu, (character) => {
|
|
3
|
+
if (character === "\n") {
|
|
4
|
+
return " ";
|
|
5
|
+
}
|
|
6
|
+
const codePoint = character.codePointAt(0);
|
|
7
|
+
return codePoint === undefined
|
|
8
|
+
? "\\u{fffd}"
|
|
9
|
+
: `\\u{${codePoint.toString(16)}}`;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export function renderTaskMarkdown(record) {
|
|
13
|
+
const lines = [
|
|
14
|
+
`# ${safeTaskText(record.task.title)}`,
|
|
15
|
+
"",
|
|
16
|
+
`Task ID: ${record.task.id}`,
|
|
17
|
+
`Status: ${record.status}`,
|
|
18
|
+
`Created: ${record.createdAt}`,
|
|
19
|
+
`Updated: ${record.updatedAt}`,
|
|
20
|
+
...(record.completedAt === null
|
|
21
|
+
? []
|
|
22
|
+
: [`Completed: ${record.completedAt}`]),
|
|
23
|
+
...(record.archivedAt === null
|
|
24
|
+
? []
|
|
25
|
+
: [`Archived: ${record.archivedAt}`]),
|
|
26
|
+
"",
|
|
27
|
+
"## Acceptance criteria",
|
|
28
|
+
""
|
|
29
|
+
];
|
|
30
|
+
for (const criterion of record.task.criteria) {
|
|
31
|
+
const references = record.evidence[criterion.id] ?? [];
|
|
32
|
+
lines.push(`- ${references.length > 0 ? "[x]" : "[ ]"} ${safeTaskText(criterion.description)} (\`${criterion.id}\`)`, ` - Verifiers: ${criterion.verifierIds
|
|
33
|
+
.map((id) => `\`${id}\``)
|
|
34
|
+
.join(", ")}`);
|
|
35
|
+
if (references.length === 0) {
|
|
36
|
+
lines.push(" - Evidence: none");
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
lines.push(" - Evidence:", ...references.map((reference) => ` - ${safeTaskText(reference)}`));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return `${lines.join("\n")}\n`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { SCHEMA_VERSION } from "../contracts.js";
|
|
3
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
4
|
+
import { validateTask } from "../schema/validate.js";
|
|
5
|
+
import { renderTaskMarkdown } from "./render.js";
|
|
6
|
+
import { advanceFailureFingerprint } from "../verify/fingerprint.js";
|
|
7
|
+
const SESSION_ID_PATTERN = /^[^\0\r\n]{1,256}$/u;
|
|
8
|
+
function defaultTaskId() {
|
|
9
|
+
return `task-${randomUUID()}`;
|
|
10
|
+
}
|
|
11
|
+
function cloneRecord(record) {
|
|
12
|
+
return structuredClone(record);
|
|
13
|
+
}
|
|
14
|
+
function taskError(code, message) {
|
|
15
|
+
return new AgentOpsError(code, message);
|
|
16
|
+
}
|
|
17
|
+
function findTask(state, taskId) {
|
|
18
|
+
const record = state.tasks.find((candidate) => candidate.task.id === taskId);
|
|
19
|
+
if (record === undefined) {
|
|
20
|
+
throw taskError("TASK_NOT_FOUND", `Task not found: ${taskId}`);
|
|
21
|
+
}
|
|
22
|
+
return record;
|
|
23
|
+
}
|
|
24
|
+
function replaceTask(state, record) {
|
|
25
|
+
const index = state.tasks.findIndex((candidate) => candidate.task.id === record.task.id);
|
|
26
|
+
if (index === -1) {
|
|
27
|
+
throw taskError("TASK_NOT_FOUND", `Task not found: ${record.task.id}`);
|
|
28
|
+
}
|
|
29
|
+
state.tasks[index] = record;
|
|
30
|
+
}
|
|
31
|
+
function assertTimestamp(value) {
|
|
32
|
+
if (value.length === 0 ||
|
|
33
|
+
value.length > 64 ||
|
|
34
|
+
!Number.isFinite(Date.parse(value))) {
|
|
35
|
+
throw taskError("TASK_TIMESTAMP_INVALID", "Task timestamps must be ISO-compatible.");
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function assertSessionId(sessionId) {
|
|
40
|
+
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
|
41
|
+
throw taskError("TASK_SESSION_INVALID", "Session identity must be a bounded non-empty string.");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function normalizeEvidence(task, input) {
|
|
45
|
+
const criterionIds = new Set(task.criteria.map((criterion) => criterion.id));
|
|
46
|
+
if (Object.keys(input).length !== criterionIds.size ||
|
|
47
|
+
Object.keys(input).some((criterionId) => !criterionIds.has(criterionId))) {
|
|
48
|
+
throw taskError("TASK_EVIDENCE_INCOMPLETE", "Completion requires evidence for every acceptance criterion.");
|
|
49
|
+
}
|
|
50
|
+
const evidence = {};
|
|
51
|
+
for (const criterion of task.criteria) {
|
|
52
|
+
const references = input[criterion.id];
|
|
53
|
+
if (references === undefined ||
|
|
54
|
+
references.length === 0 ||
|
|
55
|
+
references.some((reference) => typeof reference !== "string" ||
|
|
56
|
+
reference.length === 0 ||
|
|
57
|
+
reference.length > 4096 ||
|
|
58
|
+
reference.includes("\0")) ||
|
|
59
|
+
new Set(references).size !== references.length) {
|
|
60
|
+
throw taskError("TASK_EVIDENCE_INCOMPLETE", `Criterion ${criterion.id} requires valid evidence references.`);
|
|
61
|
+
}
|
|
62
|
+
evidence[criterion.id] = [...references];
|
|
63
|
+
}
|
|
64
|
+
return evidence;
|
|
65
|
+
}
|
|
66
|
+
export class TaskService {
|
|
67
|
+
#store;
|
|
68
|
+
#generateId;
|
|
69
|
+
#now;
|
|
70
|
+
constructor(store, options = {}) {
|
|
71
|
+
this.#store = store;
|
|
72
|
+
this.#generateId = options.generateId ?? defaultTaskId;
|
|
73
|
+
this.#now = options.now ?? (() => new Date().toISOString());
|
|
74
|
+
}
|
|
75
|
+
async create(input) {
|
|
76
|
+
const task = {
|
|
77
|
+
schemaVersion: SCHEMA_VERSION,
|
|
78
|
+
id: this.#generateId(),
|
|
79
|
+
title: input.title,
|
|
80
|
+
criteria: [...input.criteria]
|
|
81
|
+
};
|
|
82
|
+
const validation = validateTask(task);
|
|
83
|
+
if (!validation.ok) {
|
|
84
|
+
throw taskError("TASK_INVALID", validation.errors[0]?.message ?? "Task input is invalid.");
|
|
85
|
+
}
|
|
86
|
+
const now = assertTimestamp(this.#now());
|
|
87
|
+
return await this.#store.mutate((state) => {
|
|
88
|
+
if (state.tasks.some((record) => record.task.id === validation.value.id)) {
|
|
89
|
+
throw taskError("TASK_ID_CONFLICT", `Task ID already exists: ${validation.value.id}`);
|
|
90
|
+
}
|
|
91
|
+
const record = {
|
|
92
|
+
task: validation.value,
|
|
93
|
+
status: "active",
|
|
94
|
+
evidence: {},
|
|
95
|
+
createdAt: now,
|
|
96
|
+
updatedAt: now,
|
|
97
|
+
completedAt: null,
|
|
98
|
+
archivedAt: null,
|
|
99
|
+
failureFingerprint: null
|
|
100
|
+
};
|
|
101
|
+
state.tasks.push(record);
|
|
102
|
+
return cloneRecord(record);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async list() {
|
|
106
|
+
const state = await this.#store.read();
|
|
107
|
+
return state.tasks
|
|
108
|
+
.map(cloneRecord)
|
|
109
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) ||
|
|
110
|
+
left.task.id.localeCompare(right.task.id));
|
|
111
|
+
}
|
|
112
|
+
async status(query) {
|
|
113
|
+
if ((query.taskId === undefined) ===
|
|
114
|
+
(query.sessionId === undefined)) {
|
|
115
|
+
throw taskError("TASK_STATUS_TARGET_REQUIRED", "Task status requires exactly one task or session identity.");
|
|
116
|
+
}
|
|
117
|
+
const state = await this.#store.read();
|
|
118
|
+
if (query.taskId !== undefined) {
|
|
119
|
+
return cloneRecord(findTask({
|
|
120
|
+
schemaVersion: 1,
|
|
121
|
+
tasks: [...state.tasks],
|
|
122
|
+
sessions: [...state.sessions]
|
|
123
|
+
}, query.taskId));
|
|
124
|
+
}
|
|
125
|
+
const sessionId = query.sessionId;
|
|
126
|
+
if (sessionId === undefined) {
|
|
127
|
+
throw taskError("TASK_STATUS_TARGET_REQUIRED", "Task status requires a session identity.");
|
|
128
|
+
}
|
|
129
|
+
assertSessionId(sessionId);
|
|
130
|
+
const attachment = state.sessions.find((candidate) => candidate.sessionId === sessionId);
|
|
131
|
+
if (attachment === undefined) {
|
|
132
|
+
throw taskError("TASK_SESSION_UNATTACHED", "The session is not attached to a task.");
|
|
133
|
+
}
|
|
134
|
+
const record = state.tasks.find((candidate) => candidate.task.id === attachment.taskId);
|
|
135
|
+
if (record === undefined) {
|
|
136
|
+
throw taskError("TASK_STATE_INVALID", "The session references an unknown task.");
|
|
137
|
+
}
|
|
138
|
+
return cloneRecord(record);
|
|
139
|
+
}
|
|
140
|
+
async attach(sessionId, taskId) {
|
|
141
|
+
assertSessionId(sessionId);
|
|
142
|
+
const now = assertTimestamp(this.#now());
|
|
143
|
+
return await this.#store.mutate((state) => {
|
|
144
|
+
const record = findTask(state, taskId);
|
|
145
|
+
if (record.status !== "active") {
|
|
146
|
+
throw taskError("TASK_NOT_ACTIVE", "Only an active task can be attached to a session.");
|
|
147
|
+
}
|
|
148
|
+
const currentIndex = state.sessions.findIndex((attachment) => attachment.sessionId === sessionId);
|
|
149
|
+
const attachment = { sessionId, taskId, attachedAt: now };
|
|
150
|
+
if (currentIndex === -1) {
|
|
151
|
+
state.sessions.push(attachment);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
state.sessions[currentIndex] = attachment;
|
|
155
|
+
}
|
|
156
|
+
return cloneRecord(record);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
async complete(taskId, evidenceInput) {
|
|
160
|
+
const now = assertTimestamp(this.#now());
|
|
161
|
+
return await this.#store.mutate((state) => {
|
|
162
|
+
const current = findTask(state, taskId);
|
|
163
|
+
if (current.status === "archived") {
|
|
164
|
+
throw taskError("TASK_NOT_ACTIVE", "An archived task cannot be completed.");
|
|
165
|
+
}
|
|
166
|
+
const evidence = normalizeEvidence(current.task, evidenceInput);
|
|
167
|
+
if (current.status === "complete") {
|
|
168
|
+
if (JSON.stringify(current.evidence) !== JSON.stringify(evidence)) {
|
|
169
|
+
throw taskError("TASK_ALREADY_COMPLETE", "Completed task evidence cannot be replaced.");
|
|
170
|
+
}
|
|
171
|
+
return cloneRecord(current);
|
|
172
|
+
}
|
|
173
|
+
const completed = {
|
|
174
|
+
...current,
|
|
175
|
+
status: "complete",
|
|
176
|
+
evidence,
|
|
177
|
+
updatedAt: now,
|
|
178
|
+
completedAt: now
|
|
179
|
+
};
|
|
180
|
+
replaceTask(state, completed);
|
|
181
|
+
return cloneRecord(completed);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
async archive(taskId) {
|
|
185
|
+
const now = assertTimestamp(this.#now());
|
|
186
|
+
return await this.#store.mutate((state) => {
|
|
187
|
+
const current = findTask(state, taskId);
|
|
188
|
+
if (current.status === "archived") {
|
|
189
|
+
return cloneRecord(current);
|
|
190
|
+
}
|
|
191
|
+
const archived = {
|
|
192
|
+
...current,
|
|
193
|
+
status: "archived",
|
|
194
|
+
updatedAt: now,
|
|
195
|
+
archivedAt: now
|
|
196
|
+
};
|
|
197
|
+
replaceTask(state, archived);
|
|
198
|
+
state.sessions = state.sessions.filter((attachment) => attachment.taskId !== taskId);
|
|
199
|
+
return cloneRecord(archived);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async export(taskId) {
|
|
203
|
+
return renderTaskMarkdown(await this.status({ taskId }));
|
|
204
|
+
}
|
|
205
|
+
async recordFailure(taskId, fingerprint) {
|
|
206
|
+
const now = assertTimestamp(this.#now());
|
|
207
|
+
return await this.#store.mutate((state) => {
|
|
208
|
+
const current = findTask(state, taskId);
|
|
209
|
+
if (current.status === "archived") {
|
|
210
|
+
throw taskError("TASK_NOT_ACTIVE", "An archived task cannot record verification failures.");
|
|
211
|
+
}
|
|
212
|
+
const advanced = advanceFailureFingerprint(current.failureFingerprint, fingerprint, now);
|
|
213
|
+
replaceTask(state, {
|
|
214
|
+
...current,
|
|
215
|
+
updatedAt: now,
|
|
216
|
+
failureFingerprint: advanced.state
|
|
217
|
+
});
|
|
218
|
+
return structuredClone(advanced);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
async clearFailure(taskId) {
|
|
222
|
+
const now = assertTimestamp(this.#now());
|
|
223
|
+
await this.#store.mutate((state) => {
|
|
224
|
+
const current = findTask(state, taskId);
|
|
225
|
+
if (current.failureFingerprint === null) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
replaceTask(state, {
|
|
229
|
+
...current,
|
|
230
|
+
updatedAt: now,
|
|
231
|
+
failureFingerprint: null
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|