pi-harness-runtime 1.1.394 → 1.1.396

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 (214) hide show
  1. package/AGENTS.md +135 -4
  2. package/harness/glm-quota-logger.ts +17 -0
  3. package/harness/langchain/planner-logger.ts +15 -0
  4. package/harness/service-diagnostics.ts +32 -1
  5. package/index.ts +92 -3
  6. package/package.json +1 -1
  7. package/packages/a2a-adapter/package.json +1 -1
  8. package/packages/agent-policy/package.json +1 -1
  9. package/packages/architecture-generator/package.json +1 -1
  10. package/packages/auth/package.json +1 -1
  11. package/packages/autonomous-refactor/package.json +1 -1
  12. package/packages/autonomous-runtime/package.json +1 -1
  13. package/packages/cache-strategy/package.json +1 -1
  14. package/packages/capability-registry/package.json +1 -1
  15. package/packages/checkpoint/package.json +1 -1
  16. package/packages/cli-plugin-sdk/package.json +1 -1
  17. package/packages/clipboard/package.json +1 -1
  18. package/packages/clipboard-plugin/package.json +1 -1
  19. package/packages/code-generation/package.json +1 -1
  20. package/packages/code-review/package.json +1 -1
  21. package/packages/codex-adapter/package.json +1 -1
  22. package/packages/codex-watcher/package.json +1 -1
  23. package/packages/config-capture/package.json +1 -1
  24. package/packages/connection-runtime/package.json +1 -1
  25. package/packages/context-compiler/package.json +1 -1
  26. package/packages/context-discovery/package.json +1 -1
  27. package/packages/context-manager/package.json +1 -1
  28. package/packages/contracts/dist/workspace-env-format.d.ts +87 -0
  29. package/packages/contracts/dist/workspace-env-format.js +174 -6
  30. package/packages/contracts/dist/workspace-env.js +120 -9
  31. package/packages/contracts/package.json +1 -1
  32. package/packages/contracts/src/workspace-env-format.ts +209 -6
  33. package/packages/contracts/src/workspace-env.ts +128 -7
  34. package/packages/contracts/test/workspace-env.test.ts +177 -0
  35. package/packages/cookie-sanitizer/package.json +1 -1
  36. package/packages/cost-optimizer/package.json +1 -1
  37. package/packages/dependency-analyzer/package.json +1 -1
  38. package/packages/django-plugin/package.json +1 -1
  39. package/packages/doc-generator/package.json +1 -1
  40. package/packages/durable-review/package.json +1 -1
  41. package/packages/e2e-tester/package.json +1 -1
  42. package/packages/evaluation-engine/package.json +1 -1
  43. package/packages/evaluation-runner/package.json +1 -1
  44. package/packages/event-bus/package.json +1 -1
  45. package/packages/event-sink/package.json +1 -1
  46. package/packages/event-store/package.json +1 -1
  47. package/packages/experience-replay/package.json +1 -1
  48. package/packages/feedback-collector/package.json +1 -1
  49. package/packages/file-copy-helper/package.json +1 -1
  50. package/packages/framework-detector/package.json +1 -1
  51. package/packages/framework-plugin-sdk/package.json +1 -1
  52. package/packages/frappe-plugin/package.json +1 -1
  53. package/packages/generic-web-plugin/package.json +1 -1
  54. package/packages/health-monitor/package.json +1 -1
  55. package/packages/intent-analyzer/package.json +1 -1
  56. package/packages/jev-judge/dist/continuation-controller.d.ts +47 -0
  57. package/packages/jev-judge/dist/continuation-controller.d.ts.map +1 -1
  58. package/packages/jev-judge/dist/continuation-controller.js +142 -16
  59. package/packages/jev-judge/dist/continuation-controller.js.map +1 -1
  60. package/packages/jev-judge/dist/index.d.ts +6 -0
  61. package/packages/jev-judge/dist/index.d.ts.map +1 -1
  62. package/packages/jev-judge/dist/index.js +6 -0
  63. package/packages/jev-judge/dist/index.js.map +1 -1
  64. package/packages/jev-judge/dist/validation-ledger.d.ts +129 -0
  65. package/packages/jev-judge/dist/validation-ledger.d.ts.map +1 -0
  66. package/packages/jev-judge/dist/validation-ledger.js +234 -0
  67. package/packages/jev-judge/dist/validation-ledger.js.map +1 -0
  68. package/packages/jev-judge/dist/validation-plan.d.ts +176 -0
  69. package/packages/jev-judge/dist/validation-plan.d.ts.map +1 -0
  70. package/packages/jev-judge/dist/validation-plan.js +293 -0
  71. package/packages/jev-judge/dist/validation-plan.js.map +1 -0
  72. package/packages/jev-judge/dist/validation-tie-breaker.d.ts +48 -0
  73. package/packages/jev-judge/dist/validation-tie-breaker.d.ts.map +1 -0
  74. package/packages/jev-judge/dist/validation-tie-breaker.js +83 -0
  75. package/packages/jev-judge/dist/validation-tie-breaker.js.map +1 -0
  76. package/packages/jev-judge/package.json +1 -1
  77. package/packages/jev-judge/src/continuation-controller.ts +179 -20
  78. package/packages/jev-judge/src/index.ts +41 -0
  79. package/packages/jev-judge/src/validation-ledger.ts +290 -0
  80. package/packages/jev-judge/src/validation-plan.ts +406 -0
  81. package/packages/jev-judge/src/validation-tie-breaker.ts +123 -0
  82. package/packages/jev-judge/tests/continuation-controller.test.ts +167 -0
  83. package/packages/jev-judge/tests/validation-ledger.test.ts +181 -0
  84. package/packages/jev-judge/tests/validation-plan.test.ts +170 -0
  85. package/packages/jev-judge/tests/validation-tie-breaker.test.ts +88 -0
  86. package/packages/knowledge-graph/package.json +1 -1
  87. package/packages/knowledge-retrieval/package.json +1 -1
  88. package/packages/laravel-plugin/package.json +1 -1
  89. package/packages/learning-engine/package.json +1 -1
  90. package/packages/logging/README.md +37 -0
  91. package/packages/logging/dist/buffer.d.ts +33 -0
  92. package/packages/logging/dist/buffer.d.ts.map +1 -0
  93. package/packages/logging/dist/buffer.js +60 -0
  94. package/packages/logging/dist/buffer.js.map +1 -0
  95. package/packages/logging/dist/config.d.ts +40 -0
  96. package/packages/logging/dist/config.d.ts.map +1 -0
  97. package/packages/logging/dist/config.js +114 -0
  98. package/packages/logging/dist/config.js.map +1 -0
  99. package/packages/logging/dist/index.d.ts +32 -0
  100. package/packages/logging/dist/index.d.ts.map +1 -0
  101. package/packages/logging/dist/index.js +31 -0
  102. package/packages/logging/dist/index.js.map +1 -0
  103. package/packages/logging/dist/redact.d.ts +43 -0
  104. package/packages/logging/dist/redact.d.ts.map +1 -0
  105. package/packages/logging/dist/redact.js +220 -0
  106. package/packages/logging/dist/redact.js.map +1 -0
  107. package/packages/logging/dist/sampler.d.ts +37 -0
  108. package/packages/logging/dist/sampler.d.ts.map +1 -0
  109. package/packages/logging/dist/sampler.js +78 -0
  110. package/packages/logging/dist/sampler.js.map +1 -0
  111. package/packages/logging/dist/shipper.d.ts +82 -0
  112. package/packages/logging/dist/shipper.d.ts.map +1 -0
  113. package/packages/logging/dist/shipper.js +238 -0
  114. package/packages/logging/dist/shipper.js.map +1 -0
  115. package/packages/logging/dist/singleton.d.ts +38 -0
  116. package/packages/logging/dist/singleton.d.ts.map +1 -0
  117. package/packages/logging/dist/singleton.js +95 -0
  118. package/packages/logging/dist/singleton.js.map +1 -0
  119. package/packages/logging/dist/spool.d.ts +39 -0
  120. package/packages/logging/dist/spool.d.ts.map +1 -0
  121. package/packages/logging/dist/spool.js +110 -0
  122. package/packages/logging/dist/spool.js.map +1 -0
  123. package/packages/logging/dist/transports/r2.d.ts +31 -0
  124. package/packages/logging/dist/transports/r2.d.ts.map +1 -0
  125. package/packages/logging/dist/transports/r2.js +60 -0
  126. package/packages/logging/dist/transports/r2.js.map +1 -0
  127. package/packages/logging/dist/transports/webhook.d.ts +22 -0
  128. package/packages/logging/dist/transports/webhook.d.ts.map +1 -0
  129. package/packages/logging/dist/transports/webhook.js +37 -0
  130. package/packages/logging/dist/transports/webhook.js.map +1 -0
  131. package/packages/logging/dist/types.d.ts +73 -0
  132. package/packages/logging/dist/types.d.ts.map +1 -0
  133. package/packages/logging/dist/types.js +19 -0
  134. package/packages/logging/dist/types.js.map +1 -0
  135. package/packages/logging/package.json +37 -0
  136. package/packages/logging/src/buffer.ts +77 -0
  137. package/packages/logging/src/config.ts +163 -0
  138. package/packages/logging/src/index.ts +87 -0
  139. package/packages/logging/src/redact.ts +271 -0
  140. package/packages/logging/src/sampler.ts +97 -0
  141. package/packages/logging/src/shipper.ts +281 -0
  142. package/packages/logging/src/singleton.ts +142 -0
  143. package/packages/logging/src/spool.ts +139 -0
  144. package/packages/logging/src/transports/r2.ts +86 -0
  145. package/packages/logging/src/transports/webhook.ts +54 -0
  146. package/packages/logging/src/types.ts +85 -0
  147. package/packages/logging/test/buffer.test.ts +44 -0
  148. package/packages/logging/test/config.test.ts +97 -0
  149. package/packages/logging/test/integration.test.ts +135 -0
  150. package/packages/logging/test/redact.test.ts +109 -0
  151. package/packages/logging/test/sampler.test.ts +57 -0
  152. package/packages/logging/test/shipper.test.ts +152 -0
  153. package/packages/logging/test/spool.test.ts +93 -0
  154. package/packages/mcp-adapter/package.json +1 -1
  155. package/packages/memory-engine/package.json +1 -1
  156. package/packages/milestone-manager/package.json +1 -1
  157. package/packages/model-registry/package.json +1 -1
  158. package/packages/nextjs-plugin/package.json +1 -1
  159. package/packages/notification/package.json +1 -1
  160. package/packages/observability/package.json +1 -1
  161. package/packages/okf-indexer/package.json +1 -1
  162. package/packages/paperclip-integration/package.json +1 -1
  163. package/packages/performance-optimizer/package.json +1 -1
  164. package/packages/permission-mode/package.json +1 -1
  165. package/packages/privilege-broker/package.json +1 -1
  166. package/packages/project-analyzer/package.json +1 -1
  167. package/packages/project-bootstrap/package.json +1 -1
  168. package/packages/projection-engine/package.json +1 -1
  169. package/packages/prompt-compiler/package.json +1 -1
  170. package/packages/prompt-versioning/package.json +1 -1
  171. package/packages/provider-adapter-sdk/package.json +1 -1
  172. package/packages/provider-router/package.json +1 -1
  173. package/packages/provider-selector/package.json +1 -1
  174. package/packages/providers/package.json +1 -1
  175. package/packages/qdrant-skills/package.json +1 -1
  176. package/packages/quota-manager/package.json +1 -1
  177. package/packages/rate-limiter/package.json +1 -1
  178. package/packages/react-vite-plugin/package.json +1 -1
  179. package/packages/relay/package.json +1 -1
  180. package/packages/release-manager/package.json +1 -1
  181. package/packages/requirement-compiler/package.json +1 -1
  182. package/packages/runtime/package.json +1 -1
  183. package/packages/scheduler/package.json +1 -1
  184. package/packages/scheduler-adapter/package.json +1 -1
  185. package/packages/session/package.json +1 -1
  186. package/packages/session-api/package.json +1 -1
  187. package/packages/session-export/package.json +1 -1
  188. package/packages/shared-context/package.json +1 -1
  189. package/packages/skill-mcp-client/package.json +1 -1
  190. package/packages/skill-registry/package.json +1 -1
  191. package/packages/skills/package.json +1 -1
  192. package/packages/sprint-planner/package.json +1 -1
  193. package/packages/subscription-engine/package.json +1 -1
  194. package/packages/task-compiler/package.json +1 -1
  195. package/packages/telegram/package.json +1 -1
  196. package/packages/tencentdb-memory/package.json +1 -1
  197. package/packages/tencentdb-sync/package.json +1 -1
  198. package/packages/test-data-generator/package.json +1 -1
  199. package/packages/test-generator/package.json +1 -1
  200. package/packages/todo-bd-sync/package.json +1 -1
  201. package/packages/token-estimation/package.json +1 -1
  202. package/packages/token-optimizer/package.json +1 -1
  203. package/packages/trajectory/package.json +1 -1
  204. package/packages/tui/package.json +1 -1
  205. package/packages/types/package.json +1 -1
  206. package/packages/voice-api/package.json +1 -1
  207. package/packages/webmcp-tester/package.json +1 -1
  208. package/packages/wiki/package.json +1 -1
  209. package/packages/workflow-events/package.json +1 -1
  210. package/packages/workspace-scanner/package.json +1 -1
  211. package/packages/worktree/package.json +1 -1
  212. package/packages/write-review/package.json +1 -1
  213. package/scripts/unified-webhook-server.ts +102 -0
  214. package/scripts/validation-run.ts +191 -0
