@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
@@ -1987,6 +1987,7 @@ function extractAttachedRedirectionPaths(segment) {
1987
1987
  index += 1;
1988
1988
  }
1989
1989
 
1990
+ const writesFile = char === ">" || (char === "<" && next === ">");
1990
1991
  if ((char === ">" && (next === ">" || next === "|")) || (char === "<" && next === ">")) index += 1;
1991
1992
  let cursor = index + 1;
1992
1993
  while (/\s/.test(segment[cursor] ?? "")) cursor += 1;
@@ -2054,13 +2055,22 @@ function extractAttachedRedirectionPaths(segment) {
2054
2055
  target += targetChar;
2055
2056
  targetMask += (targetQuote || insideSubstitution) ? "1" : "0";
2056
2057
  }
2057
- if (target) paths.push({ value: target, literalMask: targetMask });
2058
+ if (target) paths.push({ value: target, literalMask: targetMask, writesFile });
2058
2059
  index = Math.max(index, cursor - 1);
2059
2060
  }
2060
2061
 
2061
2062
  return paths;
2062
2063
  }
2063
2064
 
2065
+ export function shellHasFileWriteRedirection(command) {
2066
+ return splitShellSegments(String(command ?? "")).some((segment) => (
2067
+ extractAttachedRedirectionPaths(segment)
2068
+ .filter((redirect) => redirect.writesFile)
2069
+ .flatMap(redirectionTargetWords)
2070
+ .length > 0
2071
+ ));
2072
+ }
2073
+
2064
2074
  export function extractShellPathCandidates(command) {
2065
2075
  const candidates = [...xargsPipelineInputCandidates(command)];
2066
2076
  const assignments = new Map();
@@ -2,6 +2,10 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { redactSensitiveText } from "../security/sensitive-data.js";
5
+ import { appendJsonlBounded, readJsonlTail } from "./state-retention.js";
6
+ import { resolveLocalStatePath } from "./local-state-path.js";
7
+
8
+ const MAX_OBSERVED_BASH_BYTES = 8 * 1024 * 1024;
5
9
 
6
10
  export function normalizeEvidenceCommand(command) {
7
11
  return String(command ?? "")
@@ -180,40 +184,56 @@ function persistedObservedEntry(entry) {
180
184
  }
181
185
 
182
186
  export function readObservedBashResults(filePath, options = {}) {
183
- if (!filePath || !fs.existsSync(filePath)) return [];
187
+ if (!filePath) return [];
184
188
  const maxEntries = Number.isInteger(options.maxEntries) ? options.maxEntries : 5000;
185
- const text = fs.readFileSync(filePath, "utf8");
186
189
  const entries = [];
187
- for (const line of text.split(/\n/)) {
188
- const trimmed = line.trim();
189
- if (!trimmed) continue;
190
- try {
191
- const parsed = JSON.parse(trimmed);
192
- const entry = canonicalObservedEntry(parsed);
193
- if (entry) entries.push(entry);
194
- } catch {
195
- // Ignore a partially written/corrupt JSONL line. The ledger is advisory evidence,
196
- // and the final gate remains fail-closed when no valid observation is available.
197
- }
190
+ for (const parsed of readJsonlTail(filePath, {
191
+ maxEntries,
192
+ limit: maxEntries,
193
+ maxBytes: MAX_OBSERVED_BASH_BYTES,
194
+ projectRoot: options.projectRoot
195
+ })) {
196
+ const entry = canonicalObservedEntry(parsed);
197
+ if (entry) entries.push(entry);
198
198
  }
199
199
  return entries.slice(-maxEntries);
200
200
  }
201
201
 
202
- function pruneObservedBashFile(filePath, maxPersistedEntries) {
202
+ function pruneObservedBashFile(filePath, maxPersistedEntries, projectRoot) {
203
203
  if (!Number.isInteger(maxPersistedEntries) || maxPersistedEntries <= 0) return;
204
- const entries = readObservedBashResults(filePath, { maxEntries: maxPersistedEntries });
204
+ const entries = readObservedBashResults(filePath, { maxEntries: maxPersistedEntries, projectRoot });
205
205
  const lines = entries.map((entry) => JSON.stringify(persistedObservedEntry(entry))).filter(Boolean);
206
- fs.writeFileSync(filePath, `${lines.join("\n")}${lines.length ? "\n" : ""}`);
206
+ const safePath = projectRoot
207
+ ? resolveLocalStatePath(projectRoot, filePath, { label: "Observed bash ledger", kind: "file" })
208
+ : filePath;
209
+ const descriptor = fs.openSync(
210
+ safePath,
211
+ fs.constants.O_WRONLY | fs.constants.O_TRUNC | (fs.constants.O_NOFOLLOW ?? 0),
212
+ 0o600
213
+ );
214
+ try {
215
+ fs.writeSync(descriptor, `${lines.join("\n")}${lines.length ? "\n" : ""}`);
216
+ try {
217
+ fs.fchmodSync(descriptor, 0o600);
218
+ } catch {
219
+ // Best effort on filesystems that do not expose POSIX modes.
220
+ }
221
+ } finally {
222
+ fs.closeSync(descriptor);
223
+ }
207
224
  }
208
225
 
209
226
  export function appendObservedBashResult(filePath, entry, options = {}) {
210
227
  if (!filePath) return undefined;
211
228
  const persisted = persistedObservedEntry(entry);
212
229
  if (!persisted) return undefined;
213
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
214
- fs.appendFileSync(filePath, `${JSON.stringify(persisted)}\n`);
230
+ appendJsonlBounded(filePath, persisted, {
231
+ maxBytes: MAX_OBSERVED_BASH_BYTES,
232
+ mode: 0o600,
233
+ projectRoot: options.projectRoot
234
+ });
215
235
  if (Number.isInteger(options.maxPersistedEntries)) {
216
- pruneObservedBashFile(filePath, options.maxPersistedEntries);
236
+ pruneObservedBashFile(filePath, options.maxPersistedEntries, options.projectRoot);
217
237
  }
218
238
  return persisted;
219
239
  }
@@ -0,0 +1,218 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { ensurePrivateStateDirectory, resolveLocalStatePath } from "./local-state-path.js";
4
+
5
+ const LOCK_WAIT_MS = 500;
6
+ const LOCK_STALE_MS = 5_000;
7
+ const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
8
+
9
+ function ensurePrivateParent(filePath, projectRoot) {
10
+ const safeFilePath = projectRoot
11
+ ? resolveLocalStatePath(projectRoot, filePath, { label: "Local JSONL state" })
12
+ : filePath;
13
+ if (projectRoot) ensurePrivateStateDirectory(projectRoot, path.dirname(filePath), "Local JSONL state directory");
14
+ else fs.mkdirSync(path.dirname(safeFilePath), { recursive: true, mode: 0o700 });
15
+ try {
16
+ fs.chmodSync(path.dirname(safeFilePath), 0o700);
17
+ } catch {
18
+ // Best effort on filesystems that do not expose POSIX modes.
19
+ }
20
+ return safeFilePath;
21
+ }
22
+
23
+ function withFileLock(filePath, action) {
24
+ const lockPath = `${filePath}.lock`;
25
+ const deadline = Date.now() + LOCK_WAIT_MS;
26
+ let descriptor;
27
+ while (descriptor === undefined) {
28
+ try {
29
+ descriptor = fs.openSync(lockPath, "wx", 0o600);
30
+ fs.writeFileSync(descriptor, `${process.pid}\n`);
31
+ } catch (error) {
32
+ if (error?.code !== "EEXIST") throw error;
33
+ try {
34
+ if (Date.now() - fs.lstatSync(lockPath).mtimeMs > LOCK_STALE_MS) {
35
+ fs.rmSync(lockPath, { force: true });
36
+ continue;
37
+ }
38
+ } catch (statError) {
39
+ if (statError?.code === "ENOENT") continue;
40
+ }
41
+ if (Date.now() >= deadline) {
42
+ throw new Error(`Timed out waiting for state lock: ${path.basename(filePath)}`);
43
+ }
44
+ Atomics.wait(sleepBuffer, 0, 0, 10);
45
+ }
46
+ }
47
+ try {
48
+ return action();
49
+ } finally {
50
+ try {
51
+ fs.closeSync(descriptor);
52
+ } finally {
53
+ fs.rmSync(lockPath, { force: true });
54
+ }
55
+ }
56
+ }
57
+
58
+ function rotateJsonl(filePath, maxBytes, incomingBytes = 0) {
59
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) return false;
60
+ let size;
61
+ try {
62
+ size = fs.statSync(filePath).size;
63
+ } catch (error) {
64
+ if (error?.code === "ENOENT") return false;
65
+ throw error;
66
+ }
67
+ if (size === 0 || size + incomingBytes <= maxBytes) return false;
68
+ const previous = `${filePath}.1`;
69
+ try {
70
+ fs.rmSync(previous, { force: true });
71
+ fs.renameSync(filePath, previous);
72
+ fs.chmodSync(previous, 0o600);
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ function boundedJsonLine(record, maxBytes) {
80
+ const line = `${JSON.stringify(record)}\n`;
81
+ const originalBytes = Buffer.byteLength(line);
82
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0 || originalBytes <= maxBytes) return { line, record };
83
+ const summary = {
84
+ schemaVersion: Number.isInteger(record?.schemaVersion) ? record.schemaVersion : 1,
85
+ recordedAt: typeof record?.recordedAt === "string" ? record.recordedAt : new Date().toISOString(),
86
+ event: typeof record?.event === "string" ? record.event : "oversized_record",
87
+ truncated: true,
88
+ originalBytes
89
+ };
90
+ const summaryLine = `${JSON.stringify(summary)}\n`;
91
+ if (Buffer.byteLength(summaryLine) > maxBytes) {
92
+ throw new RangeError(`maxBytes ${maxBytes} is too small for a bounded JSONL record`);
93
+ }
94
+ return { line: summaryLine, record: summary };
95
+ }
96
+
97
+ export function appendJsonlBounded(filePath, record, options = {}) {
98
+ const safeFilePath = ensurePrivateParent(filePath, options.projectRoot);
99
+ const bounded = boundedJsonLine(record, options.maxBytes);
100
+ return withFileLock(safeFilePath, () => {
101
+ rotateJsonl(safeFilePath, options.maxBytes, Buffer.byteLength(bounded.line));
102
+ const descriptor = fs.openSync(
103
+ safeFilePath,
104
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW ?? 0),
105
+ options.mode ?? 0o600
106
+ );
107
+ try {
108
+ fs.writeSync(descriptor, bounded.line);
109
+ try {
110
+ fs.fchmodSync(descriptor, options.mode ?? 0o600);
111
+ } catch {
112
+ // Best effort on filesystems that do not expose POSIX modes.
113
+ }
114
+ } finally {
115
+ fs.closeSync(descriptor);
116
+ }
117
+ return bounded.record;
118
+ });
119
+ }
120
+
121
+ function readTailText(filePath, maxBytes) {
122
+ try {
123
+ const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
124
+ try {
125
+ const stat = fs.fstatSync(descriptor);
126
+ const start = Math.max(0, stat.size - maxBytes);
127
+ const length = stat.size - start;
128
+ const buffer = Buffer.allocUnsafe(length);
129
+ fs.readSync(descriptor, buffer, 0, length, start);
130
+ let text = buffer.toString("utf8");
131
+ if (start > 0) text = text.slice(Math.max(0, text.indexOf("\n") + 1));
132
+ return text;
133
+ } finally {
134
+ fs.closeSync(descriptor);
135
+ }
136
+ } catch (error) {
137
+ if (error?.code === "ENOENT") return "";
138
+ throw error;
139
+ }
140
+ }
141
+
142
+ export function readJsonlTail(filePath, options = {}) {
143
+ const limit = Math.max(1, Number.isInteger(options.limit) ? options.limit : 100);
144
+ const maxBytes = Math.max(64 * 1024, Number.isInteger(options.maxBytes) ? options.maxBytes : 8 * 1024 * 1024);
145
+ const safeFilePath = options.projectRoot
146
+ ? resolveLocalStatePath(options.projectRoot, filePath, { label: "Local JSONL state" })
147
+ : filePath;
148
+ const sources = options.projectRoot
149
+ ? [
150
+ resolveLocalStatePath(options.projectRoot, `${filePath}.1`, { label: "Rotated local JSONL state" }),
151
+ safeFilePath
152
+ ]
153
+ : [`${safeFilePath}.1`, safeFilePath];
154
+ const lines = sources.flatMap((source) => readTailText(source, maxBytes).split(/\r?\n/).filter(Boolean));
155
+ const records = [];
156
+ for (const line of lines.slice(-limit)) {
157
+ try {
158
+ records.push(JSON.parse(line));
159
+ } catch {
160
+ // Ignore a partial record left by interruption or concurrent rotation.
161
+ }
162
+ }
163
+ return records;
164
+ }
165
+
166
+ export function pruneCaptureFiles(root, options = {}) {
167
+ const safeRoot = options.projectRoot
168
+ ? resolveLocalStatePath(options.projectRoot, root, { label: "Tool-result capture root" })
169
+ : root;
170
+ if (!fs.existsSync(safeRoot)) return { removed: 0, kept: 0, bytes: 0 };
171
+ if (options.projectRoot) resolveLocalStatePath(options.projectRoot, root, { label: "Tool-result capture root", kind: "directory" });
172
+ const maxFiles = Math.max(1, Number.isInteger(options.maxFiles) ? options.maxFiles : 500);
173
+ const maxBytes = Math.max(1024 * 1024, Number.isInteger(options.maxBytes) ? options.maxBytes : 128 * 1024 * 1024);
174
+ const maxAgeMs = Math.max(60_000, Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : 30 * 24 * 60 * 60 * 1000);
175
+ const now = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
176
+ const files = [];
177
+ const walk = (directory) => {
178
+ let entries;
179
+ try {
180
+ entries = fs.readdirSync(directory, { withFileTypes: true });
181
+ } catch (error) {
182
+ if (error?.code === "ENOENT") return;
183
+ throw error;
184
+ }
185
+ for (const entry of entries) {
186
+ const absolute = path.join(directory, entry.name);
187
+ if (entry.isDirectory()) walk(absolute);
188
+ else if (entry.isFile() && entry.name.endsWith(".log")) {
189
+ try {
190
+ const stat = fs.statSync(absolute);
191
+ files.push({ absolute, mtimeMs: stat.mtimeMs, size: stat.size });
192
+ } catch (error) {
193
+ if (error?.code !== "ENOENT") throw error;
194
+ }
195
+ }
196
+ }
197
+ };
198
+ walk(safeRoot);
199
+ files.sort((left, right) => right.mtimeMs - left.mtimeMs);
200
+ let kept = 0;
201
+ let bytes = 0;
202
+ let removed = 0;
203
+ for (const file of files) {
204
+ const keep = kept < maxFiles && bytes + file.size <= maxBytes && now - file.mtimeMs <= maxAgeMs;
205
+ if (keep) {
206
+ kept += 1;
207
+ bytes += file.size;
208
+ continue;
209
+ }
210
+ try {
211
+ fs.rmSync(file.absolute);
212
+ removed += 1;
213
+ } catch (error) {
214
+ if (error?.code !== "ENOENT") throw error;
215
+ }
216
+ }
217
+ return { removed, kept, bytes };
218
+ }
@@ -0,0 +1,138 @@
1
+ import crypto from "node:crypto";
2
+
3
+ function sameValues(left, right) {
4
+ return JSON.stringify(left ?? []) === JSON.stringify(right ?? []);
5
+ }
6
+
7
+ function matchesStep(step, expected) {
8
+ return step?.id === expected.id
9
+ && step?.role === expected.role
10
+ && step?.mode === expected.mode
11
+ && sameValues(step?.dependsOn, expected.dependsOn);
12
+ }
13
+
14
+ const AUTOMATIC_TINY_PLAN = [
15
+ { id: "implement", role: "parent", mode: "single-writer", dependsOn: undefined },
16
+ { id: "verify", role: "parent", mode: "review", dependsOn: ["implement"] }
17
+ ];
18
+
19
+ const ASSISTED_NORMAL_PLAN = [
20
+ { id: "plan", role: "parent", mode: "read-only", dependsOn: undefined },
21
+ { id: "implement", role: "parent", mode: "single-writer", dependsOn: ["plan"] },
22
+ { id: "review", role: "piagent-reviewer", mode: "review", dependsOn: ["implement"] }
23
+ ];
24
+
25
+ const AUTOMATIC_READ_ONLY_PLAN = [
26
+ { id: "scout", role: "parent", mode: "read-only", dependsOn: undefined }
27
+ ];
28
+
29
+ const ASSISTED_READ_ONLY_PLAN = [
30
+ { id: "scout", role: "parent", mode: "read-only", dependsOn: undefined },
31
+ { id: "review", role: "piagent-reviewer", mode: "review", dependsOn: ["scout"] }
32
+ ];
33
+
34
+ export function runtimeLifecycleMode(task) {
35
+ if (!Array.isArray(task?.workPlan)) return "manual";
36
+ if (
37
+ task.changeMode === "read-only"
38
+ && task.riskLane === "tiny"
39
+ && task.workPlan.length === AUTOMATIC_READ_ONLY_PLAN.length
40
+ && task.workPlan.every((step, index) => matchesStep(step, AUTOMATIC_READ_ONLY_PLAN[index]))
41
+ ) return "automatic-readonly";
42
+ if (
43
+ task.changeMode === "read-only"
44
+ && task.riskLane === "normal"
45
+ && task.workPlan.length === ASSISTED_READ_ONLY_PLAN.length
46
+ && task.workPlan.every((step, index) => matchesStep(step, ASSISTED_READ_ONLY_PLAN[index]))
47
+ ) return "assisted-readonly";
48
+ if (task.changeMode !== "source-change") return "manual";
49
+ if (
50
+ task.riskLane === "tiny"
51
+ && task.workPlan.length === AUTOMATIC_TINY_PLAN.length
52
+ && task.workPlan.every((step, index) => matchesStep(step, AUTOMATIC_TINY_PLAN[index]))
53
+ ) return "automatic";
54
+ if (
55
+ task.riskLane === "normal"
56
+ && task.workPlan.length === ASSISTED_NORMAL_PLAN.length
57
+ && task.workPlan.every((step, index) => matchesStep(step, ASSISTED_NORMAL_PLAN[index]))
58
+ ) return "assisted";
59
+ return "manual";
60
+ }
61
+
62
+ export function workingTreeEvidenceDigest(snapshot) {
63
+ const entries = Object.entries(snapshot ?? {})
64
+ .filter(([file, digest]) => typeof file === "string" && typeof digest === "string")
65
+ .sort(([left], [right]) => left.localeCompare(right));
66
+ return crypto.createHash("sha256").update(JSON.stringify(entries)).digest("hex");
67
+ }
68
+
69
+ function setStep(step, status, note, recordedAt) {
70
+ if (!step || (step.status === status && step.note === note)) return false;
71
+ step.status = status;
72
+ step.note = note;
73
+ step.updatedAt = recordedAt;
74
+ return true;
75
+ }
76
+
77
+ export function applyRuntimeLifecycleObservation(task, observation, recordedAt = new Date().toISOString()) {
78
+ const mode = runtimeLifecycleMode(task);
79
+ if (mode === "manual") return { changed: false, mode };
80
+
81
+ const byId = new Map(task.workPlan.map((step) => [step.id, step]));
82
+ let changed = false;
83
+ if (observation === "context-complete") {
84
+ if (mode === "automatic-readonly") {
85
+ changed = setStep(byId.get("scout"), "done", "Runtime observed bounded read-only evidence for this scout.", recordedAt) || changed;
86
+ } else if (mode === "assisted-readonly") {
87
+ changed = setStep(byId.get("scout"), "done", "Runtime observed bounded read-only evidence for this scout.", recordedAt) || changed;
88
+ const review = byId.get("review");
89
+ if (review?.status === "pending") {
90
+ changed = setStep(review, "in-progress", "Review the evidence and stated unknowns before handoff.", recordedAt) || changed;
91
+ }
92
+ }
93
+ return { changed, mode };
94
+ }
95
+
96
+ if (mode === "automatic-readonly" || mode === "assisted-readonly") {
97
+ return { changed: false, mode };
98
+ }
99
+ if (observation === "mutation") {
100
+ if (mode === "assisted") {
101
+ changed = setStep(byId.get("plan"), "done", "Runtime observed bounded context followed by a project mutation.", recordedAt) || changed;
102
+ }
103
+ changed = setStep(byId.get("implement"), "in-progress", "Runtime observed a project mutation; current verification is required.", recordedAt) || changed;
104
+ const terminalStep = byId.get(mode === "automatic" ? "verify" : "review");
105
+ if (terminalStep && terminalStep.status !== "pending") {
106
+ changed = setStep(terminalStep, "pending", "A later mutation invalidated the previous terminal evidence.", recordedAt) || changed;
107
+ }
108
+ return { changed, mode };
109
+ }
110
+
111
+ if (observation === "verification-pending") {
112
+ if (mode === "assisted") {
113
+ changed = setStep(byId.get("plan"), "done", "Runtime observed bounded context followed by implementation.", recordedAt) || changed;
114
+ }
115
+ changed = setStep(byId.get("implement"), "done", "Runtime observed project changes and an exact configured verifier.", recordedAt) || changed;
116
+ const verifyStep = byId.get(mode === "automatic" ? "verify" : "review");
117
+ if (verifyStep?.status === "pending") {
118
+ changed = setStep(verifyStep, "in-progress", "Waiting for current passing verification or explicit review evidence.", recordedAt) || changed;
119
+ }
120
+ return { changed, mode };
121
+ }
122
+
123
+ if (observation === "verification-complete") {
124
+ if (mode === "assisted") {
125
+ changed = setStep(byId.get("plan"), "done", "Runtime observed bounded context followed by implementation.", recordedAt) || changed;
126
+ }
127
+ changed = setStep(byId.get("implement"), "done", "All configured verifiers passed against the current working tree.", recordedAt) || changed;
128
+ if (mode === "automatic") {
129
+ changed = setStep(byId.get("verify"), "done", "Runtime observed every configured verifier passing against the current working tree.", recordedAt) || changed;
130
+ } else {
131
+ const review = byId.get("review");
132
+ if (review?.status === "pending") {
133
+ changed = setStep(review, "in-progress", "Verification passed; complete the explicit review lens before handoff.", recordedAt) || changed;
134
+ }
135
+ }
136
+ }
137
+ return { changed, mode };
138
+ }