@piagent/platform 1.2.9 → 1.2.11

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.
Files changed (112) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +29 -6
  3. package/adapters/backend-api/profile.json +1 -1
  4. package/adapters/be-readonly-fe/profile.json +2 -2
  5. package/adapters/data/profile.json +1 -1
  6. package/adapters/devops/profile.json +1 -3
  7. package/adapters/docs/profile.json +1 -1
  8. package/adapters/fullstack/profile.json +2 -4
  9. package/adapters/generic/profile.json +1 -1
  10. package/adapters/mobile/profile.json +1 -2
  11. package/adapters/node-typescript/profile.json +1 -3
  12. package/benchmarks/core-v1/suite.json +51 -0
  13. package/benchmarks/core-v1/tasks/discount-order/grade.mjs +17 -0
  14. package/benchmarks/core-v1/tasks/discount-order/project/package.json +8 -0
  15. package/benchmarks/core-v1/tasks/discount-order/project/src/discount.js +3 -0
  16. package/benchmarks/core-v1/tasks/discount-order/project/src/order.js +6 -0
  17. package/benchmarks/core-v1/tasks/discount-order/project/test/order.test.js +7 -0
  18. package/benchmarks/core-v1/tasks/discount-order/prompt.md +4 -0
  19. package/benchmarks/core-v1/tasks/invoice-quantity/grade.mjs +17 -0
  20. package/benchmarks/core-v1/tasks/invoice-quantity/project/package.json +8 -0
  21. package/benchmarks/core-v1/tasks/invoice-quantity/project/src/invoice.js +3 -0
  22. package/benchmarks/core-v1/tasks/invoice-quantity/project/test/invoice.test.js +7 -0
  23. package/benchmarks/core-v1/tasks/invoice-quantity/prompt.md +3 -0
  24. package/benchmarks/core-v1/tasks/protected-env-refusal/grade.mjs +11 -0
  25. package/benchmarks/core-v1/tasks/protected-env-refusal/project/README.md +4 -0
  26. package/benchmarks/core-v1/tasks/protected-env-refusal/project/package.json +8 -0
  27. package/benchmarks/core-v1/tasks/protected-env-refusal/project/test/smoke.test.js +4 -0
  28. package/benchmarks/core-v1/tasks/protected-env-refusal/prompt.md +4 -0
  29. package/benchmarks/core-v1/tasks/targeted-session/grade.mjs +14 -0
  30. package/benchmarks/core-v1/tasks/targeted-session/project/package.json +8 -0
  31. package/benchmarks/core-v1/tasks/targeted-session/project/src/auth/session.js +3 -0
  32. package/benchmarks/core-v1/tasks/targeted-session/project/src/billing/currency.js +3 -0
  33. package/benchmarks/core-v1/tasks/targeted-session/project/src/catalog/search.js +3 -0
  34. package/benchmarks/core-v1/tasks/targeted-session/project/src/notifications/channel.js +3 -0
  35. package/benchmarks/core-v1/tasks/targeted-session/project/src/search/tokenize.js +3 -0
  36. package/benchmarks/core-v1/tasks/targeted-session/project/test/session.test.js +7 -0
  37. package/benchmarks/core-v1/tasks/targeted-session/prompt.md +4 -0
  38. package/catalog/capabilities.json +12 -12
  39. package/docs/command-reference-vietnamese.md +15 -8
  40. package/docs/context-engine.md +56 -15
  41. package/docs/distribution-standard.md +11 -8
  42. package/docs/mcp-and-tools.md +20 -7
  43. package/docs/model-options.md +7 -10
  44. package/docs/operator-manual-vietnamese.md +102 -37
  45. package/docs/package-architecture-notes.md +3 -1
  46. package/docs/publishing-for-teams.md +5 -0
  47. package/docs/quality-benchmark.md +225 -58
  48. package/docs/quickstart-vietnamese.md +5 -0
  49. package/docs/release-install-policy.md +41 -7
  50. package/docs/runtime-harness-standard.md +47 -29
  51. package/docs/runtime-policy-design.md +23 -12
  52. package/docs/runtime-quality-baseline.md +31 -17
  53. package/docs/security-threat-model.md +7 -2
  54. package/docs/subagents-and-multiagent.md +1 -1
  55. package/docs/task-implementation-contract.md +113 -177
  56. package/docs/task-lifecycle-vietnamese.md +129 -125
  57. package/docs/team-onboarding.md +12 -4
  58. package/docs/usage-observability.md +15 -8
  59. package/docs-site/architecture.html +1 -1
  60. package/docs-site/commands.html +1 -1
  61. package/docs-site/content/context-engine.html +8 -1
  62. package/docs-site/content/runtime.html +10 -0
  63. package/docs-site/content/security.html +1 -0
  64. package/docs-site/context-engine.html +9 -2
  65. package/docs-site/index.html +1 -1
  66. package/docs-site/mcp-approval.html +1 -1
  67. package/docs-site/mcp-auth.html +1 -1
  68. package/docs-site/mcp-servers.html +1 -1
  69. package/docs-site/mcp.html +1 -1
  70. package/docs-site/permissions.html +1 -1
  71. package/docs-site/profiles.html +1 -1
  72. package/docs-site/quickstart.html +1 -1
  73. package/docs-site/runtime.html +11 -1
  74. package/docs-site/scope.html +1 -1
  75. package/docs-site/security.html +2 -1
  76. package/docs-site/team.html +1 -1
  77. package/docs-site/workflows.html +1 -1
  78. package/evals/fixtures/task-contract.invalid.json +15 -1
  79. package/evals/fixtures/task-contract.valid.json +18 -2
  80. package/package.json +3 -2
  81. package/packages/piagent-core/README.md +43 -4
  82. package/packages/piagent-core/benchmark/benchmark-core.js +691 -0
  83. package/packages/piagent-core/extensions/context-engine.js +209 -120
  84. package/packages/piagent-core/extensions/context-index-policy.js +1 -1
  85. package/packages/piagent-core/extensions/guard-types.ts +35 -3
  86. package/packages/piagent-core/extensions/local-state-path.js +59 -0
  87. package/packages/piagent-core/extensions/piagent-guard.ts +2073 -222
  88. package/packages/piagent-core/extensions/policy-core.js +11 -1
  89. package/packages/piagent-core/extensions/runtime-evidence.js +39 -19
  90. package/packages/piagent-core/extensions/state-retention.js +218 -0
  91. package/packages/piagent-core/extensions/task-lifecycle.js +138 -0
  92. package/packages/piagent-core/extensions/task-state.js +630 -0
  93. package/packages/piagent-core/mcp/mcp-config-layers.js +52 -6
  94. package/packages/piagent-core/mcp/mcp-tool-naming.js +12 -1
  95. package/packages/piagent-core/package.json +1 -1
  96. package/packages/piagent-core/policies/base-policy.json +1 -0
  97. package/packages/piagent-core/prompts/be-to-fe.md +14 -22
  98. package/packages/piagent-core/prompts/platform-improve.md +12 -17
  99. package/packages/piagent-core/prompts/review.md +7 -11
  100. package/packages/piagent-core/prompts/scout.md +6 -10
  101. package/packages/piagent-core/prompts/task.md +11 -17
  102. package/packages/piagent-core/skills/piagent-ops/SKILL.md +12 -15
  103. package/packages/piagent-core/subagents/piagent-reviewer.md +3 -3
  104. package/schemas/task-contract.schema.json +267 -104
  105. package/scripts/benchmark-runner.mjs +1172 -0
  106. package/scripts/install-global.sh +1 -1
  107. package/scripts/migrate-project-state.mjs +94 -3
  108. package/scripts/piagent-cli.mjs +1 -1
  109. package/scripts/update-global.mjs +36 -3
  110. package/scripts/verify-local.sh +15 -0
  111. package/templates/project/.pi/task-contract.template.json +19 -2
  112. package/templates/project/AGENTS.md +10 -20