@@ -90,6 +90,43 @@ export function isLauncherCommand(cmdline: string): boolean {
90
90
  return /^(npm|bun|yarn|pnpm)\b.*\b(run\s+)?(dev|start)\b/.test(cmdline.trim());
91
91
  }
92
92
 
93
+ /**
94
+ * The RAM rule behind the "low memory" warning.
95
+ *
96
+ * The warning exists to avoid running a build AND a dev server in parallel,
97
+ * so it is about the headroom that is actually available - not about the size
98
+ * of the machine. A 15 GB laptop with 6.5 GB free can build; a 32 GB server
99
+ * with 3 GB free cannot.
100
+ *
101
+ * - Below 4 GB free: always low.
102
+ * - Below 16 GB total: a small machine needs real headroom (6 GB) before a
103
+ * parallel build and dev server are safe.
104
+ */
105
+ export function isLowRamWarning(totalGb: number | null, availableGb: number | null): boolean {
106
+ if (availableGb === null) return false;
107
+ if (availableGb < 4) return true;
108
+ if (totalGb === null) return false;
109
+ return totalGb < 16 && availableGb < 6;
110
+ }
111
+
112
+ /**
113
+ * The headroom a build needs before it is allowed in development mode.
114
+ */
115
+ export const MIN_BUILD_HEADROOM_GB = 2;
116
+
117
+ /**
118
+ * The reason the build permission is (not) given.
119
+ *
120
+ * The agent has to know WHY a build is not allowed: an unknown environment
121
+ * needs an explicit request, a memory shortage needs a lighter build.
122
+ */
123
+ export function buildAllowanceReason(env: WorkspaceEnvResult): string {
124
+ if (env.buildAllowed) return "yes";
125
+ if (env.mode === "unknown") return "no - requires explicit request";
126
+ if (env.lowRamWarning) return "no - low memory";
127
+ return "no - requires explicit request";
128
+ }
129
+
93
130
  export type ServerState = "running" | "not-detected" | "not-checked";
