@piagent/platform 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +8 -2
  3. package/README.vi.md +27 -0
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +7 -7
  6. package/docs/en/architecture.md +13 -0
  7. package/docs/languages.json +20 -4
  8. package/docs/mcp-and-tools.md +112 -1
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +36 -0
  11. package/docs/release-install-policy.md +21 -8
  12. package/docs/runtime-harness-standard.md +1 -1
  13. package/docs/security-threat-model.md +1 -1
  14. package/docs/task-implementation-contract.md +11 -4
  15. package/docs/team-onboarding.md +2 -2
  16. package/docs/usage-observability.md +70 -0
  17. package/docs/vi/architecture.md +13 -0
  18. package/docs-site/architecture.html +5 -1
  19. package/docs-site/benchmark.html +5 -1
  20. package/docs-site/commands.html +5 -1
  21. package/docs-site/content/en/index.html +49 -0
  22. package/docs-site/content/en/runtime.html +13 -0
  23. package/docs-site/content/index.html +49 -0
  24. package/docs-site/content/runtime.html +28 -0
  25. package/docs-site/context-engine.html +5 -1
  26. package/docs-site/en/architecture.html +5 -1
  27. package/docs-site/en/benchmark.html +5 -1
  28. package/docs-site/en/commands.html +5 -1
  29. package/docs-site/en/context-engine.html +5 -1
  30. package/docs-site/en/index.html +54 -1
  31. package/docs-site/en/mcp-approval.html +5 -1
  32. package/docs-site/en/mcp-auth.html +5 -1
  33. package/docs-site/en/mcp-servers.html +5 -1
  34. package/docs-site/en/mcp.html +5 -1
  35. package/docs-site/en/permissions.html +5 -1
  36. package/docs-site/en/profiles.html +5 -1
  37. package/docs-site/en/quickstart.html +5 -1
  38. package/docs-site/en/runtime.html +18 -1
  39. package/docs-site/en/scope.html +5 -1
  40. package/docs-site/en/security.html +5 -1
  41. package/docs-site/en/team.html +5 -1
  42. package/docs-site/en/whats-new.html +5 -1
  43. package/docs-site/en/workflows.html +5 -1
  44. package/docs-site/index.html +54 -1
  45. package/docs-site/mcp-approval.html +5 -1
  46. package/docs-site/mcp-auth.html +5 -1
  47. package/docs-site/mcp-servers.html +5 -1
  48. package/docs-site/mcp.html +5 -1
  49. package/docs-site/permissions.html +5 -1
  50. package/docs-site/profiles.html +5 -1
  51. package/docs-site/quickstart.html +5 -1
  52. package/docs-site/runtime.html +33 -1
  53. package/docs-site/scope.html +5 -1
  54. package/docs-site/security.html +5 -1
  55. package/docs-site/team.html +5 -1
  56. package/docs-site/whats-new.html +5 -1
  57. package/docs-site/workflows.html +5 -1
  58. package/package.json +14 -2
  59. package/packages/piagent-core/README.md +3 -2
  60. package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
  61. package/packages/piagent-core/capabilities/runtime-integrity.js +51 -4
  62. package/packages/piagent-core/extensions/piagent-guard.ts +18 -235
  63. package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
  64. package/packages/piagent-core/extensions/shell-reach.ts +250 -0
  65. package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
  66. package/packages/piagent-core/mcp/mcp-server-catalog.js +135 -5
  67. package/packages/piagent-core/package.json +1 -1
  68. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +9 -2
  69. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +25 -2
  70. package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
  71. package/packages/piagent-core/runtime/registration/task-start-tool.ts +9 -13
  72. package/packages/piagent-core/runtime/session/token-economics.js +34 -0
  73. package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
  74. package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
  75. package/packages/piagent-core/security/sensitive-data.js +7 -0
  76. package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +172 -0
  77. package/packages/piagent-webui/dist/client/index.html +1 -1
  78. package/packages/piagent-webui/extension/lifecycle-controller.ts +15 -0
  79. package/packages/piagent-webui/extension/piagent-webui.ts +4 -4
  80. package/packages/piagent-webui/extension/same-session-bridge.ts +3 -3
  81. package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
  82. package/packages/piagent-webui/gateway/session-inspection-registry.ts +37 -2
  83. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +5 -1
  84. package/packages/piagent-webui/server/core-inspection-provider.ts +5 -5
  85. package/packages/piagent-webui/server/transcript-projection.ts +15 -0
  86. package/scripts/build-docs-site.mjs +21 -1
  87. package/scripts/explain-command.mjs +240 -0
  88. package/scripts/install-global.sh +104 -3
  89. package/scripts/mcp-manage.mjs +17 -4
  90. package/scripts/pi-session-stats.sh +10 -2
  91. package/scripts/piagent-cli.mjs +14 -1
  92. package/scripts/piagent-dashboard.mjs +55 -2
  93. package/scripts/setup.sh +69 -33
  94. package/scripts/traffic-snapshot.mjs +214 -0
  95. package/scripts/typescript-loader.mjs +20 -4
  96. package/scripts/uninstall-global.sh +53 -2
  97. package/scripts/update-global.mjs +33 -1
  98. package/scripts/verify-local.sh +4 -0
  99. package/scripts/verify-translation-pairing.mjs +121 -0
  100. package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +0 -172
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env node
2
+ // Archives GitHub repository traffic before GitHub discards it.
3
+ //
4
+ // The traffic API only serves a rolling 14-day window. Anything older is gone
5
+ // for good -- there is no endpoint that returns it and no way to reconstruct
6
+ // it. So the counts are merged into CSV files that keep growing, and a run that
7
+ // happens at least every 14 days loses nothing.
8
+ //
9
+ // Merge rule: a row is keyed by its day and the newest fetch wins. Today's
10
+ // bucket is still filling while the run happens, so it is always partial; the
11
+ // next run overwrites it with the settled value. Once a day leaves the window
12
+ // it stops being returned at all, and the stored row is final.
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ const EXIT_USAGE = 2;
18
+ const EXIT_FORBIDDEN = 3;
19
+
20
+ const SERIES = [
21
+ { endpoint: "clones", file: "clones.csv", columns: ["date", "count", "uniques"] },
22
+ { endpoint: "views", file: "views.csv", columns: ["date", "count", "uniques"] }
23
+ ];
24
+ // Referrers and paths are a 14-day rollup, not a daily series: the API gives no
25
+ // per-day breakdown, so a row is only meaningful next to the day it was read.
26
+ const ROLLUPS = [
27
+ { endpoint: "popular/referrers", file: "referrers.csv", key: "referrer", columns: ["snapshot_date", "referrer", "count", "uniques"] },
28
+ { endpoint: "popular/paths", file: "paths.csv", key: "path", columns: ["snapshot_date", "path", "title", "count", "uniques"] }
29
+ ];
30
+
31
+ function fail(message, code = EXIT_USAGE) {
32
+ console.error(`FAIL: ${message}`);
33
+ process.exit(code);
34
+ }
35
+
36
+ export function parseArguments(argv) {
37
+ const options = { repository: "", out: "traffic", dryRun: false, today: "" };
38
+ for (let index = 0; index < argv.length; index += 1) {
39
+ const argument = argv[index];
40
+ switch (argument) {
41
+ case "--repository": case "--repo": {
42
+ options.repository = argv[index += 1] ?? "";
43
+ if (!/^[^/\s]+\/[^/\s]+$/.test(options.repository)) fail("--repository needs owner/name");
44
+ break;
45
+ }
46
+ case "--out": {
47
+ options.out = argv[index += 1] ?? "";
48
+ if (!options.out) fail("--out needs a directory");
49
+ break;
50
+ }
51
+ // The archive is written from a snapshot date, and a test cannot wait a
52
+ // day to prove the merge. Injecting it keeps the assertions exact.
53
+ case "--today": {
54
+ options.today = argv[index += 1] ?? "";
55
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(options.today)) fail("--today needs YYYY-MM-DD");
56
+ break;
57
+ }
58
+ case "--dry-run": options.dryRun = true; break;
59
+ case "-h": case "--help": options.help = true; break;
60
+ default: fail(`unknown argument: ${argument}`);
61
+ }
62
+ }
63
+ return options;
64
+ }
65
+
66
+ export function csvEscape(value) {
67
+ const text = String(value ?? "");
68
+ return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
69
+ }
70
+
71
+ export function parseCsv(text) {
72
+ const rows = [];
73
+ let row = [], field = "", quoted = false;
74
+ for (let index = 0; index < text.length; index += 1) {
75
+ const character = text[index];
76
+ if (quoted) {
77
+ if (character === '"' && text[index + 1] === '"') { field += '"'; index += 1; }
78
+ else if (character === '"') quoted = false;
79
+ else field += character;
80
+ continue;
81
+ }
82
+ if (character === '"') quoted = true;
83
+ else if (character === ",") { row.push(field); field = ""; }
84
+ else if (character === "\n") { row.push(field); rows.push(row); row = []; field = ""; }
85
+ else if (character !== "\r") field += character;
86
+ }
87
+ if (field.length || row.length) { row.push(field); rows.push(row); }
88
+ return rows.filter((entry) => entry.some((cell) => cell !== ""));
89
+ }
90
+
91
+ function readTable(file, columns) {
92
+ if (!fs.existsSync(file)) return [];
93
+ const rows = parseCsv(fs.readFileSync(file, "utf8"));
94
+ const header = rows.shift();
95
+ if (!header || header.join(",") !== columns.join(",")) {
96
+ fail(`${file} has header [${header?.join(", ") ?? "none"}], expected [${columns.join(", ")}]`);
97
+ }
98
+ return rows.map((row) => Object.fromEntries(columns.map((column, index) => [column, row[index] ?? ""])));
99
+ }
100
+
101
+ function writeTable(file, columns, rows) {
102
+ const lines = [columns.join(",")];
103
+ for (const row of rows) lines.push(columns.map((column) => csvEscape(row[column])).join(","));
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ fs.writeFileSync(file, `${lines.join("\n")}\n`);
106
+ }
107
+
108
+ /**
109
+ * Existing rows plus fetched rows, keyed by `key`. The fetched value wins,
110
+ * because it is the newer reading of the same day. Rows outside the API window
111
+ * are untouched -- that is the whole point of the archive.
112
+ */
113
+ export function mergeByKey(existing, fetched, key) {
114
+ const merged = new Map(existing.map((row) => [row[key], row]));
115
+ for (const row of fetched) merged.set(row[key], row);
116
+ return [...merged.values()].sort((a, b) => String(a[key]).localeCompare(String(b[key])));
117
+ }
118
+
119
+ /** Rollup rows for one snapshot date, replacing any earlier run on that date. */
120
+ export function mergeRollup(existing, fetched, snapshotDate) {
121
+ const kept = existing.filter((row) => row.snapshot_date !== snapshotDate);
122
+ return [...kept, ...fetched].sort((a, b) =>
123
+ String(a.snapshot_date).localeCompare(String(b.snapshot_date))
124
+ || Number(b.count) - Number(a.count));
125
+ }
126
+
127
+ async function fetchTraffic(repository, endpoint, token) {
128
+ const url = `https://api.github.com/repos/${repository}/traffic/${endpoint}`;
129
+ const response = await fetch(url, {
130
+ headers: {
131
+ accept: "application/vnd.github+json",
132
+ authorization: `Bearer ${token}`,
133
+ "user-agent": "piagent-traffic-snapshot",
134
+ "x-github-api-version": "2022-11-28"
135
+ }
136
+ });
137
+ if (response.status === 403 || response.status === 404) {
138
+ // The distinction matters: this is the one failure an operator cannot debug
139
+ // from the status code, because the traffic API answers 403 for a token
140
+ // that is valid but lacks push access -- which the default workflow token
141
+ // may or may not have, depending on repository settings.
142
+ fail(`the token cannot read ${endpoint} for ${repository} (HTTP ${response.status}).\n`
143
+ + " Repository traffic needs push access. If this ran in GitHub Actions with the\n"
144
+ + " default GITHUB_TOKEN, create a fine-grained personal access token with the\n"
145
+ + " 'Administration: read' permission on this repository, store it as the\n"
146
+ + " TRAFFIC_TOKEN secret, and re-run. Nothing was written.", EXIT_FORBIDDEN);
147
+ }
148
+ if (!response.ok) fail(`GET ${url} returned HTTP ${response.status}`);
149
+ return response.json();
150
+ }
151
+
152
+ export async function snapshot(options, token, fetcher = fetchTraffic) {
153
+ const today = options.today || new Date().toISOString().slice(0, 10);
154
+ const summary = { repository: options.repository, snapshotDate: today, files: {} };
155
+
156
+ // Read every existing file before fetching anything. A corrupt archive is a
157
+ // reason to stop, and stopping before the network call keeps the failure
158
+ // about the file rather than about whatever the API happened to answer.
159
+ const existing = new Map();
160
+ for (const target of [...SERIES, ...ROLLUPS]) {
161
+ existing.set(target.file, readTable(path.join(options.out, target.file), target.columns));
162
+ }
163
+
164
+ for (const series of SERIES) {
165
+ const payload = await fetcher(options.repository, series.endpoint, token);
166
+ const fetched = (payload[series.endpoint] ?? []).map((entry) => ({
167
+ date: String(entry.timestamp ?? "").slice(0, 10),
168
+ count: String(entry.count ?? 0),
169
+ uniques: String(entry.uniques ?? 0)
170
+ })).filter((row) => row.date);
171
+ const file = path.join(options.out, series.file);
172
+ const merged = mergeByKey(existing.get(series.file), fetched, "date");
173
+ if (!options.dryRun) writeTable(file, series.columns, merged);
174
+ summary.files[series.file] = { fetchedDays: fetched.length, totalDays: merged.length };
175
+ }
176
+
177
+ for (const rollup of ROLLUPS) {
178
+ const payload = await fetcher(options.repository, rollup.endpoint, token);
179
+ const fetched = (Array.isArray(payload) ? payload : []).map((entry) => ({
180
+ snapshot_date: today,
181
+ [rollup.key]: String(entry[rollup.key] ?? entry.referrer ?? entry.path ?? ""),
182
+ title: String(entry.title ?? ""),
183
+ count: String(entry.count ?? 0),
184
+ uniques: String(entry.uniques ?? 0)
185
+ }));
186
+ const file = path.join(options.out, rollup.file);
187
+ const merged = mergeRollup(existing.get(rollup.file), fetched, today);
188
+ if (!options.dryRun) writeTable(file, rollup.columns, merged);
189
+ summary.files[rollup.file] = { fetchedRows: fetched.length, totalRows: merged.length };
190
+ }
191
+
192
+ return summary;
193
+ }
194
+
195
+ const invokedDirectly = process.argv[1]
196
+ && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
197
+
198
+ if (invokedDirectly) {
199
+ const options = parseArguments(process.argv.slice(2));
200
+ if (options.help) {
201
+ console.log(`Usage:
202
+ node scripts/traffic-snapshot.mjs --repository <owner/name> [--out <dir>] [--dry-run]
203
+
204
+ Archives GitHub traffic (clones, views, referrers, paths) into growing CSV files.
205
+ The API only keeps 14 days, so run this at least fortnightly or the gap is
206
+ permanent. Reads the token from TRAFFIC_TOKEN, else GITHUB_TOKEN.`);
207
+ process.exit(0);
208
+ }
209
+ if (!options.repository) fail("--repository is required");
210
+ const token = process.env.TRAFFIC_TOKEN || process.env.GITHUB_TOKEN;
211
+ if (!token) fail("set TRAFFIC_TOKEN or GITHUB_TOKEN");
212
+ const summary = await snapshot(options, token);
213
+ console.log(JSON.stringify(summary, null, 2));
214
+ }
@@ -18,11 +18,27 @@ export const PINNED_EXTERNAL_TRANSFORM = Object.freeze({
18
18
  async function pinnedTransformRoot(url) {
19
19
  const configured = process.env.PIAGENT_PINNED_TS_TRANSFORM_ROOT;
20
20
  if (!configured || process.versions.node !== PINNED_EXTERNAL_TRANSFORM.nodeVersion) return false;
21
- const root = await fs.realpath(configured), target = await fs.realpath(fileURLToPath(url));
21
+ // This asks one question: is the file being loaded inside the pinned adapter?
22
+ // A configured root that is absent, unreadable or not that adapter answers
23
+ // "no". It used to throw instead, and the throw escaped `load` on the first
24
+ // `.ts` file of any kind -- so a machine without pi-mcp-adapter installed
25
+ // could not start the gateway at all, for a reason that had nothing to do
26
+ // with the file it died on. Answering "no" falls back to `strip`, which is
27
+ // the stricter of the two modes: this can only refuse a transform, never
28
+ // grant one.
29
+ let root;
30
+ let metadata;
31
+ try {
32
+ root = await fs.realpath(configured);
33
+ metadata = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
34
+ } catch {
35
+ return false;
36
+ }
37
+ if (metadata?.name !== PINNED_EXTERNAL_TRANSFORM.packageName
38
+ || metadata?.version !== PINNED_EXTERNAL_TRANSFORM.packageVersion) return false;
39
+ const target = await fs.realpath(fileURLToPath(url));
22
40
  const relative = path.relative(root, target);
23
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return false;
24
- const metadata = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
25
- return metadata.name === PINNED_EXTERNAL_TRANSFORM.packageName && metadata.version === PINNED_EXTERNAL_TRANSFORM.packageVersion;
41
+ return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
26
42
  }
27
43
 
28
44
  export async function load(url, context, nextLoad) {
@@ -80,6 +80,46 @@ run_cmd() {
80
80
  fi
81
81
  }
82
82
 
83
+ # The helper can live under a non-default npm prefix: install falls back to a
84
+ # user-writable root when the configured one is not writable, and --npm-prefix
85
+ # picks one deliberately. A bare `npm uninstall -g` resolves against the
86
+ # configured prefix instead, so it removes nothing and still reports success.
87
+ # Derive the prefix from where this script actually runs, the same way
88
+ # update-global.mjs does, and only add --prefix when it differs.
89
+ installed_helper_prefix() {
90
+ local root="$ROOT"
91
+ [[ "$(basename "$root")" == "platform" ]] || return 0
92
+ local scope_root node_modules_root parent
93
+ scope_root="$(dirname "$root")"
94
+ [[ "$(basename "$scope_root")" == "@piagent" ]] || return 0
95
+ node_modules_root="$(dirname "$scope_root")"
96
+ [[ "$(basename "$node_modules_root")" == "node_modules" ]] || return 0
97
+ parent="$(dirname "$node_modules_root")"
98
+ # An npx run lives in a prunable cache that owns no bin on anyone's PATH;
99
+ # uninstalling from there would claim to remove an install nobody has.
100
+ case "$parent" in
101
+ */_npx/*|*/_npx) return 0 ;;
102
+ esac
103
+ if [[ "$(basename "$parent")" == "lib" ]]; then
104
+ dirname "$parent"
105
+ else
106
+ printf '%s' "$parent"
107
+ fi
108
+ }
109
+
110
+ # The prefix npm has to be pointed at, or empty when the helper already lives
111
+ # under the configured one and the plain command is correct. Returns a single
112
+ # value rather than an argument list: macOS still ships bash 3.2, where
113
+ # expanding an empty array under `set -u` aborts the script.
114
+ npm_prefix_override() {
115
+ local helper_prefix configured
116
+ helper_prefix="$(installed_helper_prefix)"
117
+ [[ -n "$helper_prefix" ]] || return 0
118
+ configured="$(npm prefix -g 2>/dev/null || true)"
119
+ [[ "$helper_prefix" != "$configured" ]] || return 0
120
+ printf '%s' "$helper_prefix"
121
+ }
122
+
83
123
  # Reads the package sources Pi has registered, so removal targets what is
84
124
  # actually installed rather than what this version happens to install today. An
85
125
  # older release registered a different source shape, and it still has to come
@@ -174,7 +214,13 @@ if [[ "$WITH_HOST" == true ]]; then
174
214
  echo
175
215
  if command -v npm >/dev/null 2>&1; then
176
216
  echo "Uninstalling Pi host:"
177
- run_cmd npm uninstall -g "$PI_HOST_PACKAGE"
217
+ HOST_PREFIX="$(npm_prefix_override)"
218
+ if [[ -n "$HOST_PREFIX" ]]; then
219
+ echo " (prefix $HOST_PREFIX, where this helper is installed)"
220
+ run_cmd npm uninstall -g --prefix "$HOST_PREFIX" "$PI_HOST_PACKAGE"
221
+ else
222
+ run_cmd npm uninstall -g "$PI_HOST_PACKAGE"
223
+ fi
178
224
  else
179
225
  echo "WARN: npm is not on PATH; cannot uninstall $PI_HOST_PACKAGE." >&2
180
226
  fi
@@ -231,7 +277,12 @@ echo
231
277
  if [[ "$APPLY" == true ]]; then
232
278
  echo "Uninstall complete."
233
279
  echo "The npm-global helper is separate and stays until you remove it:"
234
- echo " npm uninstall -g $PLATFORM_PACKAGE"
280
+ HELPER_PREFIX="$(npm_prefix_override)"
281
+ if [[ -n "$HELPER_PREFIX" ]]; then
282
+ echo " npm uninstall -g --prefix $HELPER_PREFIX $PLATFORM_PACKAGE"
283
+ else
284
+ echo " npm uninstall -g $PLATFORM_PACKAGE"
285
+ fi
235
286
  else
236
287
  echo "Dry run only. Nothing was removed. Re-run with --apply to perform it."
237
288
  fi
@@ -213,8 +213,21 @@ function binForPrefix(prefix) {
213
213
  return process.platform === "win32" ? prefix : path.join(prefix, "bin");
214
214
  }
215
215
 
216
+ // The npx cache is a `node_modules` tree like any other, so the documented
217
+ // bootstrap -- `npm exec -y --package @piagent/platform@X.Y.Z -- piagent-update`
218
+ // -- ran the helper from `~/.npm/_npx/<hash>/node_modules/@piagent/platform` and
219
+ // this function happily reported that hash directory as the prefix to install
220
+ // into. Host and helper then landed in a cache npm is free to prune, with a `bin`
221
+ // that is on nobody's PATH: the command reported success and the machine had
222
+ // nothing installed. A cache is never an install target, so it is refused here
223
+ // and the caller falls back to a real global prefix.
224
+ function isNpxCacheRoot(root) {
225
+ return root.split(path.sep).some((segment) => segment === "_npx" || segment === "_cacache");
226
+ }
227
+
216
228
  function npmPrefixForInstalledHelper(root) {
217
229
  if (path.basename(root) !== "platform") return undefined;
230
+ if (isNpxCacheRoot(root)) return undefined;
218
231
  const scopeRoot = path.dirname(root);
219
232
  if (path.basename(scopeRoot) !== "@piagent") return undefined;
220
233
  const nodeModulesRoot = path.dirname(scopeRoot);
@@ -444,10 +457,29 @@ function main() {
444
457
  console.log(` npm: using explicit global prefix ${npmTarget.prefix}`);
445
458
  }
446
459
 
460
+ // The host version above was read before this prepend, so it described whatever
461
+ // `pi` the caller's PATH happened to resolve. Once the prefix moves -- a default
462
+ // global root that is not writable, an explicit `--npm-prefix`, or a helper
463
+ // already installed elsewhere -- every step below uses a different `pi`, and
464
+ // "(already current)" could be describing a binary this run never touches. The
465
+ // decision belongs to the `pi` that will actually be used, so it is taken again
466
+ // here rather than trusted from before the move.
467
+ let effectiveHost = currentHost;
468
+ let effectiveHostNeedsChange = hostNeedsChange;
469
+ if (npmTarget.bin) {
470
+ const activeHost = installedHostVersion();
471
+ if (activeHost !== currentHost) {
472
+ effectiveHost = activeHost;
473
+ effectiveHostNeedsChange = options.host && (options.force || activeHost !== targetHost);
474
+ console.log(` host: the active prefix resolves pi ${activeHost ?? "not installed"}, not ${currentHost ?? "not installed"};`
475
+ + ` using ${effectiveHost ?? "not installed"} -> ${targetHost}${effectiveHostNeedsChange ? "" : " (already current)"}`);
476
+ }
477
+ }
478
+
447
479
  // Host first: piagent-install refuses to run against a host that does not match
448
480
  // the version its package.json pins, so a helper installed ahead of the host
449
481
  // would fail on its very next step.
450
- if (hostNeedsChange) {
482
+ if (effectiveHostNeedsChange) {
451
483
  const args = npmInstallArgs(`${HOST_PACKAGE}@${targetHost}`, npmTarget);
452
484
  describeStep(options.dryRun, "npm", args);
453
485
  if (!options.dryRun && !run("npm", args).ok) fail(`could not install ${HOST_PACKAGE}@${targetHost}`);
@@ -173,6 +173,7 @@ required_files=(
173
173
  "$ROOT/scripts/build-docs-site.mjs"
174
174
  "$ROOT/scripts/check-architecture.mjs"
175
175
  "$ROOT/scripts/check-doc-languages.mjs"
176
+ "$ROOT/scripts/verify-translation-pairing.mjs"
176
177
  "$ROOT/scripts/check-third-party-neutrality.mjs"
177
178
  "$ROOT/governance/piagent-webui/wui5-18-acceptance-matrix.v1.json"
178
179
  "$ROOT/tests/piagent-webui-wui5-18-gate.test.mjs"
@@ -914,6 +915,9 @@ node --check "$ROOT/scripts/build-docs-site.mjs" >/dev/null
914
915
  node --check "$ROOT/scripts/preview-docs-site.mjs" >/dev/null
915
916
  node "$ROOT/scripts/check-architecture.mjs" >/dev/null
916
917
  node "$ROOT/scripts/check-doc-languages.mjs" >/dev/null
918
+ # check-doc-languages proves the peer exists. This proves neither side moved
919
+ # without the other, which is the way a translation actually goes stale.
920
+ node "$ROOT/scripts/verify-translation-pairing.mjs" >/dev/null
917
921
  node "$ROOT/scripts/check-third-party-neutrality.mjs" >/dev/null
918
922
  run_quietly "npm run build --workspace @piagent/webui" npm run build --workspace @piagent/webui
919
923
  run_quietly "npm test" npm test
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ // Fails when one side of a bilingual pair has moved and the other has not.
3
+ //
4
+ // The existing language gate proves the Vietnamese peer exists. Existing is not
5
+ // the same as saying the same thing: editing docs/en/architecture.md and
6
+ // forgetting docs/vi/architecture.md passes every check in this repository,
7
+ // and the reader of the stale side has no way to know.
8
+ //
9
+ // So each pair carries the content digest of both sides as of the last time
10
+ // somebody confirmed them consistent. A digest that no longer matches means the
11
+ // record is stale, and the fix is to bring the other side along and re-record.
12
+ //
13
+ // What this cannot do is read the two documents and decide they agree. It is a
14
+ // record that a human looked, nothing more -- which is worth exactly as much as
15
+ // the honesty of whoever runs --write.
16
+
17
+ import { createHash } from "node:crypto";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
+
24
+ export function contentDigest(text) {
25
+ // Newline-normalised so a checkout on another platform does not read as drift.
26
+ return createHash("sha256").update(text.replace(/\r\n/g, "\n"), "utf8").digest("hex").slice(0, 40);
27
+ }
28
+
29
+ export function digestForFile(root, relative) {
30
+ const file = path.join(root, relative);
31
+ if (!fs.existsSync(file)) return null;
32
+ return contentDigest(fs.readFileSync(file, "utf8"));
33
+ }
34
+
35
+ /**
36
+ * @param {{root?: string, manifest?: object}} options
37
+ * @returns {{ok: boolean, checked: number, unrecorded: string[], drifted: object[], stale: object[], missing: string[]}}
38
+ */
39
+ export function inspectPairing({ root = repositoryRoot, manifest } = {}) {
40
+ const languages = manifest
41
+ ?? JSON.parse(fs.readFileSync(path.join(root, "docs", "languages.json"), "utf8"));
42
+ const result = { ok: true, checked: 0, unrecorded: [], drifted: [], stale: [], missing: [] };
43
+
44
+ for (const pair of languages.pairs ?? []) {
45
+ const current = { en: digestForFile(root, pair.en), vi: digestForFile(root, pair.vi) };
46
+ for (const side of ["en", "vi"]) {
47
+ if (current[side] === null) result.missing.push(`${pair.topic}: ${pair[side]} does not exist`);
48
+ }
49
+ if (current.en === null || current.vi === null) { result.ok = false; continue; }
50
+
51
+ result.checked += 1;
52
+ const recorded = pair.consistentAt;
53
+ if (!recorded) { result.unrecorded.push(pair.topic); result.ok = false; continue; }
54
+
55
+ const movedSides = ["en", "vi"].filter((side) => recorded[side] !== current[side]);
56
+ if (movedSides.length === 0) continue;
57
+ result.ok = false;
58
+ // One side moving alone is the failure this exists for: the other side is
59
+ // now describing something that changed. Both moving is likely a paired
60
+ // edit, but nothing here can prove it, so it still needs re-recording.
61
+ const entry = { topic: pair.topic, moved: movedSides, en: pair.en, vi: pair.vi };
62
+ if (movedSides.length === 1) result.drifted.push(entry);
63
+ else result.stale.push(entry);
64
+ }
65
+ return result;
66
+ }
67
+
68
+ function record(root) {
69
+ const file = path.join(root, "docs", "languages.json");
70
+ const languages = JSON.parse(fs.readFileSync(file, "utf8"));
71
+ for (const pair of languages.pairs ?? []) {
72
+ const en = digestForFile(root, pair.en);
73
+ const vi = digestForFile(root, pair.vi);
74
+ if (en === null || vi === null) continue;
75
+ pair.consistentAt = { en, vi };
76
+ }
77
+ fs.writeFileSync(file, `${JSON.stringify(languages, null, 2)}\n`);
78
+ return languages.pairs.length;
79
+ }
80
+
81
+ const invokedDirectly = process.argv[1]
82
+ && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
83
+
84
+ if (invokedDirectly) {
85
+ const write = process.argv.includes("--write");
86
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
87
+ console.log(`Usage:
88
+ node scripts/verify-translation-pairing.mjs [--write]
89
+
90
+ Checks that neither side of a bilingual documentation pair has moved since the
91
+ two were last confirmed consistent. --write re-records the current state, which
92
+ is a claim that you brought both sides into line.`);
93
+ process.exit(0);
94
+ }
95
+ if (write) {
96
+ console.log(`Recorded ${record(repositoryRoot)} pairs as consistent.`);
97
+ process.exit(0);
98
+ }
99
+
100
+ const result = inspectPairing();
101
+ if (result.ok) {
102
+ console.log(`PASS: ${result.checked} bilingual pairs match their recorded state`);
103
+ process.exit(0);
104
+ }
105
+ for (const line of result.missing) console.error(`FAIL: ${line}`);
106
+ for (const topic of result.unrecorded) {
107
+ console.error(`FAIL: ${topic} has no recorded consistent state.`);
108
+ }
109
+ for (const entry of result.drifted) {
110
+ const moved = entry.moved[0];
111
+ const other = moved === "en" ? "vi" : "en";
112
+ console.error(`FAIL: ${entry.topic}: ${entry[moved]} changed and ${entry[other]} did not.`);
113
+ console.error(` Bring ${entry[other]} along, then re-record.`);
114
+ }
115
+ for (const entry of result.stale) {
116
+ console.error(`FAIL: ${entry.topic}: both sides changed, so the record no longer says anything.`);
117
+ console.error(` Confirm they still agree, then re-record.`);
118
+ }
119
+ console.error("\nRe-record with: node scripts/verify-translation-pairing.mjs --write");
120
+ process.exit(1);
121
+ }