@@ -0,0 +1,630 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { ensurePrivateStateDirectory, resolveLocalStatePath } from "./local-state-path.js";
6
+
7
+ export const TASK_CONTRACT_SCHEMA_VERSION = 2;
8
+ export const DEFAULT_MAX_TASK_ATTEMPTS = 3;
9
+ const TASK_OUTCOMES = ["pending", "completed", "blocked", "partial", "failed"];
10
+ const REVIEW_LENSES = ["correctness", "tests", "scope", "security", "docs", "release", "package"];
11
+ const TASK_CONTRACT_FIELDS = new Set([
12
+ "schemaVersion", "taskRunId", "taskId", "sessionId", "sessionName", "changeMode", "attempt", "maxAttempts",
13
+ "previousAttempts", "summary", "riskLane", "intakeMode", "expectedOutput", "acceptanceCriteria", "scope", "outOfScope",
14
+ "protectedPaths", "requiredContext", "contextManifest", "memoryCitations", "mcpCapabilities", "verifyGroup",
15
+ "verifyCommands", "workPlan", "reviewLenses", "orchestration", "baselineChangedFiles", "baselineFileDigests",
16
+ "observedChangedFiles", "finalWorkingTreeFiles", "finalFileDigests", "changedFiles", "verifyEvidence", "trace",
17
+ "failedAt", "failureReason", "ruledOut", "migratedFromSchemaVersion", "createdAt", "updatedAt"
18
+ ]);
19
+ const PREVIOUS_ATTEMPT_FIELDS = new Set(["taskRunId", "attempt", "outcome", "failedAt", "reason", "ruledOut", "recordedAt"]);
20
+ const CITATION_FIELDS = new Set(["path", "reason"]);
21
+ const WORK_PLAN_FIELDS = new Set(["id", "title", "role", "mode", "status", "dependsOn", "note", "updatedAt"]);
22
+ const ORCHESTRATION_FIELDS = new Set(["mode", "subagents", "reason", "fieldGuidePath", "modelRoles"]);
23
+ const MODEL_ROLE_FIELDS = new Set(["planner", "worker", "reviewer", "watchdog"]);
24
+ const VERIFY_EVIDENCE_FIELDS = new Set(["command", "exitCode", "summary", "recordedAt", "observed", "observedAt", "isError", "matchedProfileCommand", "workingTreeDigest"]);
25
+ const TRACE_FIELDS = new Set(["outcome", "friction", "notes", "recordedAt"]);
26
+
27
+ export function safeTaskId(value) {
28
+ return String(value ?? "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "task";
29
+ }
30
+
31
+ function stateRoot(cwd) {
32
+ return path.join(cwd, ".pi", "piagent-state");
33
+ }
34
+
35
+ function tasksRoot(cwd) {
36
+ return path.join(stateRoot(cwd), "tasks");
37
+ }
38
+
39
+ function sessionsRoot(cwd) {
40
+ return path.join(stateRoot(cwd), "session-tasks");
41
+ }
42
+
43
+ function taskRunPath(cwd, taskRunId) {
44
+ return path.join(tasksRoot(cwd), `${safeTaskId(taskRunId)}.json`);
45
+ }
46
+
47
+ function sessionBindingPath(cwd, sessionId) {
48
+ const digest = crypto.createHash("sha256").update(String(sessionId || "unknown")).digest("hex");
49
+ return path.join(sessionsRoot(cwd), `${digest}.json`);
50
+ }
51
+
52
+ function readTaskRun(cwd, taskRunId) {
53
+ const raw = readJson(cwd, taskRunPath(cwd, taskRunId));
54
+ const task = normalizeTaskContract(raw, { sourceName: taskRunId });
55
+ return task?.taskRunId === safeTaskId(taskRunId) ? task : undefined;
56
+ }
57
+
58
+ function readJson(cwd, filePath) {
59
+ try {
60
+ const safePath = resolveLocalStatePath(cwd, filePath, { label: "Task state", kind: "file" });
61
+ const descriptor = fs.openSync(safePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
62
+ try {
63
+ return JSON.parse(fs.readFileSync(descriptor, "utf8"));
64
+ } finally {
65
+ fs.closeSync(descriptor);
66
+ }
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ function writeJsonAtomic(cwd, filePath, value) {
73
+ const parent = ensurePrivateStateDirectory(cwd, path.dirname(filePath), "Task state directory");
74
+ const safePath = resolveLocalStatePath(cwd, filePath, { label: "Task state" });
75
+ const temporary = path.join(parent, `${path.basename(safePath)}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
76
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
77
+ fs.renameSync(temporary, safePath);
78
+ try {
79
+ fs.chmodSync(parent, 0o700);
80
+ fs.chmodSync(safePath, 0o600);
81
+ } catch {
82
+ // Best effort on filesystems that do not expose POSIX modes.
83
+ }
84
+ }
85
+
86
+ function stringArray(value) {
87
+ return Array.isArray(value) ? [...new Set(value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()))] : [];
88
+ }
89
+
90
+ function stringRecord(value) {
91
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
92
+ return Object.fromEntries(Object.entries(value)
93
+ .filter(([key, item]) => key && typeof item === "string")
94
+ .map(([key, item]) => [key.replaceAll("\\", "/"), item]));
95
+ }
96
+
97
+ function positiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
98
+ return Number.isInteger(value) && value > 0 ? Math.min(value, maximum) : fallback;
99
+ }
100
+
101
+ function validTimestamp(value) {
102
+ return typeof value === "string" && value.trim().length > 0 && Number.isFinite(Date.parse(value));
103
+ }
104
+
105
+ function normalizedTimestamp(value, fallback) {
106
+ return validTimestamp(value) ? value : fallback;
107
+ }
108
+
109
+ function isRecord(value) {
110
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
111
+ }
112
+
113
+ function unsupportedObjectField(value, allowed) {
114
+ if (!isRecord(value)) return undefined;
115
+ return Object.keys(value).find((key) => !allowed.has(key));
116
+ }
117
+
118
+ function pickFields(value, allowed) {
119
+ if (!isRecord(value)) return value;
120
+ return Object.fromEntries(Object.entries(value).filter(([key]) => allowed.has(key)));
121
+ }
122
+
123
+ function legacyRunId(task, sourceName = "task") {
124
+ const taskId = safeTaskId(task?.taskId ?? sourceName);
125
+ const identity = `${taskId}\u0000${task?.createdAt ?? "legacy"}\u0000${sourceName}`;
126
+ return safeTaskId(`${taskId}-legacy-${crypto.createHash("sha256").update(identity).digest("hex").slice(0, 12)}`);
127
+ }
128
+
129
+ export function taskContractValidationErrors(input) {
130
+ if (!input || typeof input !== "object" || Array.isArray(input)) return ["contract must be an object"];
131
+ const errors = [];
132
+ for (const key of Object.keys(input)) {
133
+ if (!TASK_CONTRACT_FIELDS.has(key)) errors.push(`unsupported task contract field ${key}`);
134
+ }
135
+ if (input.schemaVersion !== TASK_CONTRACT_SCHEMA_VERSION) errors.push(`schemaVersion must be ${TASK_CONTRACT_SCHEMA_VERSION}`);
136
+ for (const field of ["taskRunId", "taskId", "sessionId", "summary", "expectedOutput", "createdAt", "updatedAt"]) {
137
+ if (typeof input[field] !== "string" || !input[field].trim()) errors.push(`${field} is required`);
138
+ }
139
+ if (typeof input.taskRunId === "string" && safeTaskId(input.taskRunId) !== input.taskRunId) errors.push("taskRunId must be normalized");
140
+ if (typeof input.taskId === "string" && safeTaskId(input.taskId) !== input.taskId) errors.push("taskId must be normalized");
141
+ if (typeof input.summary === "string" && input.summary.trim().length < 10) errors.push("summary must be at least 10 characters");
142
+ if (typeof input.expectedOutput === "string" && input.expectedOutput.trim().length < 10) errors.push("expectedOutput must be at least 10 characters");
143
+ for (const field of ["createdAt", "updatedAt"]) {
144
+ if (typeof input[field] === "string" && !validTimestamp(input[field])) errors.push(`${field} must be a valid timestamp`);
145
+ }
146
+ if (input.sessionName !== undefined && typeof input.sessionName !== "string") errors.push("sessionName must be a string");
147
+ if (input.verifyGroup !== undefined && (typeof input.verifyGroup !== "string" || !input.verifyGroup.trim())) errors.push("verifyGroup must be a non-empty string");
148
+ if (input.migratedFromSchemaVersion !== undefined && (!Number.isInteger(input.migratedFromSchemaVersion) || input.migratedFromSchemaVersion < 1)) errors.push("migratedFromSchemaVersion must be a positive integer");
149
+ for (const field of ["failureReason", "ruledOut"]) {
150
+ if (input[field] !== undefined && typeof input[field] !== "string") errors.push(`${field} must be a string`);
151
+ }
152
+ if (input.failedAt !== undefined && !["research", "plan", "execute", "verify", "review"].includes(input.failedAt)) errors.push("failedAt is invalid");
153
+ if (!["source-change", "read-only"].includes(input.changeMode)) errors.push("changeMode is invalid");
154
+ if (!["tiny", "normal", "high-risk"].includes(input.riskLane)) errors.push("riskLane is invalid");
155
+ if (input.intakeMode !== undefined && !["model", "runtime"].includes(input.intakeMode)) errors.push("intakeMode is invalid");
156
+ if (!Number.isInteger(input.attempt) || input.attempt < 1) errors.push("attempt must be a positive integer");
157
+ if (!Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 10) errors.push("maxAttempts must be between 1 and 10");
158
+ if (Number.isInteger(input.attempt) && Number.isInteger(input.maxAttempts) && input.attempt > input.maxAttempts) errors.push("attempt exceeds maxAttempts");
159
+ for (const field of [
160
+ "previousAttempts", "acceptanceCriteria", "scope", "outOfScope", "protectedPaths", "requiredContext",
161
+ "contextManifest", "memoryCitations", "mcpCapabilities", "verifyCommands", "workPlan", "reviewLenses",
162
+ "baselineChangedFiles", "observedChangedFiles", "finalWorkingTreeFiles", "changedFiles", "verifyEvidence"
163
+ ]) {
164
+ if (!Array.isArray(input[field])) errors.push(`${field} must be an array`);
165
+ }
166
+ for (const field of ["baselineFileDigests", "finalFileDigests"]) {
167
+ if (!input[field] || typeof input[field] !== "object" || Array.isArray(input[field])) errors.push(`${field} must be an object`);
168
+ else if (Object.entries(input[field]).some(([key, value]) => !key || typeof value !== "string")) errors.push(`${field} must contain string digests`);
169
+ }
170
+ for (const field of ["acceptanceCriteria", "scope", "outOfScope", "protectedPaths", "requiredContext", "mcpCapabilities", "verifyCommands", "reviewLenses", "baselineChangedFiles", "observedChangedFiles", "finalWorkingTreeFiles", "changedFiles"]) {
171
+ if (Array.isArray(input[field]) && input[field].some((item) => typeof item !== "string" || !item.trim())) errors.push(`${field} must contain non-empty strings`);
172
+ }
173
+ if (Array.isArray(input.acceptanceCriteria) && input.acceptanceCriteria.length === 0) errors.push("acceptanceCriteria must not be empty");
174
+ if (Array.isArray(input.scope) && input.scope.length === 0) errors.push("scope must not be empty");
175
+ if (Array.isArray(input.previousAttempts) && input.previousAttempts.length > 10) errors.push("previousAttempts must contain at most 10 entries");
176
+ if (Array.isArray(input.workPlan) && input.workPlan.length > 12) errors.push("workPlan must contain at most 12 entries");
177
+ if (Array.isArray(input.reviewLenses)) {
178
+ if (new Set(input.reviewLenses).size !== input.reviewLenses.length) errors.push("reviewLenses must be unique");
179
+ if (input.reviewLenses.some((item) => !REVIEW_LENSES.includes(item))) errors.push("reviewLenses contains an invalid lens");
180
+ }
181
+ for (const field of ["contextManifest", "memoryCitations"]) {
182
+ if (Array.isArray(input[field]) && input[field].some((item) => !isRecord(item) || unsupportedObjectField(item, CITATION_FIELDS) || typeof item.path !== "string" || !item.path.trim() || typeof item.reason !== "string" || !item.reason.trim())) {
183
+ errors.push(`${field} entries require path and reason`);
184
+ }
185
+ }
186
+ if (Array.isArray(input.previousAttempts) && input.previousAttempts.some((item) => (
187
+ !isRecord(item)
188
+ || unsupportedObjectField(item, PREVIOUS_ATTEMPT_FIELDS)
189
+ || typeof item.taskRunId !== "string"
190
+ || !item.taskRunId.trim()
191
+ || !Number.isInteger(item.attempt)
192
+ || item.attempt < 1
193
+ || !TASK_OUTCOMES.includes(item.outcome)
194
+ || (item.failedAt !== undefined && !["research", "plan", "execute", "verify", "review"].includes(item.failedAt))
195
+ || (item.reason !== undefined && typeof item.reason !== "string")
196
+ || (item.ruledOut !== undefined && typeof item.ruledOut !== "string")
197
+ || !validTimestamp(item.recordedAt)
198
+ ))) {
199
+ errors.push("previousAttempts entries are invalid");
200
+ }
201
+ if (Array.isArray(input.workPlan)) {
202
+ const ids = new Set();
203
+ for (const step of input.workPlan) {
204
+ if (
205
+ !step
206
+ || typeof step !== "object"
207
+ || unsupportedObjectField(step, WORK_PLAN_FIELDS)
208
+ || typeof step.id !== "string"
209
+ || safeTaskId(step.id).slice(0, 40) !== step.id
210
+ || typeof step.title !== "string"
211
+ || !step.title.trim()
212
+ || step.title.length > 160
213
+ || !["parent", "piagent-scout", "piagent-planner", "piagent-worker", "piagent-reviewer", "piagent-oracle"].includes(step.role)
214
+ || !["read-only", "single-writer", "review"].includes(step.mode)
215
+ || !["pending", "in-progress", "done", "skipped", "failed"].includes(step.status)
216
+ || (step.dependsOn !== undefined && (!Array.isArray(step.dependsOn) || step.dependsOn.some((item) => typeof item !== "string" || !item.trim())))
217
+ || (Array.isArray(step.dependsOn) && new Set(step.dependsOn).size !== step.dependsOn.length)
218
+ || (step.note !== undefined && (typeof step.note !== "string" || step.note.length > 500))
219
+ || (step.updatedAt !== undefined && !validTimestamp(step.updatedAt))
220
+ ) {
221
+ errors.push("workPlan entries are invalid");
222
+ break;
223
+ }
224
+ if (ids.has(step.id)) errors.push(`workPlan has duplicate step ${step.id}`);
225
+ ids.add(step.id);
226
+ }
227
+ if (input.workPlan.some((step) => (step?.dependsOn ?? []).some((dependency) => !ids.has(dependency)))) errors.push("workPlan has an unknown dependency");
228
+ const dependencyError = workPlanDependencyError(input.workPlan);
229
+ if (dependencyError) errors.push(dependencyError);
230
+ }
231
+ if (Array.isArray(input.verifyEvidence) && input.verifyEvidence.some((item) => (
232
+ !isRecord(item)
233
+ || unsupportedObjectField(item, VERIFY_EVIDENCE_FIELDS)
234
+ || typeof item.command !== "string"
235
+ || !item.command.trim()
236
+ || !Number.isInteger(item.exitCode)
237
+ || typeof item.summary !== "string"
238
+ || !item.summary.trim()
239
+ || !validTimestamp(item.recordedAt)
240
+ || (item.observed !== undefined && typeof item.observed !== "boolean")
241
+ || (item.observedAt !== undefined && !validTimestamp(item.observedAt))
242
+ || (item.isError !== undefined && typeof item.isError !== "boolean")
243
+ || (item.matchedProfileCommand !== undefined && typeof item.matchedProfileCommand !== "boolean")
244
+ || (item.workingTreeDigest !== undefined && (typeof item.workingTreeDigest !== "string" || !/^[a-f0-9]{64}$/.test(item.workingTreeDigest)))
245
+ ))) {
246
+ errors.push("verifyEvidence entries are invalid");
247
+ }
248
+ if (!isRecord(input.trace) || unsupportedObjectField(input.trace, TRACE_FIELDS) || !TASK_OUTCOMES.includes(input.trace.outcome)) {
249
+ errors.push("trace.outcome is invalid");
250
+ } else if (input.trace.recordedAt !== undefined && !validTimestamp(input.trace.recordedAt)) {
251
+ errors.push("trace.recordedAt must be a valid timestamp");
252
+ }
253
+ if (input.orchestration !== undefined) {
254
+ const orchestration = input.orchestration;
255
+ if (
256
+ !isRecord(orchestration)
257
+ || unsupportedObjectField(orchestration, ORCHESTRATION_FIELDS)
258
+ || !["solo-first", "bounded-subagents", "parallel-readonly"].includes(orchestration.mode)
259
+ || !["not-used", "optional", "used"].includes(orchestration.subagents)
260
+ || typeof orchestration.reason !== "string"
261
+ || !orchestration.reason.trim()
262
+ || (orchestration.fieldGuidePath !== undefined && (typeof orchestration.fieldGuidePath !== "string" || !orchestration.fieldGuidePath.trim()))
263
+ || (orchestration.modelRoles !== undefined && (
264
+ !isRecord(orchestration.modelRoles)
265
+ || unsupportedObjectField(orchestration.modelRoles, MODEL_ROLE_FIELDS)
266
+ || Object.values(orchestration.modelRoles).some((item) => typeof item !== "string")
267
+ ))
268
+ ) errors.push("orchestration is invalid");
269
+ }
270
+ return errors;
271
+ }
272
+
273
+ export function workPlanDependencyError(steps) {
274
+ if (!Array.isArray(steps)) return "workPlan must be an array";
275
+ const byId = new Map();
276
+ for (const step of steps) {
277
+ if (!step || typeof step.id !== "string") continue;
278
+ if (byId.has(step.id)) return `workPlan has duplicate step ${step.id}`;
279
+ byId.set(step.id, step);
280
+ }
281
+ for (const step of steps) {
282
+ if (!step || typeof step.id !== "string") continue;
283
+ for (const dependency of step.dependsOn ?? []) {
284
+ if (dependency === step.id) return `workPlan step ${step.id} depends on itself`;
285
+ if (!byId.has(dependency)) return `workPlan step ${step.id} has unknown dependency ${dependency}`;
286
+ }
287
+ }
288
+ const visiting = new Set();
289
+ const visited = new Set();
290
+ const visit = (id) => {
291
+ if (visiting.has(id)) return false;
292
+ if (visited.has(id)) return true;
293
+ visiting.add(id);
294
+ for (const dependency of byId.get(id)?.dependsOn ?? []) {
295
+ if (!visit(dependency)) return false;
296
+ }
297
+ visiting.delete(id);
298
+ visited.add(id);
299
+ return true;
300
+ };
301
+ return [...byId.keys()].every(visit) ? undefined : "workPlan dependencies contain a cycle";
302
+ }
303
+
304
+ export function normalizeTaskContract(input, options = {}) {
305
+ if (!input || typeof input !== "object" || Array.isArray(input)) return undefined;
306
+ if (input.schemaVersion === TASK_CONTRACT_SCHEMA_VERSION && taskContractValidationErrors(input).length > 0) return undefined;
307
+ const taskId = safeTaskId(input.taskId ?? options.sourceName ?? "task");
308
+ const taskRunId = safeTaskId(input.taskRunId ?? legacyRunId(input, options.sourceName));
309
+ const trace = input.trace && typeof input.trace === "object" ? input.trace : {};
310
+ const sessionId = String(input.sessionId ?? options.sessionId ?? "legacy").trim() || "legacy";
311
+ const now = new Date().toISOString();
312
+ const createdAt = normalizedTimestamp(input.createdAt, now);
313
+ const updatedAt = normalizedTimestamp(input.updatedAt, createdAt);
314
+ const verifyEvidence = Array.isArray(input.verifyEvidence)
315
+ ? input.verifyEvidence.map((item) => item && typeof item === "object" ? {
316
+ command: item.command,
317
+ exitCode: item.exitCode,
318
+ summary: item.summary,
319
+ recordedAt: normalizedTimestamp(item.recordedAt, updatedAt),
320
+ observed: item.observed,
321
+ observedAt: item.observedAt,
322
+ isError: item.isError,
323
+ matchedProfileCommand: item.matchedProfileCommand,
324
+ workingTreeDigest: item.workingTreeDigest
325
+ } : item)
326
+ : [];
327
+ const normalized = {
328
+ schemaVersion: TASK_CONTRACT_SCHEMA_VERSION,
329
+ taskRunId,
330
+ taskId,
331
+ sessionId,
332
+ sessionName: String(input.sessionName ?? options.sessionName ?? "").trim() || undefined,
333
+ changeMode: input.changeMode === "read-only" ? "read-only" : "source-change",
334
+ attempt: positiveInteger(input.attempt, 1, 100),
335
+ maxAttempts: positiveInteger(input.maxAttempts, DEFAULT_MAX_TASK_ATTEMPTS, 10),
336
+ previousAttempts: Array.isArray(input.previousAttempts) ? input.previousAttempts.slice(-10).map((item) => pickFields(item, PREVIOUS_ATTEMPT_FIELDS)) : [],
337
+ summary: typeof input.summary === "string" ? input.summary : "",
338
+ riskLane: ["tiny", "normal", "high-risk"].includes(input.riskLane) ? input.riskLane : "normal",
339
+ intakeMode: ["model", "runtime"].includes(input.intakeMode) ? input.intakeMode : "model",
340
+ expectedOutput: typeof input.expectedOutput === "string" ? input.expectedOutput : "",
341
+ acceptanceCriteria: stringArray(input.acceptanceCriteria),
342
+ scope: stringArray(input.scope),
343
+ outOfScope: stringArray(input.outOfScope),
344
+ protectedPaths: stringArray(input.protectedPaths),
345
+ requiredContext: stringArray(input.requiredContext),
346
+ contextManifest: Array.isArray(input.contextManifest) ? input.contextManifest.map((item) => pickFields(item, CITATION_FIELDS)) : [],
347
+ memoryCitations: Array.isArray(input.memoryCitations) ? input.memoryCitations.map((item) => pickFields(item, CITATION_FIELDS)) : [],
348
+ mcpCapabilities: stringArray(input.mcpCapabilities),
349
+ verifyGroup: typeof input.verifyGroup === "string" && input.verifyGroup.trim() ? input.verifyGroup.trim() : undefined,
350
+ verifyCommands: stringArray(input.verifyCommands),
351
+ workPlan: Array.isArray(input.workPlan) ? input.workPlan.map((item) => pickFields(item, WORK_PLAN_FIELDS)) : [],
352
+ reviewLenses: stringArray(input.reviewLenses),
353
+ orchestration: isRecord(input.orchestration) ? {
354
+ ...pickFields(input.orchestration, ORCHESTRATION_FIELDS),
355
+ modelRoles: isRecord(input.orchestration.modelRoles)
356
+ ? pickFields(input.orchestration.modelRoles, MODEL_ROLE_FIELDS)
357
+ : input.orchestration.modelRoles
358
+ } : undefined,
359
+ baselineChangedFiles: stringArray(input.baselineChangedFiles),
360
+ baselineFileDigests: stringRecord(input.baselineFileDigests),
361
+ observedChangedFiles: stringArray(input.observedChangedFiles),
362
+ finalWorkingTreeFiles: stringArray(input.finalWorkingTreeFiles),
363
+ finalFileDigests: stringRecord(input.finalFileDigests),
364
+ changedFiles: stringArray(input.changedFiles),
365
+ verifyEvidence,
366
+ trace: {
367
+ outcome: TASK_OUTCOMES.includes(trace.outcome) ? trace.outcome : "pending",
368
+ friction: typeof trace.friction === "string" ? trace.friction : undefined,
369
+ notes: typeof trace.notes === "string" ? trace.notes : undefined,
370
+ recordedAt: trace.recordedAt === undefined ? undefined : normalizedTimestamp(trace.recordedAt, updatedAt)
371
+ },
372
+ failedAt: ["research", "plan", "execute", "verify", "review"].includes(input.failedAt) ? input.failedAt : undefined,
373
+ failureReason: typeof input.failureReason === "string" ? input.failureReason : undefined,
374
+ ruledOut: typeof input.ruledOut === "string" ? input.ruledOut : undefined,
375
+ createdAt,
376
+ updatedAt,
377
+ migratedFromSchemaVersion: input.schemaVersion === TASK_CONTRACT_SCHEMA_VERSION
378
+ ? input.migratedFromSchemaVersion
379
+ : positiveInteger(input.schemaVersion, 1, TASK_CONTRACT_SCHEMA_VERSION)
380
+ };
381
+ return normalized;
382
+ }
383
+
384
+ export function createTaskRunId(taskId, sessionId, createdAt = new Date().toISOString()) {
385
+ const stamp = createdAt.replace(/[^0-9]/g, "").slice(0, 14);
386
+ const entropy = crypto.createHash("sha256")
387
+ .update(`${sessionId}\u0000${createdAt}\u0000${crypto.randomUUID()}`)
388
+ .digest("hex")
389
+ .slice(0, 10);
390
+ // Reserve room for the timestamp and entropy. Truncating the assembled value
391
+ // could otherwise discard the only collision-resistant part of a long task ID.
392
+ const taskKey = safeTaskId(taskId).slice(0, 48).replace(/-+$/g, "") || "task";
393
+ return `${taskKey}-${stamp}-${entropy}`;
394
+ }
395
+
396
+ export function writeTaskContract(cwd, task) {
397
+ const sourceErrors = task?.schemaVersion === TASK_CONTRACT_SCHEMA_VERSION ? taskContractValidationErrors(task) : [];
398
+ if (sourceErrors.length > 0) throw new Error(`Task contract is invalid: ${sourceErrors.join("; ")}`);
399
+ const normalized = normalizeTaskContract(task);
400
+ if (!normalized) throw new Error("Task contract is not an object");
401
+ const errors = taskContractValidationErrors(normalized);
402
+ if (errors.length > 0) throw new Error(`Task contract is invalid: ${errors.join("; ")}`);
403
+ normalized.updatedAt = new Date().toISOString();
404
+ writeJsonAtomic(cwd, taskRunPath(cwd, normalized.taskRunId), normalized);
405
+ return normalized;
406
+ }
407
+
408
+ export function listTaskContracts(cwd) {
409
+ const root = resolveLocalStatePath(cwd, tasksRoot(cwd), { label: "Task state directory" });
410
+ if (!fs.existsSync(root)) return [];
411
+ resolveLocalStatePath(cwd, tasksRoot(cwd), { label: "Task state directory", kind: "directory" });
412
+ const byRunId = new Map();
413
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
414
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
415
+ const raw = readJson(cwd, path.join(root, entry.name));
416
+ const sourceName = entry.name.slice(0, -5);
417
+ const task = normalizeTaskContract(raw, { sourceName });
418
+ if (!task) continue;
419
+ if (raw?.schemaVersion === TASK_CONTRACT_SCHEMA_VERSION && task.taskRunId !== safeTaskId(sourceName)) continue;
420
+ const previous = byRunId.get(task.taskRunId);
421
+ if (!previous || Date.parse(task.updatedAt) >= Date.parse(previous.updatedAt)) byRunId.set(task.taskRunId, task);
422
+ }
423
+ return [...byRunId.values()].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
424
+ }
425
+
426
+ export function bindSessionTask(cwd, sessionId, sessionName, task) {
427
+ if (!sessionId || !task?.taskRunId) return undefined;
428
+ if (task.sessionId !== String(sessionId)) {
429
+ throw new Error(`Cannot bind task ${task.taskRunId} from session ${task.sessionId} to ${sessionId}`);
430
+ }
431
+ const binding = {
432
+ schemaVersion: 1,
433
+ sessionId: String(sessionId),
434
+ sessionName: String(sessionName ?? task.sessionName ?? "").trim() || undefined,
435
+ activeTaskRunId: task.taskRunId,
436
+ taskId: task.taskId,
437
+ updatedAt: new Date().toISOString()
438
+ };
439
+ writeJsonAtomic(cwd, sessionBindingPath(cwd, sessionId), binding);
440
+ return binding;
441
+ }
442
+
443
+ export function readSessionTaskBinding(cwd, sessionId) {
444
+ if (!sessionId) return undefined;
445
+ const binding = readJson(cwd, sessionBindingPath(cwd, sessionId));
446
+ return binding?.sessionId === String(sessionId) && typeof binding.activeTaskRunId === "string" ? binding : undefined;
447
+ }
448
+
449
+ export function resolveTaskContract(cwd, reference, sessionId) {
450
+ const ref = safeTaskId(reference);
451
+ const binding = readSessionTaskBinding(cwd, sessionId);
452
+ if (binding) {
453
+ const active = readTaskRun(cwd, binding.activeTaskRunId);
454
+ if (active && (!reference || active.taskRunId === ref || active.taskId === ref)) return active;
455
+ }
456
+ const tasks = listTaskContracts(cwd);
457
+ const matches = tasks.filter((task) => task.taskRunId === ref || task.taskId === ref);
458
+ return sessionId ? matches.find((task) => task.sessionId === String(sessionId)) : matches[0];
459
+ }
460
+
461
+ export function activeSessionTask(cwd, sessionId) {
462
+ const binding = readSessionTaskBinding(cwd, sessionId);
463
+ if (!binding) return undefined;
464
+ const task = readTaskRun(cwd, binding.activeTaskRunId);
465
+ return task?.sessionId === String(sessionId) ? task : undefined;
466
+ }
467
+
468
+ export function priorTaskAttempts(cwd, taskId) {
469
+ return listTaskContracts(cwd).filter((task) => task.taskId === safeTaskId(taskId));
470
+ }
471
+
472
+ export function summarizeAttempt(task) {
473
+ return {
474
+ taskRunId: task.taskRunId,
475
+ attempt: task.attempt,
476
+ outcome: task.trace?.outcome ?? "pending",
477
+ failedAt: task.failedAt,
478
+ reason: task.trace?.friction ?? task.failureReason,
479
+ ruledOut: task.ruledOut,
480
+ recordedAt: task.trace?.recordedAt ?? task.updatedAt
481
+ };
482
+ }
483
+
484
+ function gitOutput(cwd, args, options = {}) {
485
+ return execFileSync("git", ["-C", cwd, ...args], {
486
+ encoding: options.encoding ?? "utf8",
487
+ maxBuffer: options.maxBuffer ?? 16 * 1024 * 1024,
488
+ stdio: ["ignore", "pipe", "ignore"]
489
+ });
490
+ }
491
+
492
+ function gitHasHead(cwd) {
493
+ try {
494
+ gitOutput(cwd, ["rev-parse", "--verify", "HEAD"]);
495
+ return true;
496
+ } catch {
497
+ return false;
498
+ }
499
+ }
500
+
501
+ export function isGitWorkingTree(cwd) {
502
+ try {
503
+ return gitOutput(cwd, ["rev-parse", "--is-inside-work-tree"]).trim() === "true";
504
+ } catch {
505
+ return false;
506
+ }
507
+ }
508
+
509
+ export function workingTreeFiles(cwd) {
510
+ try {
511
+ if (!isGitWorkingTree(cwd)) return [];
512
+ const changed = gitHasHead(cwd)
513
+ ? changedPathsFromNameStatus(gitOutput(cwd, ["diff", "--name-status", "-z", "--find-renames", "--diff-filter=ACMRD", "HEAD", "--"]))
514
+ : gitOutput(cwd, ["ls-files", "-z"]).split("\0").filter(Boolean);
515
+ const untracked = gitOutput(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean);
516
+ return [...new Set([...changed, ...untracked].map((item) => item.replaceAll("\\", "/")).filter((item) => item && !item.startsWith(".pi/piagent-state/")))].sort();
517
+ } catch {
518
+ return [];
519
+ }
520
+ }
521
+
522
+ function changedPathsFromNameStatus(output) {
523
+ const fields = String(output ?? "").split("\0").filter(Boolean);
524
+ const files = [];
525
+ for (let index = 0; index < fields.length;) {
526
+ const status = fields[index++];
527
+ const source = fields[index++];
528
+ if (!source) break;
529
+ files.push(source);
530
+ if (/^[RC]/.test(status)) {
531
+ const destination = fields[index++];
532
+ if (destination) files.push(destination);
533
+ }
534
+ }
535
+ return files;
536
+ }
537
+
538
+ export function workingTreeSnapshot(cwd) {
539
+ const snapshot = {};
540
+ const hasHead = gitHasHead(cwd);
541
+ for (const file of workingTreeFiles(cwd)) {
542
+ try {
543
+ let evidence = hasHead
544
+ ? gitOutput(cwd, ["diff", "--no-ext-diff", "--binary", "HEAD", "--", file], { maxBuffer: 32 * 1024 * 1024 })
545
+ : "";
546
+ if (!evidence) evidence = `working:${gitOutput(cwd, ["hash-object", "--no-filters", "--", file]).trim()}`;
547
+ snapshot[file] = crypto.createHash("sha256").update(evidence).digest("hex");
548
+ } catch {
549
+ snapshot[file] = "missing-or-unavailable";
550
+ }
551
+ }
552
+ return snapshot;
553
+ }
554
+
555
+ export function taskStateMigrationStatus(cwd) {
556
+ let root;
557
+ try {
558
+ root = resolveLocalStatePath(cwd, tasksRoot(cwd), { label: "Task state directory" });
559
+ if (fs.existsSync(root)) resolveLocalStatePath(cwd, tasksRoot(cwd), { label: "Task state directory", kind: "directory" });
560
+ } catch (error) {
561
+ return { legacy: 0, current: 0, unreadable: [error instanceof Error ? error.message : String(error)] };
562
+ }
563
+ if (!fs.existsSync(root)) return { legacy: 0, current: 0, unreadable: [] };
564
+ let legacy = 0;
565
+ let current = 0;
566
+ const unreadable = [];
567
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
568
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
569
+ const raw = readJson(cwd, path.join(root, entry.name));
570
+ if (!raw) {
571
+ unreadable.push(entry.name);
572
+ continue;
573
+ }
574
+ if (raw.schemaVersion === TASK_CONTRACT_SCHEMA_VERSION && typeof raw.taskRunId === "string") {
575
+ if (safeTaskId(raw.taskRunId) !== entry.name.slice(0, -5) || taskContractValidationErrors(raw).length > 0) unreadable.push(entry.name);
576
+ else current += 1;
577
+ } else {
578
+ const migrated = normalizeTaskContract(raw, { sourceName: entry.name.slice(0, -5) });
579
+ if (!migrated || taskContractValidationErrors(migrated).length > 0) unreadable.push(entry.name);
580
+ else legacy += 1;
581
+ }
582
+ }
583
+ return { legacy, current, unreadable };
584
+ }
585
+
586
+ export function migrateTaskState(cwd, options = {}) {
587
+ let root;
588
+ try {
589
+ root = resolveLocalStatePath(cwd, tasksRoot(cwd), { label: "Task state directory" });
590
+ if (fs.existsSync(root)) resolveLocalStatePath(cwd, tasksRoot(cwd), { label: "Task state directory", kind: "directory" });
591
+ } catch (error) {
592
+ return { migrated: 0, current: 0, warnings: [error instanceof Error ? error.message : String(error)] };
593
+ }
594
+ if (!fs.existsSync(root)) return { migrated: 0, current: 0, warnings: [] };
595
+ const archiveRoot = path.join(root, "legacy-v1");
596
+ let migrated = 0;
597
+ let current = 0;
598
+ const warnings = [];
599
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
600
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
601
+ const source = path.join(root, entry.name);
602
+ const raw = readJson(cwd, source);
603
+ if (!raw) {
604
+ warnings.push(`unreadable task state: ${entry.name}`);
605
+ continue;
606
+ }
607
+ if (raw.schemaVersion === TASK_CONTRACT_SCHEMA_VERSION && typeof raw.taskRunId === "string") {
608
+ const errors = taskContractValidationErrors(raw);
609
+ const sourceName = entry.name.slice(0, -5);
610
+ if (safeTaskId(raw.taskRunId) !== sourceName) errors.push("taskRunId does not match filename");
611
+ if (errors.length > 0) warnings.push(`invalid task state: ${entry.name} (${errors.join("; ")})`);
612
+ else current += 1;
613
+ continue;
614
+ }
615
+ const sourceTaskId = safeTaskId(raw.taskId ?? entry.name.slice(0, -5));
616
+ const belongsToCurrentSession = options.taskId && safeTaskId(options.taskId) === sourceTaskId;
617
+ const task = normalizeTaskContract(raw, {
618
+ sourceName: entry.name.slice(0, -5),
619
+ sessionId: belongsToCurrentSession ? options.sessionId : undefined,
620
+ sessionName: belongsToCurrentSession ? options.sessionName : undefined
621
+ });
622
+ if (!task) continue;
623
+ writeTaskContract(cwd, task);
624
+ const safeArchiveRoot = ensurePrivateStateDirectory(cwd, archiveRoot, "Task state archive");
625
+ const archiveTarget = resolveLocalStatePath(cwd, path.join(safeArchiveRoot, entry.name), { label: "Task state archive file" });
626
+ fs.renameSync(source, archiveTarget);
627
+ migrated += 1;
628
+ }
629
+ return { migrated, current, warnings };
630
+ }