94
131
 
95
132
  /**
@@ -231,6 +268,51 @@ export function isEnvShapedKey(key: string): boolean {
231
268
  return /(^|_)(ENV|ENVIRONMENT|MODE|STAGE)$/i.test(key);
232
269
  }
233
270
 
271
+ /**
272
+ * The namespaces of the desktop session variables.
273
+ *
274
+ * A session variable names the desktop session ("GNOME_SHELL_SESSION_MODE",
275
+ * "XDG_SESSION_TYPE"), never the environment of a workspace. Some of them
276
+ * end in "_MODE" or "_ENV", so without this list they look like an
277
+ * environment selector and decide the mode of a workspace that has no
278
+ * environment at all.
279
+ */
280
+ const SESSION_VARIABLE_PREFIXES = [
281
+ "GNOME_",
282
+ "XDG_",
283
+ "DESKTOP_",
284
+ "DBUS_",
285
+ "GDM_",
286
+ "SDDM_",
287
+ "LIGHTDM_",
288
+ "GTK_",
289
+ "QT_",
290
+ "MATE_",
291
+ "KDE_",
292
+ "PLASMA_",
293
+ "UNITY_",
294
+ "XFCE_",
295
+ "CINNAMON_",
296
+ "WAYLAND_",
297
+ "MIR_",
298
+ "I3_",
299
+ "TERM_",
300
+ "SSH_",
301
+ "TMUX_",
302
+ ];
303
+
304
+ /**
305
+ * True for a key that belongs to the desktop session, not to the workspace.
306
+ *
307
+ * The rule is a namespace list, not a value check: the value of a session
308
+ * variable ("ubuntu", "wayland", "user") says nothing about the environment
309
+ * of a workspace, so such a variable must never name the mode.
310
+ */
311
+ export function isSessionVariableKey(key: string): boolean {
312
+ const upper = key.toUpperCase();
313
+ return SESSION_VARIABLE_PREFIXES.some((prefix) => upper.startsWith(prefix));
314
+ }
315
+
234
316
  /**
235
317
  * True for a key that may hold a secret.
236
318
  */
