@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
@@ -8,7 +8,9 @@ import {
8
8
  contextIndexExcludePolicyVersion,
9
9
  normalizeContextIndexExcludePatterns
10
10
  } from "./context-index-policy.js";
11
- import { matchesProtectedPath } from "./policy-core.js";
11
+ import { matchesAnyPath, matchesProtectedPath } from "./policy-core.js";
12
+ import { appendJsonlBounded, readJsonlTail } from "./state-retention.js";
13
+ import { ensurePrivateStateDirectory, resolveLocalStatePath } from "./local-state-path.js";
12
14
 
13
15
  const INDEX_SCHEMA_VERSION = 2;
14
16
  const TELEMETRY_SCHEMA_VERSION = 1;
@@ -18,6 +20,7 @@ const DEFAULT_MAX_FILES = 8_000;
18
20
  const DEFAULT_PACK_TOKENS = 6_000;
19
21
  const MAX_TELEMETRY_EVENTS = 50_000;
20
22
  const MAX_TELEMETRY_READ_BYTES = 32 * 1024 * 1024;
23
+ const MAX_TELEMETRY_FILE_BYTES = 32 * 1024 * 1024;
21
24
  const SOURCE_EXTENSIONS = new Set([
22
25
  ".c", ".cc", ".cpp", ".cs", ".css", ".dart", ".ex", ".exs", ".go", ".h", ".hpp",
23
26
  ".html", ".java", ".js", ".jsx", ".kt", ".kts", ".lua", ".m", ".md", ".mdx",
@@ -47,7 +50,8 @@ const STOP_TERMS = new Set([
47
50
  "about", "after", "agent", "before", "build", "change", "check", "code", "could", "file",
48
51
  "from", "have", "into", "just", "make", "need", "please", "project", "should", "task",
49
52
  "that", "them", "then", "there", "these", "this", "through", "update", "using", "want",
50
- "what", "when", "where", "which", "with", "would"
53
+ "what", "when", "where", "which", "with", "would",
54
+ "anh", "cho", "cua", "em", "giup", "lam", "minh", "muon", "nay", "trong", "voi"
51
55
  ]);
52
56
 
53
57
  let sqliteModulePromise;
@@ -79,7 +83,17 @@ function requireExplicitExcludePatterns(options, operation) {
79
83
  }
80
84
 
81
85
  export function estimateContextTokens(value) {
82
- return Math.max(0, Math.ceil(String(value ?? "").length / 4));
86
+ const text = String(value ?? "");
87
+ let asciiChars = 0;
88
+ let nonAsciiBytes = 0;
89
+ for (const character of text) {
90
+ if (character.codePointAt(0) <= 0x7f) asciiChars += 1;
91
+ else nonAsciiBytes += Buffer.byteLength(character, "utf8");
92
+ }
93
+ // ASCII prose/code averages close to four characters per token. UTF-8 text
94
+ // is estimated more conservatively so Vietnamese, CJK, and emoji packs do not
95
+ // silently exceed the provider budget.
96
+ return Math.max(0, Math.ceil(asciiChars / 4 + nonAsciiBytes / 2.5));
83
97
  }
84
98
 
85
99
  export function contextEnginePaths(cwd) {
@@ -92,6 +106,21 @@ export function contextEnginePaths(cwd) {
92
106
  };
93
107
  }
94
108
 
109
+ function hardenContextDatabaseArtifacts(cwd) {
110
+ const database = resolveLocalStatePath(cwd, contextEnginePaths(cwd).database, { label: "Context index database" });
111
+ for (const artifact of [database, `${database}-wal`, `${database}-shm`]) {
112
+ try {
113
+ const stat = fs.lstatSync(artifact);
114
+ if (!stat.isFile()) {
115
+ throw new Error(`Context index storage path must be a regular file: ${artifact}`);
116
+ }
117
+ fs.chmodSync(artifact, 0o600);
118
+ } catch (error) {
119
+ if (error?.code !== "ENOENT") throw error;
120
+ }
121
+ }
122
+ }
123
+
95
124
  async function loadSqlite() {
96
125
  if (!sqliteModulePromise) {
97
126
  sqliteModulePromise = (async () => {
@@ -116,54 +145,91 @@ async function loadSqlite() {
116
145
 
117
146
  async function openDatabase(cwd, { create = true } = {}) {
118
147
  const paths = contextEnginePaths(cwd);
119
- if (!create && !fs.existsSync(paths.database)) return undefined;
120
- fs.mkdirSync(paths.root, { recursive: true });
148
+ const database = resolveLocalStatePath(cwd, paths.database, { label: "Context index database" });
149
+ if (!create && !fs.existsSync(database)) return undefined;
150
+ if (create) ensurePrivateStateDirectory(cwd, paths.root, "Context index storage root");
151
+ else {
152
+ const existingRoot = resolveLocalStatePath(cwd, paths.root, { label: "Context index storage root", kind: "directory" });
153
+ try {
154
+ fs.chmodSync(existingRoot, 0o700);
155
+ } catch {
156
+ // Best effort on filesystems that do not expose POSIX modes.
157
+ }
158
+ }
159
+ hardenContextDatabaseArtifacts(cwd);
160
+ const descriptor = fs.openSync(
161
+ database,
162
+ fs.constants.O_RDWR
163
+ | (create ? fs.constants.O_CREAT : 0)
164
+ | (fs.constants.O_NOFOLLOW ?? 0),
165
+ 0o600
166
+ );
167
+ try {
168
+ fs.fchmodSync(descriptor, 0o600);
169
+ } finally {
170
+ fs.closeSync(descriptor);
171
+ }
121
172
  const { DatabaseSync } = await loadSqlite();
122
- const db = new DatabaseSync(paths.database);
123
- db.exec(`
124
- PRAGMA journal_mode = WAL;
125
- PRAGMA synchronous = NORMAL;
126
- PRAGMA foreign_keys = ON;
127
- CREATE TABLE IF NOT EXISTS metadata (
128
- key TEXT PRIMARY KEY,
129
- value TEXT NOT NULL
130
- );
131
- CREATE TABLE IF NOT EXISTS files (
132
- path TEXT PRIMARY KEY,
133
- hash TEXT NOT NULL,
134
- bytes INTEGER NOT NULL,
135
- mtime_ms REAL NOT NULL,
136
- language TEXT NOT NULL,
137
- indexed_at TEXT NOT NULL
138
- );
139
- CREATE TABLE IF NOT EXISTS symbols (
140
- id INTEGER PRIMARY KEY,
141
- file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
142
- name TEXT NOT NULL,
143
- kind TEXT NOT NULL,
144
- line INTEGER NOT NULL,
145
- end_line INTEGER NOT NULL,
146
- signature TEXT NOT NULL
147
- );
148
- CREATE INDEX IF NOT EXISTS symbols_name_idx ON symbols(name);
149
- CREATE INDEX IF NOT EXISTS symbols_file_idx ON symbols(file_path);
150
- CREATE TABLE IF NOT EXISTS imports (
151
- id INTEGER PRIMARY KEY,
152
- file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
153
- specifier TEXT NOT NULL,
154
- target_path TEXT,
155
- line INTEGER NOT NULL
156
- );
157
- CREATE INDEX IF NOT EXISTS imports_file_idx ON imports(file_path);
158
- CREATE INDEX IF NOT EXISTS imports_target_idx ON imports(target_path);
159
- CREATE VIRTUAL TABLE IF NOT EXISTS file_fts USING fts5(
160
- path UNINDEXED,
161
- body,
162
- symbol_names,
163
- tokenize = 'unicode61 remove_diacritics 2'
164
- );
165
- `);
166
- return db;
173
+ const db = new DatabaseSync(database);
174
+ try {
175
+ db.exec(`
176
+ PRAGMA journal_mode = WAL;
177
+ PRAGMA synchronous = NORMAL;
178
+ PRAGMA foreign_keys = ON;
179
+ PRAGMA secure_delete = ON;
180
+ CREATE TABLE IF NOT EXISTS metadata (
181
+ key TEXT PRIMARY KEY,
182
+ value TEXT NOT NULL
183
+ );
184
+ CREATE TABLE IF NOT EXISTS files (
185
+ path TEXT PRIMARY KEY,
186
+ hash TEXT NOT NULL,
187
+ bytes INTEGER NOT NULL,
188
+ mtime_ms REAL NOT NULL,
189
+ language TEXT NOT NULL,
190
+ indexed_at TEXT NOT NULL
191
+ );
192
+ CREATE TABLE IF NOT EXISTS symbols (
193
+ id INTEGER PRIMARY KEY,
194
+ file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
195
+ name TEXT NOT NULL,
196
+ kind TEXT NOT NULL,
197
+ line INTEGER NOT NULL,
198
+ end_line INTEGER NOT NULL,
199
+ signature TEXT NOT NULL
200
+ );
201
+ CREATE INDEX IF NOT EXISTS symbols_name_idx ON symbols(name);
202
+ CREATE INDEX IF NOT EXISTS symbols_file_idx ON symbols(file_path);
203
+ CREATE TABLE IF NOT EXISTS imports (
204
+ id INTEGER PRIMARY KEY,
205
+ file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
206
+ specifier TEXT NOT NULL,
207
+ target_path TEXT,
208
+ line INTEGER NOT NULL
209
+ );
210
+ CREATE INDEX IF NOT EXISTS imports_file_idx ON imports(file_path);
211
+ CREATE INDEX IF NOT EXISTS imports_target_idx ON imports(target_path);
212
+ CREATE VIRTUAL TABLE IF NOT EXISTS file_fts USING fts5(
213
+ path UNINDEXED,
214
+ body,
215
+ symbol_names,
216
+ tokenize = 'unicode61 remove_diacritics 2'
217
+ );
218
+ `);
219
+ const ftsSecureDelete = db.prepare(`
220
+ SELECT v
221
+ FROM file_fts_config
222
+ WHERE k = 'secure-delete'
223
+ `).get();
224
+ if (Number(ftsSecureDelete?.v) !== 1) {
225
+ db.exec("INSERT INTO file_fts(file_fts, rank) VALUES('secure-delete', 1)");
226
+ }
227
+ hardenContextDatabaseArtifacts(cwd);
228
+ return db;
229
+ } catch (error) {
230
+ db.close();
231
+ throw error;
232
+ }
167
233
  }
168
234
 
169
235
  function metadataMap(db) {
@@ -408,18 +474,30 @@ function resolveImportTarget(sourcePath, specifier, files) {
408
474
  return candidates.map(normalizeRelative).find((candidate) => files.has(candidate));
409
475
  }
410
476
 
477
+ function foldSearchSignal(value) {
478
+ return String(value ?? "")
479
+ .normalize("NFKD")
480
+ .replace(/\p{M}/gu, "")
481
+ .replace(/[đĐ]/g, (character) => character === "Đ" ? "D" : "d")
482
+ .toLowerCase();
483
+ }
484
+
411
485
  function tokenizeQuery(query) {
412
486
  const expanded = String(query ?? "")
413
487
  .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
414
488
  .replaceAll("_", " ")
415
489
  .replaceAll("-", " ");
416
- const terms = expanded.match(/[A-Za-z][A-Za-z0-9]{1,63}/g) ?? [];
417
- return [...new Set(terms.map((term) => term.toLowerCase()).filter((term) => !STOP_TERMS.has(term)))].slice(0, 16);
490
+ const terms = expanded.match(/[\p{L}][\p{L}\p{N}]{1,63}/gu) ?? [];
491
+ return [...new Set(terms
492
+ .map((term) => term.toLowerCase())
493
+ .filter((term) => !STOP_TERMS.has(foldSearchSignal(term))))].slice(0, 16);
418
494
  }
419
495
 
420
496
  function queryPathCandidates(query) {
421
- const values = String(query ?? "").match(/(?:^|[\s"'`(])((?:\.{0,2}\/)?[A-Za-z0-9_.@-]+(?:\/[A-Za-z0-9_.@-]+)+|[A-Za-z0-9_.@-]+\.[A-Za-z0-9]{1,8})(?=$|[\s"'`),:])/g) ?? [];
422
- return values.map((value) => normalizeRelative(value.trim().replace(/^["'`(]+|["'`),:]+$/g, ""))).filter(Boolean);
497
+ const pattern = /(?:^|[\s"'`(])((?:\.{0,2}\/)?[\p{L}\p{N}_.@-]+(?:\/[\p{L}\p{N}_.@-]+)+|[\p{L}\p{N}_.@-]+\.[\p{L}\p{N}]{1,8}|\.[\p{L}\p{N}_@-]{1,32})(?=$|[\s"'`),:])/gu;
498
+ return [...String(query ?? "").matchAll(pattern)]
499
+ .map((match) => normalizeRelative(match[1]))
500
+ .filter(Boolean);
423
501
  }
424
502
 
425
503
  function ftsQuery(terms) {
@@ -441,18 +519,20 @@ function gitChangedFiles(cwd) {
441
519
  export function classifyContextTask(prompt) {
442
520
  const text = String(prompt ?? "").trim();
443
521
  const lower = text.toLowerCase();
522
+ const folded = foldSearchSignal(text);
444
523
  const paths = queryPathCandidates(text);
445
524
  const terms = tokenizeQuery(text);
446
525
  let workflow = "task";
447
526
  if (/^\/?(?:scout|review|plan|discuss)\b/.test(lower)) workflow = lower.match(/^\/?([a-z-]+)/)?.[1] ?? "task";
448
- if (/\b(onboard|profile setup|first-read)\b/.test(lower)) workflow = "onboard";
449
- if (/\b(commit|pull request|\bpr\b|release|publish|deploy)\b/.test(lower)) workflow = "release";
527
+ if (/\b(onboard|profile setup|first-read|cau hinh profile|khoi tao project)\b/.test(folded)) workflow = "onboard";
528
+ if (/\b(commit|pull request|pr|release|publish|deploy|phat hanh|trien khai)\b/.test(folded)) workflow = "release";
450
529
  const usageIntent = /^\/?(?:usage|session)\b/.test(lower)
451
530
  || /\b(?:show|check|view|report|current|session|how many)\b.{0,48}\b(?:usage|tokens?|cost|context stats|efficiency)\b/.test(lower)
452
- || /\b(?:usage|tokens?|cost|context stats|efficiency)\b.{0,48}\b(?:show|check|view|report|current|session)\b/.test(lower);
531
+ || /\b(?:usage|tokens?|cost|context stats|efficiency)\b.{0,48}\b(?:show|check|view|report|current|session)\b/.test(lower)
532
+ || /\b(?:xem|kiem tra|bao cao|hien tai|phien)\b.{0,48}\b(?:usage|token|chi phi|context)\b/.test(folded);
453
533
  if (usageIntent) workflow = "usage";
454
- const highRisk = /\b(auth|authorization|credential|database|deploy|encryption|migration|payment|permission|production|release|secret|security|token)\b/.test(lower);
455
- const tiny = text.length < 220 && (paths.length > 0 || /\b(rename|typo|label|copy|one line|small)\b/.test(lower));
534
+ const highRisk = /\b(auth|authorization|credential|database|deploy|encryption|migration|payment|permission|production|release|secret|security|token|bao mat|phan quyen|xac thuc|du lieu|co so du lieu|thanh toan|ma hoa|trien khai|phat hanh)\b/.test(folded);
535
+ const tiny = text.length < 220 && (paths.length > 0 || /\b(rename|typo|label|copy|one line|small|doi ten|sua chu|nhan|mot dong|nho)\b/.test(folded));
456
536
  return {
457
537
  workflow,
458
538
  lane: highRisk ? "high-risk" : tiny ? "tiny" : "normal",
@@ -465,6 +545,7 @@ export function classifyContextTask(prompt) {
465
545
 
466
546
  export async function buildContextIndexV2(cwd, options = {}) {
467
547
  const excludePatterns = requireExplicitExcludePatterns(options, "buildContextIndexV2");
548
+ const excludeDigest = contextIndexExcludeDigest(excludePatterns);
468
549
  const startedAt = Date.now();
469
550
  const projectRoot = fs.realpathSync.native(cwd);
470
551
  const directoryCache = new Map();
@@ -473,7 +554,10 @@ export async function buildContextIndexV2(cwd, options = {}) {
473
554
  const listed = gitFileList(cwd) ?? walkedFileList(cwd, maxFiles);
474
555
  const candidates = listed.filter((file) => shouldIndexPath(file, { ...options, excludePatterns })).slice(0, maxFiles).sort();
475
556
  const db = await openDatabase(cwd);
557
+ const previousMetadata = metadataMap(db);
476
558
  const existing = new Map(db.prepare("SELECT path, hash, bytes, mtime_ms FROM files").all().map((row) => [row.path, row]));
559
+ const purgeStaleContent = Boolean(previousMetadata.builtAt || existing.size > 0)
560
+ && (previousMetadata.purgePending === "1" || previousMetadata.excludeDigest !== excludeDigest);
477
561
  const current = new Set();
478
562
  const currentHashes = new Map();
479
563
  const changed = [];
@@ -525,8 +609,10 @@ export async function buildContextIndexV2(cwd, options = {}) {
525
609
  }
526
610
 
527
611
  const removed = [...existing.keys()].filter((file) => !current.has(file));
528
- db.exec("BEGIN IMMEDIATE");
612
+ let transactionOpen = false;
529
613
  try {
614
+ db.exec("BEGIN IMMEDIATE");
615
+ transactionOpen = true;
530
616
  const deleteFile = db.prepare("DELETE FROM files WHERE path = ?");
531
617
  const deleteFts = db.prepare("DELETE FROM file_fts WHERE path = ?");
532
618
  for (const relativePath of [...removed, ...changed.map((item) => item.relativePath)]) {
@@ -574,12 +660,28 @@ export async function buildContextIndexV2(cwd, options = {}) {
574
660
  importCount: counts.imports,
575
661
  maxFileBytes,
576
662
  excludePatterns: JSON.stringify(excludePatterns),
577
- excludeDigest: contextIndexExcludeDigest(excludePatterns),
663
+ excludeDigest,
578
664
  excludePolicyVersion: contextIndexExcludePolicyVersion(),
665
+ purgePending: purgeStaleContent ? 1 : 0,
579
666
  skippedPaths: JSON.stringify(skippedPaths)
580
667
  });
581
668
  db.exec("COMMIT");
582
- db.close();
669
+ transactionOpen = false;
670
+ if (purgeStaleContent) {
671
+ if (typeof options.onPurgeStart === "function") await options.onPurgeStart();
672
+ db.exec("INSERT INTO file_fts(file_fts) VALUES('rebuild')");
673
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
674
+ db.exec("VACUUM");
675
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
676
+ db.exec("BEGIN IMMEDIATE");
677
+ transactionOpen = true;
678
+ setMetadata(db, {
679
+ purgePending: 0,
680
+ purgedAt: nowIso()
681
+ });
682
+ db.exec("COMMIT");
683
+ transactionOpen = false;
684
+ }
583
685
  return {
584
686
  schemaVersion: INDEX_SCHEMA_VERSION,
585
687
  builtAt: indexedAt,
@@ -590,26 +692,26 @@ export async function buildContextIndexV2(cwd, options = {}) {
590
692
  removed: removed.length,
591
693
  skippedLarge,
592
694
  skippedBinary,
695
+ purgedStaleContent: purgeStaleContent,
593
696
  ...counts
594
697
  };
595
698
  } catch (error) {
596
- db.exec("ROLLBACK");
597
- db.close();
699
+ if (transactionOpen) db.exec("ROLLBACK");
598
700
  throw error;
701
+ } finally {
702
+ db.close();
703
+ hardenContextDatabaseArtifacts(cwd);
599
704
  }
600
705
  }
601
706
 
602
707
  export async function contextIndexV2Status(cwd, options = {}) {
708
+ const expectedExcludePatterns = requireExplicitExcludePatterns(options, "contextIndexV2Status");
709
+ const expectedExcludeDigest = contextIndexExcludeDigest(expectedExcludePatterns);
603
710
  const paths = contextEnginePaths(cwd);
604
711
  const projectRoot = fs.realpathSync.native(cwd);
605
712
  const directoryCache = new Map();
606
- const expectedExcludePatterns = options.excludePatterns === undefined
607
- ? undefined
608
- : normalizeContextIndexExcludePatterns(options.excludePatterns);
609
- const expectedExcludeDigest = expectedExcludePatterns
610
- ? contextIndexExcludeDigest(expectedExcludePatterns)
611
- : undefined;
612
- if (!fs.existsSync(paths.database)) {
713
+ const database = resolveLocalStatePath(cwd, paths.database, { label: "Context index database" });
714
+ if (!fs.existsSync(database)) {
613
715
  return {
614
716
  schemaVersion: INDEX_SCHEMA_VERSION,
615
717
  exists: false,
@@ -617,6 +719,7 @@ export async function contextIndexV2Status(cwd, options = {}) {
617
719
  stale: false,
618
720
  policyStale: false,
619
721
  expectedExcludeDigest,
722
+ purgePending: false,
620
723
  warnings: ["index-v2 missing"]
621
724
  };
622
725
  }
@@ -628,20 +731,14 @@ export async function contextIndexV2Status(cwd, options = {}) {
628
731
  const symbols = Number(db.prepare("SELECT COUNT(*) count FROM symbols").get().count);
629
732
  const imports = Number(db.prepare("SELECT COUNT(*) count FROM imports").get().count);
630
733
  const indexedPaths = new Set(fileRows.map((row) => row.path));
631
- let storedExcludePatterns = [];
632
- try {
633
- const parsed = JSON.parse(metadata.excludePatterns ?? "[]");
634
- if (Array.isArray(parsed)) storedExcludePatterns = normalizeContextIndexExcludePatterns(parsed);
635
- } catch {
636
- storedExcludePatterns = [];
637
- }
638
- const activeExcludePatterns = expectedExcludePatterns ?? storedExcludePatterns;
639
734
  const storedExcludeDigest = metadata.excludeDigest;
640
- const policyStale = expectedExcludeDigest !== undefined
641
- && (typeof storedExcludeDigest !== "string" || storedExcludeDigest !== expectedExcludeDigest);
735
+ const purgePending = metadata.purgePending === "1";
736
+ const excludeDigestStale = typeof storedExcludeDigest !== "string"
737
+ || storedExcludeDigest !== expectedExcludeDigest;
738
+ const policyStale = purgePending || excludeDigestStale;
642
739
  const statChanged = [];
643
740
  for (const row of fileRows) {
644
- if (!shouldIndexPath(row.path, { excludePatterns: activeExcludePatterns })) continue;
741
+ if (!shouldIndexPath(row.path, { excludePatterns: expectedExcludePatterns })) continue;
645
742
  const file = projectFileInfo(cwd, row.path, projectRoot, directoryCache);
646
743
  if (
647
744
  !file
@@ -660,11 +757,12 @@ export async function contextIndexV2Status(cwd, options = {}) {
660
757
  }
661
758
  const listed = gitFileList(cwd);
662
759
  const newlyVisible = listed
663
- ? listed.filter((file) => !indexedPaths.has(file) && !skippedPaths.has(file) && shouldIndexPath(file, { excludePatterns: activeExcludePatterns }))
760
+ ? listed.filter((file) => !indexedPaths.has(file) && !skippedPaths.has(file) && shouldIndexPath(file, { excludePatterns: expectedExcludePatterns }))
664
761
  : [];
665
762
  const stalePaths = [...new Set([...statChanged, ...newlyVisible])];
666
763
  const warnings = [];
667
- if (policyStale) warnings.push("context index exclusion policy changed");
764
+ if (purgePending) warnings.push("context index storage purge pending");
765
+ if (excludeDigestStale) warnings.push("context index exclusion policy changed");
668
766
  if (stalePaths.length > 0) warnings.push(`${stalePaths.length} changed file(s) since the last index build`);
669
767
  return {
670
768
  schemaVersion: Number(metadata.schemaVersion ?? INDEX_SCHEMA_VERSION),
@@ -676,6 +774,7 @@ export async function contextIndexV2Status(cwd, options = {}) {
676
774
  imports,
677
775
  stale: policyStale || stalePaths.length > 0,
678
776
  policyStale,
777
+ purgePending,
679
778
  excludePolicyVersion: metadata.excludePolicyVersion,
680
779
  excludeDigest: storedExcludeDigest,
681
780
  expectedExcludeDigest,
@@ -755,9 +854,9 @@ function personalizedPageRank(importRows, seeds, iterations = 8) {
755
854
  }
756
855
 
757
856
  function retrievalFeedback(cwd) {
758
- const target = contextEnginePaths(cwd).telemetry;
759
857
  let signature = "missing";
760
858
  try {
859
+ const target = resolveLocalStatePath(cwd, contextEnginePaths(cwd).telemetry, { label: "Context telemetry" });
761
860
  const stat = fs.statSync(target);
762
861
  signature = `${stat.size}:${stat.mtimeMs}`;
763
862
  } catch {
@@ -958,27 +1057,39 @@ export async function buildContextPack(cwd, query, options = {}) {
958
1057
  const excludePatterns = requireExplicitExcludePatterns(options, "buildContextPack");
959
1058
  const budgetTokens = clampInteger(options.budgetTokens, DEFAULT_PACK_TOKENS, 200, 50_000);
960
1059
  const includeCode = options.includeCode !== false;
1060
+ if (options.includePatterns !== undefined && !Array.isArray(options.includePatterns)) {
1061
+ throw new TypeError("buildContextPack includePatterns must be an array when provided");
1062
+ }
1063
+ const includePatterns = options.includePatterns === undefined
1064
+ ? undefined
1065
+ : [...new Set(options.includePatterns.map((value) => String(value).trim()).filter(Boolean))];
961
1066
  const projectRoot = fs.realpathSync.native(cwd);
962
1067
  const directoryCache = new Map();
963
1068
  const search = await searchContextIndexV2(cwd, query, {
964
1069
  limit: clampInteger(options.limit, 16, 1, 50),
965
1070
  excludePatterns
966
1071
  });
1072
+ const rankedResults = includePatterns === undefined
1073
+ ? search.results
1074
+ : search.results.filter((result) => matchesAnyPath(result.path, includePatterns));
1075
+ const confidence = rankedResults.length > 0 ? search.confidence : "none";
967
1076
  const selected = [];
968
1077
  const mapBudget = Math.min(1_200, Math.max(40, Math.trunc(budgetTokens * 0.2)));
969
1078
  const mapLines = [];
970
- for (const result of search.results) {
1079
+ for (const result of rankedResults) {
971
1080
  const symbolNames = result.symbols.slice(0, 5).map((symbol) => `${symbol.kind} ${symbol.name}`).join(", ");
972
1081
  const line = `- ${result.path}${symbolNames ? `: ${symbolNames}` : ""} [${result.sources.join("+")}]`;
973
1082
  const cost = estimateContextTokens(line);
974
1083
  if (estimateContextTokens(mapLines.join("\n")) + cost > mapBudget) break;
975
1084
  mapLines.push(line);
976
1085
  }
977
- const footer = "Use this as navigation evidence. Re-read current files before editing; index hits are advisory.";
1086
+ const footer = options.currentSnapshot === true
1087
+ ? "Current-turn source snapshot: content was read from current non-protected project files after index freshness checks. Use it for the first edit; re-read only when a needed region is absent or an edit reports a mismatch."
1088
+ : "Use this as navigation evidence. Re-read current files before editing; index hits are advisory.";
978
1089
  const renderHeader = () => [
979
1090
  "Pi Context Pack v2",
980
1091
  `queryHash: ${sha256(String(query)).slice(0, 12)}`,
981
- `confidence: ${search.confidence}`,
1092
+ `confidence: ${confidence}`,
982
1093
  `index: ${search.status.files ?? 0} files / ${search.status.symbols ?? 0} symbols${search.status.stale ? " / stale" : ""}`,
983
1094
  "",
984
1095
  "Repository map:",
@@ -996,7 +1107,7 @@ export async function buildContextPack(cwd, query, options = {}) {
996
1107
  header = renderHeader();
997
1108
  }
998
1109
 
999
- for (const result of search.results) {
1110
+ for (const result of rankedResults) {
1000
1111
  let text;
1001
1112
  try {
1002
1113
  const file = projectFileInfo(cwd, result.path, projectRoot, directoryCache);
@@ -1031,13 +1142,13 @@ export async function buildContextPack(cwd, query, options = {}) {
1031
1142
  const text = renderPack(selected);
1032
1143
  return {
1033
1144
  queryHash: sha256(String(query)),
1034
- confidence: search.confidence,
1145
+ confidence,
1035
1146
  status: search.status,
1036
- candidates: search.results.length,
1147
+ candidates: rankedResults.length,
1037
1148
  selected: selected.map(({ text: _text, ...item }) => item),
1038
1149
  estimatedTokens: estimateContextTokens(text),
1039
- finderRecommended: search.confidence === "none" || search.confidence === "low",
1040
- finderRequest: search.confidence === "none" || search.confidence === "low"
1150
+ finderRecommended: confidence === "none" || confidence === "low",
1151
+ finderRequest: confidence === "none" || confidence === "low"
1041
1152
  ? `Run one bounded read-only finder pass for: ${String(query).slice(0, 600)}. Return only paths, symbols, line ranges, evidence, and unknowns.`
1042
1153
  : undefined,
1043
1154
  text
@@ -1094,44 +1205,21 @@ export async function buildTestImpact(cwd, changedFiles = [], options = {}) {
1094
1205
 
1095
1206
  export function appendContextTelemetry(cwd, event) {
1096
1207
  const paths = contextEnginePaths(cwd);
1097
- fs.mkdirSync(paths.root, { recursive: true });
1098
1208
  const record = {
1099
1209
  schemaVersion: TELEMETRY_SCHEMA_VERSION,
1100
1210
  source: "piagent",
1101
1211
  recordedAt: nowIso(),
1102
1212
  ...event
1103
1213
  };
1104
- fs.appendFileSync(paths.telemetry, `${JSON.stringify(record)}\n`, { mode: 0o600 });
1214
+ appendJsonlBounded(paths.telemetry, record, { maxBytes: MAX_TELEMETRY_FILE_BYTES, mode: 0o600, projectRoot: cwd });
1105
1215
  return record;
1106
1216
  }
1107
1217
 
1108
1218
  export function readContextTelemetry(cwd, options = {}) {
1109
1219
  const target = contextEnginePaths(cwd).telemetry;
1110
- if (!fs.existsSync(target)) return [];
1111
1220
  const limit = clampInteger(options.limit, MAX_TELEMETRY_EVENTS, 1, MAX_TELEMETRY_EVENTS);
1112
1221
  const maxBytes = clampInteger(options.maxBytes, MAX_TELEMETRY_READ_BYTES, 64 * 1024, 256 * 1024 * 1024);
1113
- const stat = fs.statSync(target);
1114
- const start = Math.max(0, stat.size - maxBytes);
1115
- const length = stat.size - start;
1116
- const buffer = Buffer.allocUnsafe(length);
1117
- const descriptor = fs.openSync(target, "r");
1118
- try {
1119
- fs.readSync(descriptor, buffer, 0, length, start);
1120
- } finally {
1121
- fs.closeSync(descriptor);
1122
- }
1123
- let text = buffer.toString("utf8");
1124
- if (start > 0) text = text.slice(Math.max(0, text.indexOf("\n") + 1));
1125
- const lines = text.split(/\r?\n/).filter(Boolean);
1126
- const events = [];
1127
- for (const line of lines.slice(-limit)) {
1128
- try {
1129
- events.push(JSON.parse(line));
1130
- } catch {
1131
- // Ignore one incomplete tail record after an interrupted append.
1132
- }
1133
- }
1134
- return events;
1222
+ return readJsonlTail(target, { limit, maxBytes, projectRoot: cwd });
1135
1223
  }
1136
1224
 
1137
1225
  function ratio(numerator, denominator) {
@@ -1233,8 +1321,9 @@ export function buildContextEfficiencyReport(cwd, options = {}) {
1233
1321
  recommendations
1234
1322
  };
1235
1323
  const paths = contextEnginePaths(cwd);
1236
- fs.mkdirSync(paths.root, { recursive: true });
1237
- fs.writeFileSync(paths.report, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
1324
+ ensurePrivateStateDirectory(cwd, paths.root, "Context report directory");
1325
+ const reportPath = resolveLocalStatePath(cwd, paths.report, { label: "Context efficiency report" });
1326
+ fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
1238
1327
  return report;
1239
1328
  }
1240
1329
 
@@ -6,7 +6,7 @@ import { resolveProjectProfileDocument } from "../capabilities/project-profile.j
6
6
 
7
7
  const DEFAULT_CONTEXT_INDEX_PATH = ".pi/context-index.json";
8
8
  const CONTEXT_ENGINE_STATE_PATTERN = ".pi/piagent-state/**";
9
- const EXCLUDE_POLICY_VERSION = 1;
9
+ const EXCLUDE_POLICY_VERSION = 2;
10
10
 
11
11
  function stringArray(value) {
12
12
  return Array.isArray(value)
@@ -179,8 +179,20 @@ export type WorkPlanStep = {
179
179
  title: string;
180
180
  role: OrchestrationRole;
181
181
  mode: "read-only" | "single-writer" | "review";
182
- status: "pending" | "in-progress" | "done" | "skipped";
182
+ status: "pending" | "in-progress" | "done" | "skipped" | "failed";
183
183
  dependsOn?: string[];
184
+ note?: string;
185
+ updatedAt?: string;
186
+ };
187
+
188
+ export type TaskAttemptSummary = {
189
+ taskRunId: string;
190
+ attempt: number;
191
+ outcome: "pending" | "completed" | "blocked" | "partial" | "failed";
192
+ failedAt?: "research" | "plan" | "execute" | "verify" | "review";
193
+ reason?: string;
194
+ ruledOut?: string;
195
+ recordedAt: string;
184
196
  };
185
197
 
186
198
  export type OrchestrationPolicySettings = {
@@ -250,9 +262,18 @@ export type ProjectOnboardingSnapshot = {
250
262
  };
251
263
 
252
264
  export type TaskContract = {
265
+ schemaVersion: 2;
266
+ taskRunId: string;
253
267
  taskId: string;
268
+ sessionId: string;
269
+ sessionName?: string;
270
+ changeMode: "source-change" | "read-only";
271
+ attempt: number;
272
+ maxAttempts: number;
273
+ previousAttempts: TaskAttemptSummary[];
254
274
  summary: string;
255
275
  riskLane: "tiny" | "normal" | "high-risk";
276
+ intakeMode?: "model" | "runtime";
256
277
  expectedOutput: string;
257
278
  acceptanceCriteria: string[];
258
279
  scope: string[];
@@ -262,9 +283,10 @@ export type TaskContract = {
262
283
  contextManifest: Array<{ path: string; reason: string }>;
263
284
  memoryCitations: Array<{ path: string; reason: string }>;
264
285
  mcpCapabilities: string[];
286
+ verifyGroup?: string;
265
287
  verifyCommands: string[];
266
- workPlan?: WorkPlanStep[];
267
- reviewLenses?: ReviewLens[];
288
+ workPlan: WorkPlanStep[];
289
+ reviewLenses: ReviewLens[];
268
290
  orchestration?: {
269
291
  mode: OrchestrationMode;
270
292
  subagents: "not-used" | "optional" | "used";
@@ -272,6 +294,11 @@ export type TaskContract = {
272
294
  fieldGuidePath?: string;
273
295
  modelRoles?: Record<"planner" | "worker" | "reviewer" | "watchdog", string>;
274
296
  };
297
+ baselineChangedFiles: string[];
298
+ baselineFileDigests: Record<string, string>;
299
+ observedChangedFiles: string[];
300
+ finalWorkingTreeFiles: string[];
301
+ finalFileDigests: Record<string, string>;
275
302
  changedFiles: string[];
276
303
  verifyEvidence: Array<{
277
304
  command: string;
@@ -282,6 +309,7 @@ export type TaskContract = {
282
309
  observedAt?: string;
283
310
  isError?: boolean;
284
311
  matchedProfileCommand?: boolean;
312
+ workingTreeDigest?: string;
285
313
  }>;
286
314
  trace: {
287
315
  outcome: "pending" | "completed" | "blocked" | "partial" | "failed";
@@ -289,6 +317,10 @@ export type TaskContract = {
289
317
  notes?: string;
290
318
  recordedAt?: string;
291
319
  };
320
+ failedAt?: "research" | "plan" | "execute" | "verify" | "review";
321
+ failureReason?: string;
322
+ ruledOut?: string;
323
+ migratedFromSchemaVersion?: number;
292
324
  createdAt: string;
293
325
  updatedAt: string;
294
326
  };