@@ -282,6 +364,9 @@ export function detectModeHint(
282
364
  ): DotenvVar | null {
283
365
  const nodeEnv = vars.find((v) => v.key === "NODE_ENV");
284
366
  if (nodeEnv) return nodeEnv;
367
+ // A session variable is not an environment selector: it must never
368
+ // become the hint that names the mode.
369
+ const usableVars = vars.filter((v) => !isSessionVariableKey(v.key));
285
370
  // The value match is opt-in: a caller that passes the process env
286
371
  // variables must not let an unrelated variable that happens to hold the
287
372
  // word "development" decide the mode.
@@ -289,7 +374,7 @@ export function detectModeHint(
289
374
  // An environment shaped key whose value names a known mode wins: an
290
375
  // earlier "APP_MODE=staging" or an empty "APP_ENV=" of the same file
291
376
  // must not shadow the "AMOS_ENV=development" of the same file.
292
- const usable = vars.find(
377
+ const usable = usableVars.find(
293
378
  (v) =>
294
379
  v.key !== "NODE_ENV" &&
295
380
  isEnvShapedKey(v.key) &&
@@ -299,7 +384,9 @@ export function detectModeHint(
299
384
  }
300
385
  // No usable key: fall back to the first environment shaped key so that
301
386
  // the label can name it (it names no known mode).
302
- return vars.find((v) => v.key !== "NODE_ENV" && isEnvShapedKey(v.key)) ?? null;
387
+ return (
388
+ usableVars.find((v) => v.key !== "NODE_ENV" && isEnvShapedKey(v.key)) ?? null
389
+ );
303
390
  }
304
391
 
305
392
  /**
@@ -363,6 +450,9 @@ export function collectProcessEnvVars(env: NodeJS.ProcessEnv = process.env): Dot
363
450
  for (const key of Object.keys(env)) {
364
451
  const value = env[key];
365
452
  if (value === undefined) continue;
453
+ // A session variable of the desktop is never an environment
454
+ // selector, so it is not even collected as a candidate.
455
+ if (isSessionVariableKey(key)) continue;
366
456
  if (key === "NODE_ENV" || isEnvShapedKey(key)) {
367
457
  vars.push({ key, value });
368
458
  }
@@ -379,6 +469,98 @@ export interface ModeResolution {
379
469
  source: "process.env" | ".env file" | null;
380
470
  }
381
471
 
472
+ /**
473
+ * The deployment configuration of a repository.
474
+ *
475
+ * A repository that carries one of these files is deployed somewhere: it
476
+ * has a runtime environment of its own, so the shape of the repository
477
+ * cannot prove that the workspace is a development checkout.
478
+ */
479
+ export const DEPLOYMENT_MARKER_FILES = [
480
+ "Dockerfile",
481
+ "Containerfile",
482
+ "docker-compose.yml",
483
+ "docker-compose.yaml",
484
+ "compose.yml",
485
+ "compose.yaml",
486
+ "Procfile",
487
+ "fly.toml",
488
+ "render.yaml",
489
+ "railway.json",
490
+ "vercel.json",
491
+ "netlify.toml",
492
+ "serverless.yml",
493
+ "serverless.yaml",
494
+ "app.yaml",
495
+ "ecosystem.config.cjs",
496
+ "ecosystem.config.js",
497
+ ] as const;
498
+
499
+ /**
500
+ * The facts about the repository the workspace belongs to.
501
+ *
502
+ * They are read from the file system (and from git), never from a variable:
503
+ * a published package that is deployed nowhere is source code that the
504
+ * user runs on their own machine.
505
+ */
506
+ export interface RepositoryFacts {
507
+ /** The workspace is a git checkout. */
508
+ isGitRepository: boolean;
509
+ /** The current branch ("" when it cannot be read). */
510
+ branch: string;
511
+ /** The package.json describes a package that is published to a registry. */
512
+ isPublishedPackage: boolean;
513
+ /** The repository carries a deployment configuration. */
514
+ hasDeploymentMarker: boolean;
515
+ /** A dev or build server runs in this workspace. */
516
+ hasRunningServer: boolean;
517
+ }
518
+
519
+ /**
520
+ * A mode that the facts about the repository named.
521
+ */
522
+ export interface RepositoryModeDetection {
523
+ mode: WorkspaceMode;
524
+ /**
525
+ * The phrase that names the detection.
526
+ *
527
+ * It is used as the key of the evidence ("source-only repository",
528
+ * "git branch develop"), so the label can name it the same way it
529
+ * names a variable.
530
+ */
531
+ key: string;
532
+ }
533
+
534
+ /**
535
+ * The branches that name a development line of work.
536
+ */
537
+ const DEVELOPMENT_BRANCHES = new Set(["develop", "development"]);
538
+
539
+ /**
540
+ * Name the mode of a workspace from the facts about its repository.
541
+ *
542
+ * This is the LAST tier of the mode detection: it runs only when no
543
+ * variable names the environment at all. It never claims production - a
544
+ * checkout of a production branch is not a production environment, and the
545
+ * deployment marker of the repository keeps the answer honest.
546
+ *
547
+ * Two sets of facts name a development workspace:
548
+ * 1. a published package without a deployment configuration and without a
549
+ * running server: source code that the user runs on their own machine
550
+ * 2. a branch that names a development line of work ("develop")
551
+ */
552
+ export function modeFromRepositoryFacts(facts: RepositoryFacts): RepositoryModeDetection | null {
553
+ if (!facts.isGitRepository) return null;
554
+ if (facts.isPublishedPackage && !facts.hasDeploymentMarker && !facts.hasRunningServer) {
555
+ return { mode: "development", key: "source-only repository" };
556
+ }
557
+ const branch = facts.branch.trim().toLowerCase();
558
+ if (DEVELOPMENT_BRANCHES.has(branch)) {
559
+ return { mode: "development", key: `git branch ${branch}` };
560
+ }
561
+ return null;
562
+ }
563
+
382
564
  /**
383
565
  * Resolve the workspace mode from the variables that were found.
384
566
  *
@@ -436,6 +618,17 @@ function findModeEvidence(env: WorkspaceEnvResult): EnvEvidence | undefined {
436
618
  return candidates.find((e) => modeFromValue(e.value) !== null) ?? candidates[0];
437
619
  }
438
620
 
621
+ /**
622
+ * The evidence entry that names the mode through the repository (if any).
623
+ *
624
+ * A detection is weaker than a variable: it is the shape of the checkout,
625
+ * not a declaration of the environment. It is therefore only used when no
626
+ * variable names the environment at all.
627
+ */
628
+ function findDetectionEvidence(env: WorkspaceEnvResult): EnvEvidence | undefined {
629
+ return env.evidence.find((e) => e.source === "detection");
630
+ }
631
+
439
632
  /**
440
633
  * Human readable mode label.
441
634
  *
@@ -451,9 +644,12 @@ export function describeWorkspaceMode(env: WorkspaceEnvResult): string {
451
644
  const hint = findModeEvidence(env);
452
645
  const where = hint?.file ? ` in ${hint.file}` : "";
453
646
  if (env.mode !== "unknown") {
454
- return hint
455
- ? `${env.mode.toUpperCase()} (from ${hint.key}${where})`
456
- : env.mode.toUpperCase();
647
+ if (hint) return `${env.mode.toUpperCase()} (from ${hint.key}${where})`;
648
+ // The mode came from the repository, not from a variable: the label
649
+ // names the reason ("DEVELOPMENT (source-only repository)").
650
+ const detection = findDetectionEvidence(env);
651
+ if (detection) return `${env.mode.toUpperCase()} (${detection.key})`;
652
+ return env.mode.toUpperCase();
457
653
  }
458
654
  if (hint) {
459
655
  // The stored mode and the evidence can disagree (a stale cache row):
@@ -470,6 +666,13 @@ export function describeWorkspaceMode(env: WorkspaceEnvResult): string {
470
666
  }
471
667
  return `UNKNOWN (${hint.key}=${hint.value} is not a known mode${where})`;
472
668
  }
669
+ // A stale cache row can carry a detection next to an unknown mode: the
670
+ // detection names a mode, so the mode is shown.
671
+ const detection = findDetectionEvidence(env);
672
+ if (detection) {
673
+ const named = modeFromValue(detection.value);
674
+ if (named) return `${named.toUpperCase()} (${detection.key})`;
675
+ }
473
676
  return "UNKNOWN (no NODE_ENV or environment variable found)";
474
677
  }
475
678
 
@@ -590,7 +793,7 @@ export function formatEnvForAgentResult(env: WorkspaceEnvResult): string {
590
793
  }
591
794
 
592
795
  message += `\n`;
593
- message += `Build allowed: ${env.buildAllowed ? "yes" : "no - requires explicit request"}\n`;
796
+ message += `Build allowed: ${buildAllowanceReason(env)}\n`;
594
797
 
595
798
  if (env.mode === "unknown") {
596
799
  message += `\nNOTE: Environment unknown - a dev/build script only means a server CAN be started, it does not establish the workspace mode. Explicitly ask the user before build.\n`;
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { DatabaseSync } from "node:sqlite";
16
16
  import { execSync } from "child_process";
17
- import { existsSync, readFileSync, mkdirSync, readlinkSync, realpathSync } from "fs";
17
+ import { existsSync, readFileSync, readdirSync, mkdirSync, readlinkSync, realpathSync } from "fs";
18
18
  import { join } from "path";
19
19
  import { platform, totalmem, freemem } from "os";
20
20
  import {
@@ -26,12 +26,16 @@ import {
26
26
  describeServerState,
27
27
  describeWorkspaceMode,
28
28
  detectModeHint,
29
+ DEPLOYMENT_MARKER_FILES,
29
30
  filterOwnPids,
30
31
  formatEnvForAgentResult,
31
32
  formatEnvStatusLine as formatStatusLine,
32
33
  formatWorkspacePath,
33
34
  isLauncherCommand,
35
+ isLowRamWarning,
34
36
  MASKED_MODE_VALUE,
37
+ MIN_BUILD_HEADROOM_GB,
38
+ modeFromRepositoryFacts,
35
39
  modeFromValue,
36
40
  orderDotenvFileNames,
37
41
  parseDotenvVars,
@@ -41,6 +45,7 @@ import { readDotenvFiles } from "./workspace-dotenv.js";
41
45
  import type {
42
46
  DotenvVar,
43
47
  EnvEvidence,
48
+ RepositoryFacts,
44
49
  ServerState,
45
50
  WorkspaceEnvResult,
46
51
  WorkspaceMode,
@@ -145,12 +150,11 @@ function detectRamInfo(): {
145
150
  const freeBytes = freemem();
146
151
  const totalGb = Math.round((totalBytes / (1024 ** 3)) * 10) / 10;
147
152
  const availableGb = Math.round((freeBytes / (1024 ** 3)) * 10) / 10;
148
- const LOW_RAM_GB = 16;
149
153
 
150
154
  return {
151
155
  total: totalGb,
152
156
  available: availableGb,
153
- lowWarning: totalGb < LOW_RAM_GB || availableGb < 4,
157
+ lowWarning: isLowRamWarning(totalGb, availableGb),
154
158
  };
155
159
  } catch {
156
160
  return { total: null, available: null, lowWarning: false };
@@ -565,6 +569,99 @@ function detectPackageManager(workspacePath: string): string | null {
565
569
  return null;
566
570
  }
567
571
 
572
+ /**
573
+ * The git repository of a workspace.
574
+ *
575
+ * The root of the repository is the unit of the detection: a package
576
+ * published from a monorepo is described by the root, not by the folder
577
+ * the harness happens to run in.
578
+ */
579
+ function detectGitFacts(workspacePath: string): { root: string; branch: string } {
580
+ try {
581
+ const out = execSync(
582
+ "git rev-parse --show-toplevel 2>/dev/null; git rev-parse --abbrev-ref HEAD 2>/dev/null",
583
+ { cwd: workspacePath, encoding: "utf8", timeout: 1500 },
584
+ );
585
+ const lines = out.split("\n").map((line) => line.trim());
586
+ const root = lines[0] ?? "";
587
+ const branch = lines[1] ?? "";
588
+ // A detached HEAD reports "HEAD" - that is not a branch name.
589
+ return { root, branch: branch === "HEAD" ? "" : branch };
590
+ } catch {
591
+ // No git, no repository, or a timeout: nothing is known.
592
+ return { root: "", branch: "" };
593
+ }
594
+ }
595
+
596
+ /**
597
+ * True when the package is published to a registry.
598
+ *
599
+ * A package that is published names a version and declares what it ships
600
+ * ("files", "bin" or a "publishConfig"). A repository that only holds an
601
+ * application usually declares none of them - so this stays a careful
602
+ * signal instead of "every package.json is published".
603
+ */
604
+ function isPublishedPackage(packageDir: string): boolean {
605
+ const pkgJsonPath = join(packageDir, "package.json");
606
+ if (!existsSync(pkgJsonPath)) return false;
607
+ try {
608
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
609
+ if (!pkg || typeof pkg !== "object") return false;
610
+ if (pkg.private === true) return false;
611
+ if (typeof pkg.name !== "string" || !pkg.name) return false;
612
+ if (typeof pkg.version !== "string" || !pkg.version) return false;
613
+ return (
614
+ Array.isArray(pkg.files) ||
615
+ (typeof pkg.bin === "object" && pkg.bin !== null) ||
616
+ (typeof pkg.publishConfig === "object" && pkg.publishConfig !== null)
617
+ );
618
+ } catch {
619
+ return false;
620
+ }
621
+ }
622
+
623
+ /**
624
+ * True when the repository carries a deployment configuration.
625
+ *
626
+ * A deployment file or a deploy workflow means the repository has a
627
+ * runtime environment of its own - the shape of the repository can then no
628
+ * longer say that the workspace is a development checkout.
629
+ */
630
+ function hasDeploymentConfiguration(repoRoot: string): boolean {
631
+ for (const marker of DEPLOYMENT_MARKER_FILES) {
632
+ if (existsSync(join(repoRoot, marker))) return true;
633
+ }
634
+ const workflowDir = join(repoRoot, ".github", "workflows");
635
+ if (existsSync(workflowDir)) {
636
+ try {
637
+ for (const name of readdirSync(workflowDir)) {
638
+ if (/deploy/i.test(name)) return true;
639
+ }
640
+ } catch {
641
+ // An unreadable directory proves nothing.
642
+ }
643
+ }
644
+ return false;
645
+ }
646
+
647
+ /**
648
+ * Read the facts about the repository the workspace belongs to.
649
+ */
650
+ function detectRepositoryFacts(
651
+ workspacePath: string,
652
+ hasRunningServer: boolean,
653
+ ): RepositoryFacts {
654
+ const { root, branch } = detectGitFacts(workspacePath);
655
+ const repoRoot = root || workspacePath;
656
+ return {
657
+ isGitRepository: root !== "",
658
+ branch,
659
+ isPublishedPackage: isPublishedPackage(repoRoot),
660
+ hasDeploymentMarker: hasDeploymentConfiguration(repoRoot),
661
+ hasRunningServer,
662
+ };
663
+ }
664
+
568
665
  /**
569
666
  * Determine workspace mode with proper precedence
570
667
  * Never assume production - unknown requires investigation
@@ -576,11 +673,15 @@ function detectPackageManager(workspacePath: string): string | null {
576
673
  * key whose value names a known mode, then any other environment shaped
577
674
  * key
578
675
  * 3. an environment shaped key of the process env (the files are silent)
676
+ * 4. the facts about the repository (only when NO variable names the
677
+ * environment at all) - a published source repository without a runtime
678
+ * of its own is a development checkout
579
679
  */
580
680
  function determineMode(
581
681
  processVars: DotenvVar[],
582
682
  dotenvVars: DotenvVar[],
583
- evidence: EnvEvidence[]
683
+ evidence: EnvEvidence[],
684
+ repositoryFacts: RepositoryFacts,
584
685
  ): WorkspaceMode {
585
686
  const resolved = resolveWorkspaceMode(processVars, dotenvVars);
586
687
  if (resolved.hint && resolved.source) {
@@ -599,8 +700,20 @@ function determineMode(
599
700
  timestamp: Date.now(),
600
701
  file: resolved.hint.file,
601
702
  });
703
+ // An explicit variable decides - even when it names no known mode
704
+ // ("staging"). The mode stays unknown and the label names the value.
705
+ return resolved.mode;
602
706
  }
603
- return resolved.mode;
707
+ // No variable names the environment: the repository may still name it.
708
+ const detection = modeFromRepositoryFacts(repositoryFacts);
709
+ if (!detection) return resolved.mode;
710
+ evidence.push({
711
+ source: "detection",
712
+ key: detection.key,
713
+ value: detection.mode,
714
+ timestamp: Date.now(),
715
+ });
716
+ return detection.mode;
604
717
  }
605
718
 
606
719
  /**
@@ -655,14 +768,22 @@ export function detectWorkspaceEnv(workspacePath: string): WorkspaceEnvResult {
655
768
  detectServerStates(workspacePath);
656
769
 
657
770
  // Determine mode - NEVER assume production
658
- const mode = determineMode(processVars, dotenvVars, evidence);
771
+ const repositoryFacts = detectRepositoryFacts(
772
+ workspacePath,
773
+ devServerState === "running" || buildServerState === "running",
774
+ );
775
+ const mode = determineMode(processVars, dotenvVars, evidence, repositoryFacts);
659
776
 
660
777
  // Determine build permission
661
778
  let buildAllowed = false;
662
779
  if (mode === "production") {
663
780
  buildAllowed = true; // Production build is allowed
664
781
  } else if (mode === "development") {
665
- buildAllowed = !ramInfo.lowWarning; // Allow if not low RAM
782
+ // A build needs headroom, not a big machine. The low-memory warning is
783
+ // about running a build AND a dev server in parallel; a build on its
784
+ // own only needs enough free memory to finish.
785
+ buildAllowed =
786
+ ramInfo.available === null ? false : ramInfo.available >= MIN_BUILD_HEADROOM_GB;
666
787
  } else {
667
788
  buildAllowed = false; // Unknown requires investigation
668
789
  }
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "n
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
6
+ buildAllowanceReason,
6
7
  canShowModeValue,
7
8
  collectDotenvVars,
8
9
  collectProcessEnvVars,
@@ -16,7 +17,10 @@ import {
16
17
  formatEnvStatusLine,
17
18
  formatWorkspacePath,
18
19
  isLauncherCommand,
20
+ isLowRamWarning,
21
+ isSessionVariableKey,
19
22
  MASKED_MODE_VALUE,
23
+ modeFromRepositoryFacts,
20
24
  modeFromValue,
21
25
  orderDotenvFileNames,
22
26
  parseDotenvVars,
@@ -623,3 +627,176 @@ describe("the template files of a workspace", () => {
623
627
  expect(isTemplateDotenvName(".env.production")).toBe(false);
624
628
  });
625
629
  });
630
+
631
+ describe("the session variables of a desktop", () => {
632
+ test("a session variable is not an environment selector", () => {
633
+ expect(isSessionVariableKey("GNOME_SHELL_SESSION_MODE")).toBe(true);
634
+ expect(isSessionVariableKey("XDG_SESSION_TYPE")).toBe(true);
635
+ expect(isSessionVariableKey("DESKTOP_SESSION")).toBe(true);
636
+ expect(isSessionVariableKey("DBUS_SESSION_BUS_ADDRESS")).toBe(true);
637
+ });
638
+
639
+ test("a real environment selector is not a session variable", () => {
640
+ expect(isSessionVariableKey("NODE_ENV")).toBe(false);
641
+ expect(isSessionVariableKey("AMOS_ENV")).toBe(false);
642
+ expect(isSessionVariableKey("APP_MODE")).toBe(false);
643
+ expect(isSessionVariableKey("RAILS_ENV")).toBe(false);
644
+ });
645
+
646
+ test("a session variable never decides the mode of a workspace", () => {
647
+ // The scenario of the user: GNOME_SHELL_SESSION_MODE=ubuntu is set in
648
+ // every GNOME shell and must not make a source repository unknown.
649
+ const processVars = collectProcessEnvVars({
650
+ GNOME_SHELL_SESSION_MODE: "ubuntu",
651
+ });
652
+
653
+ expect(processVars).toEqual([]);
654
+ expect(resolveWorkspaceMode(processVars, []).mode).toBe("unknown");
655
+ expect(resolveWorkspaceMode(processVars, []).hint).toBeNull();
656
+ });
657
+
658
+ test("a session variable in a dotenv file does not decide the mode either", () => {
659
+ const vars = collectDotenvVars([
660
+ { name: ".env", content: "XDG_SESSION_TYPE=x11\n" },
661
+ ]);
662
+
663
+ expect(resolveWorkspaceMode([], vars).hint).toBeNull();
664
+ });
665
+
666
+ test("a session variable does not shadow a real environment selector", () => {
667
+ const vars = collectDotenvVars([
668
+ { name: ".env", content: "GNOME_SHELL_SESSION_MODE=ubuntu\nAMOS_ENV=production\n" },
669
+ ]);
670
+ const resolved = resolveWorkspaceMode([], vars);
671
+
672
+ expect(resolved.mode).toBe("production");
673
+ expect(resolved.hint?.key).toBe("AMOS_ENV");
674
+ });
675
+ });
676
+
677
+ describe("the mode detection of a source repository", () => {
678
+ const published = {
679
+ isGitRepository: true,
680
+ branch: "main",
681
+ isPublishedPackage: true,
682
+ hasDeploymentMarker: false,
683
+ hasRunningServer: false,
684
+ };
685
+
686
+ test("a published package without a runtime is a development checkout", () => {
687
+ expect(modeFromRepositoryFacts(published)).toEqual({
688
+ mode: "development",
689
+ key: "source-only repository",
690
+ });
691
+ });
692
+
693
+ test("a deployment configuration keeps the mode unknown", () => {
694
+ const detection = modeFromRepositoryFacts({
695
+ ...published,
696
+ hasDeploymentMarker: true,
697
+ });
698
+
699
+ expect(detection).toBeNull();
700
+ });
701
+
702
+ test("a development branch is the fallback", () => {
703
+ expect(
704
+ modeFromRepositoryFacts({
705
+ ...published,
706
+ branch: "develop",
707
+ isPublishedPackage: false,
708
+ }),
709
+ ).toEqual({ mode: "development", key: "git branch develop" });
710
+ expect(
711
+ modeFromRepositoryFacts({
712
+ ...published,
713
+ branch: "development",
714
+ isPublishedPackage: false,
715
+ }),
716
+ ).toEqual({ mode: "development", key: "git branch development" });
717
+ });
718
+
719
+ test("a production branch is never a development checkout", () => {
720
+ expect(
721
+ modeFromRepositoryFacts({
722
+ ...published,
723
+ branch: "main",
724
+ isPublishedPackage: false,
725
+ }),
726
+ ).toBeNull();
727
+ expect(
728
+ modeFromRepositoryFacts({
729
+ ...published,
730
+ branch: "production",
731
+ isPublishedPackage: false,
732
+ }),
733
+ ).toBeNull();
734
+ });
735
+
736
+ test("a directory that is not a repository names no mode", () => {
737
+ expect(
738
+ modeFromRepositoryFacts({
739
+ ...published,
740
+ isGitRepository: false,
741
+ }),
742
+ ).toBeNull();
743
+ });
744
+
745
+ test("the label names the reason of the detection", () => {
746
+ const env = makeEnv({
747
+ mode: "development",
748
+ buildAllowed: true,
749
+ evidence: [
750
+ { source: "detection", key: "source-only repository", value: "development", timestamp: 0 },
751
+ ],
752
+ });
753
+
754
+ expect(describeWorkspaceMode(env)).toBe("DEVELOPMENT (source-only repository)");
755
+ });
756
+ });
757
+
758
+ describe("the low memory rule", () => {
759
+ test("flags a machine with almost no free memory", () => {
760
+ expect(isLowRamWarning(64, 2)).toBe(true);
761
+ expect(isLowRamWarning(16, 3.9)).toBe(true);
762
+ });
763
+
764
+ test("does not flag a small machine with real headroom", () => {
765
+ expect(isLowRamWarning(15, 6.6)).toBe(false);
766
+ expect(isLowRamWarning(8, 8)).toBe(false);
767
+ });
768
+
769
+ test("flags a big machine that ran out of memory", () => {
770
+ expect(isLowRamWarning(64, 3)).toBe(true);
771
+ });
772
+
773
+ test("does not flag an unknown memory state", () => {
774
+ expect(isLowRamWarning(null, null)).toBe(false);
775
+ expect(isLowRamWarning(null, 12)).toBe(false);
776
+ });
777
+ });
778
+
779
+ describe("the build permission", () => {
780
+ test("allows a build when the environment allows it", () => {
781
+ expect(buildAllowanceReason(makeEnv({ mode: "development", buildAllowed: true }))).toBe("yes");
782
+ });
783
+
784
+ test("asks for an explicit request when the mode is unknown", () => {
785
+ expect(
786
+ buildAllowanceReason(makeEnv({ mode: "unknown", buildAllowed: false, lowRamWarning: false })),
787
+ ).toBe("no - requires explicit request");
788
+ });
789
+
790
+ test("names the memory shortage when the memory is the reason", () => {
791
+ expect(
792
+ buildAllowanceReason(makeEnv({ mode: "development", buildAllowed: false, lowRamWarning: true })),
793
+ ).toBe("no - low memory");
794
+ });
795
+
796
+ test("appears in the agent context", () => {
797
+ const message = formatEnvForAgentResult(
798
+ makeEnv({ mode: "development", buildAllowed: false, lowRamWarning: true }),
799
+ );
800
+ expect(message).toContain("Build allowed: no - low memory");
801
+ });
802
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/cookie-sanitizer",
3
- "version": "1.1.394",
3
+ "version": "1.1.396",
4
4
  "description": "pi-harness-runtime — cookie sanitizer (provider-quota cookie file normaliser)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",