pi-crew 0.11.24 → 0.11.26

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 +72 -0
  2. package/README.md +2 -2
  3. package/agents/designer.md +23 -0
  4. package/agents/orchestrator.md +17 -0
  5. package/agents/verifier.md +1 -1
  6. package/dist/index.mjs +568 -559
  7. package/{workflows/chain.workflow.md → docs/chain-workflow.md} +8 -0
  8. package/docs/dynamic-workflows.md +23 -0
  9. package/docs/publishing.md +34 -5
  10. package/docs/troubleshooting.md +50 -2
  11. package/docs/usage.md +7 -2
  12. package/install.mjs +2 -1
  13. package/package.json +4 -2
  14. package/skills/read-only-explorer/SKILL.md +15 -5
  15. package/skills/real-test-pi-crew/SKILL.md +7 -7
  16. package/src/config/config-validation.ts +58 -0
  17. package/src/config/config.ts +90 -5
  18. package/src/config/env-vars.ts +14 -7
  19. package/src/config/role-tools.ts +7 -5
  20. package/src/config/types.ts +4 -1
  21. package/src/extension/crew-cleanup.ts +6 -0
  22. package/src/extension/project-init.ts +19 -1
  23. package/src/extension/register.ts +68 -30
  24. package/src/extension/registration/artifact-cleanup.ts +52 -11
  25. package/src/extension/registration/commands/manage.ts +9 -2
  26. package/src/extension/registration/hook-registration.ts +3 -0
  27. package/src/extension/registration/lazy-configurers.ts +2 -0
  28. package/src/extension/registration/lifecycle-handlers.ts +79 -18
  29. package/src/extension/registration/render-loop.ts +27 -2
  30. package/src/extension/registration/tool-loop-guard.ts +5 -3
  31. package/src/extension/registration/verify-gate-tool.ts +7 -3
  32. package/src/extension/team-tool/api/read.ts +12 -3
  33. package/src/extension/team-tool/cancel.ts +11 -0
  34. package/src/extension/team-tool/config-patch.ts +56 -11
  35. package/src/extension/team-tool/destructive-gate.ts +72 -12
  36. package/src/extension/team-tool/dispatch/manage.ts +9 -1
  37. package/src/extension/team-tool/handle-schedule.ts +38 -4
  38. package/src/extension/team-tool/handle-settings.ts +76 -6
  39. package/src/extension/team-tool/health-monitor.ts +14 -2
  40. package/src/extension/team-tool/lifecycle-actions.ts +28 -10
  41. package/src/extension/team-tool/respond.ts +12 -5
  42. package/src/extension/team-tool/run-deadline.ts +29 -2
  43. package/src/extension/team-tool/run.ts +11 -0
  44. package/src/extension/team-tool/status.ts +64 -8
  45. package/src/extension/team-tool/workflow-manage.ts +5 -1
  46. package/src/extension/team-tool.ts +3 -0
  47. package/src/i18n.ts +18 -14
  48. package/src/prompt/inbox-poll.ts +14 -2
  49. package/src/prompt/message-tool.ts +1 -1
  50. package/src/prompt/prompt-runtime.ts +25 -3
  51. package/src/prompt/scratchpad-lifecycle.ts +14 -13
  52. package/src/prompt/surface-worker.ts +35 -9
  53. package/src/runtime/broker/crew-broker.ts +113 -111
  54. package/src/runtime/broker/delegate/delegate-abort.ts +118 -0
  55. package/src/runtime/broker/delegate/delegate-event.ts +3 -1
  56. package/src/runtime/broker/delegate/delegate-finalize.ts +179 -0
  57. package/src/runtime/broker/protocol/wait-resolve.ts +71 -5
  58. package/src/runtime/budget-enforcement.ts +26 -6
  59. package/src/runtime/chain-parser.ts +5 -3
  60. package/src/runtime/chain-runner.ts +41 -22
  61. package/src/runtime/child-pi/mock-fixtures.ts +12 -5
  62. package/src/runtime/compaction/tool-output-pruner.ts +8 -3
  63. package/src/runtime/delegate-spawn.ts +61 -8
  64. package/src/runtime/dispatch-batch.ts +10 -4
  65. package/src/runtime/effectiveness.ts +31 -3
  66. package/src/runtime/goal-workflow/plan-templates.ts +2 -2
  67. package/src/runtime/live-session/live-agent-control.ts +89 -6
  68. package/src/runtime/live-session/live-agent-manager.ts +94 -10
  69. package/src/runtime/live-session/live-session-runtime.ts +9 -2
  70. package/src/runtime/model/model-fallback.ts +6 -9
  71. package/src/runtime/model/pi-args.ts +3 -3
  72. package/src/runtime/model/runtime-policy.ts +4 -2
  73. package/src/runtime/model/runtime-resolver.ts +6 -2
  74. package/src/runtime/process/orphan-reaper.ts +143 -0
  75. package/src/runtime/process-status.ts +38 -16
  76. package/src/runtime/resilient-edit.ts +170 -93
  77. package/src/runtime/role-permission.ts +3 -1
  78. package/src/runtime/run-tracker.ts +9 -1
  79. package/src/runtime/sanitize/worker-text.ts +98 -0
  80. package/src/runtime/scheduling/run-coalesced-task-group.ts +82 -28
  81. package/src/runtime/spawn-policy.ts +190 -8
  82. package/src/runtime/stale-reconciler.ts +36 -1
  83. package/src/runtime/subagent-manager.ts +29 -0
  84. package/src/runtime/surface/herdr-provider.ts +31 -1
  85. package/src/runtime/task-output-context.ts +58 -11
  86. package/src/runtime/task-runner/post-execution.ts +150 -0
  87. package/src/runtime/task-runner/prompt-builder.ts +41 -8
  88. package/src/runtime/task-runner.ts +16 -3
  89. package/src/runtime/verification/classifier-pre-gate.ts +27 -6
  90. package/src/runtime/verification/verify-gate.ts +153 -30
  91. package/src/state/atomic-write.ts +73 -4
  92. package/src/state/contracts.ts +7 -0
  93. package/src/state/coordination/mailbox.ts +40 -11
  94. package/src/state/event-log/event-log-rotation.ts +28 -6
  95. package/src/state/event-log/poison-marker.ts +16 -1
  96. package/src/state/stores/artifact-store.ts +181 -6
  97. package/src/state/stores/manifest-io.ts +126 -8
  98. package/src/state/stores/state-store.ts +30 -31
  99. package/src/state/types.ts +13 -0
  100. package/src/state/usage.ts +41 -0
  101. package/src/ui/run-dashboard.ts +3 -3
  102. package/src/ui/settings-overlay.ts +19 -1
  103. package/src/utils/internal-error.ts +5 -1
  104. package/src/utils/timer-limits.ts +30 -0
  105. package/src/utils/token-counter.ts +47 -0
  106. package/src/workflows/discover-workflows.ts +9 -1
  107. package/src/worktree/worktree-manager.ts +128 -18
  108. package/workflows/default.workflow.md +1 -1
  109. package/workflows/fast-fix.workflow.md +1 -1
  110. package/workflows/plan-execute.workflow.md +1 -1
  111. package/workflows/review.workflow.md +1 -1
  112. package/workflows/strict-fast-fix.workflow.md +1 -1
@@ -15,6 +15,7 @@ import { agentOutputPath, readCrewAgentEventsCursor, readCrewAgentStatus, readCr
15
15
  import { readForegroundControlStatus, writeForegroundInterruptRequest } from "../../../runtime/foreground-control.ts";
16
16
  import { probeLiveSessionRuntime } from "../../../runtime/live-session/live-session-runtime.ts";
17
17
  import { resolveCrewRuntime } from "../../../runtime/model/runtime-resolver.ts";
18
+ import { fenceWorkerText } from "../../../runtime/sanitize/worker-text.ts";
18
19
  import { readEvents, readEventsCursor } from "../../../state/event-log/event-log.ts";
19
20
  import { globMatch } from "../../../utils/glob-match.ts";
20
21
  import { resolveRealContainedPath } from "../../../utils/safe-paths.ts";
@@ -201,7 +202,11 @@ export const handleGetAgentResult: ApiOperationHandler = (hctx) => {
201
202
  true,
202
203
  );
203
204
  const task = loaded.tasks.find((item) => item.id === agent.taskId);
204
- const text = safeReadContainedFile(loaded.manifest.artifactsRoot, task?.resultArtifact?.path) ?? JSON.stringify(agent, null, 2);
205
+ const artifactText = safeReadContainedFile(loaded.manifest.artifactsRoot, task?.resultArtifact?.path);
206
+ // R38-C1 (EP1 seed): result-artifact bytes are worker-authored — fence them
207
+ // before they enter the privileged session. The no-artifact fallback (agent
208
+ // record JSON) is runtime-owned and stays unfenced.
209
+ const text = artifactText !== undefined ? fenceWorkerText(artifactText, { source: "agent-result" }) : JSON.stringify(agent, null, 2);
205
210
  return result(text, {
206
211
  action: "api",
207
212
  status: "ok",
@@ -294,7 +299,9 @@ export const handleReadAgentTranscript: ApiOperationHandler = (hctx) => {
294
299
  const fallbackText = artifactText ? "" : (safeReadContainedFile(loaded.manifest.stateRoot, fallbackPath) ?? "");
295
300
  const transcriptPath = artifactText ? artifactTranscriptPath : fallbackPath;
296
301
  const text = artifactText || fallbackText;
297
- return result(text || `(no transcript at ${transcriptPath})`, {
302
+ // R38-C1 (EP1 seed): transcript/output.log bytes are worker-authored — fence
303
+ // them; the no-transcript fallback is runtime-owned and stays unfenced.
304
+ return result(text ? fenceWorkerText(text, { source: "agent-transcript" }) : `(no transcript at ${transcriptPath})`, {
298
305
  action: "api",
299
306
  status: "ok",
300
307
  runId: loaded.manifest.runId,
@@ -403,7 +410,9 @@ export const handleDiff: ApiOperationHandler = (hctx) => {
403
410
  const content = safeReadContainedFile(loaded.manifest.artifactsRoot, artifact.path);
404
411
  if (content) {
405
412
  const display = content.length > 4000 ? content.slice(0, 4000) + "\n... (truncated)" : content;
406
- parts.push(`\n--- ${artifact.path} ---\n${display}`);
413
+ // R38-C1 (EP1 seed): diff/patch bytes are worker-authored — fence them
414
+ // per artifact (per-op fencing; headers stay runtime-owned).
415
+ parts.push(`\n--- ${artifact.path} ---\n${fenceWorkerText(display, { source: "diff" })}`);
407
416
  }
408
417
  }
409
418
  return result(parts.join("\n"), {
@@ -1,4 +1,5 @@
1
1
  import { appendHookEvent, executeHook } from "../../hooks/registry.ts";
2
+ import { abortRunDelegates } from "../../runtime/broker/delegate/delegate-abort.ts";
2
3
  import { killProcessPid } from "../../runtime/child-pi/child-pi.ts";
3
4
  import { recordFromTask, saveCrewAgents } from "../../runtime/crew-agent-records.ts";
4
5
  import { writeForegroundInterruptRequest } from "../../runtime/foreground-control.ts";
@@ -340,6 +341,16 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
340
341
  // still signals synchronously here — while we hold the lock — so the async
341
342
  // background runner cannot overwrite the cancelled state we write below.
342
343
  if (fresh.manifest.status === "running") {
344
+ // M4-R1(c) (fix-wave 6 residual, M3-A / R43-N3): the run is going
345
+ // terminal — abort its in-flight DELEGATE GRANDCHILDREN too. They live
346
+ // in the broker's abort registry (a separate, process-local lifetime),
347
+ // so without this they outlive the cancel by up to their own 24h
348
+ // deadline while still spending tokens and holding nested slots. Exact
349
+ // wiring budget-enforcement.ts terminaliseRunWithDrain already uses;
350
+ // abortRunDelegates is SYNC + never throws (0 when no broker aborter is
351
+ // installed — headless/worker-side cancels keep the pre-existing
352
+ // degrade, never a new failure path).
353
+ abortRunDelegates(fresh.manifest.runId, "run-cancelled");
343
354
  void terminateLiveAgentsForRun(fresh.manifest.runId, "cancelled", appendEvent, fresh.manifest.eventsPath).catch((error) =>
344
355
  logInternalError("team-tool.handleCancel.terminateLiveAgents", error, `runId=${fresh.manifest.runId}`),
345
356
  );
@@ -1,17 +1,17 @@
1
1
  import { effectiveAutonomousConfig, type PiTeamsAutonomousConfig, type PiTeamsConfig, parseConfig } from "../../config/config.ts";
2
+ import { stripDangerousKeysDeep } from "../../config/config-validation.ts";
3
+ import { collectSensitiveConfigPaths } from "../../schema/sensitive-config-paths.ts";
2
4
 
3
- const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
4
-
5
- /** Recursively strip dangerous prototype-pollution keys from all levels of an object. */
5
+ /**
6
+ * Recursively strip dangerous prototype-pollution keys from all levels of an
7
+ * object. M1-1: delegates to the shared proto-safe helpers (config-validation.ts,
8
+ * case-insensitive
9
+ * DANGEROUS_OBJECT_KEYS superset) so every config-patch parse boundary applies
10
+ * ONE canonical drop rule — config patch, autonomy patch, and
11
+ * effectiveRunConfig all flow through here or through parseConfig's asRecord.
12
+ */
6
13
  export function sanitizeObject<T>(obj: T): T {
7
- if (obj === null || obj === undefined || typeof obj !== "object") return obj;
8
- if (Array.isArray(obj)) return obj.map((item) => sanitizeObject(item)) as T;
9
- const safe: Record<string, unknown> = {};
10
- for (const key of Object.keys(obj as Record<string, unknown>)) {
11
- if (DANGEROUS_KEYS.has(key)) continue;
12
- safe[key] = sanitizeObject((obj as Record<string, unknown>)[key]);
13
- }
14
- return safe as T;
14
+ return stripDangerousKeysDeep(obj);
15
15
  }
16
16
 
17
17
  export function autonomousPatchFromConfig(config: unknown): PiTeamsAutonomousConfig {
@@ -24,8 +24,53 @@ export function configPatchFromConfig(config: unknown): PiTeamsConfig {
24
24
  return parseConfig(config);
25
25
  }
26
26
 
27
+ /**
28
+ * M1-8 (R45-S2): drop the schema's sensitive-marked config paths from the
29
+ * RUN-PARAM tier. `params.config` is worker/LLM-influenced tool input — the
30
+ * same fields a project file may not set (user-only kill-switches and
31
+ * execution-shaping knobs: executeWorkers, runtime.mode/isolationPolicy,
32
+ * worktree.setupHook/seedPaths, agents.overrides, otlp.*, policy.*,
33
+ * autonomous.*, goalWrap, …) must not be settable through run params either.
34
+ *
35
+ * Drops mutate the FRESH parseConfig output only — never the caller's input
36
+ * and never `base` (the loaded user/project config, already project-tier
37
+ * sanitized at load). Sensitive keys are dropped SILENTLY: the drop is the
38
+ * contract, and the persisted `manifest.runConfig` records the effective
39
+ * (post-drop) config for audit. Precedence note (P4 pin): with
40
+ * `executeWorkers` dropped from the patch, a user's `executeWorkers:false`
41
+ * survives untouched — the kill-switch cannot be flipped back on via run
42
+ * params (runtime-resolver reads the merged effective config).
43
+ *
44
+ * Conditional carve-out (mirrors the project tier's CONDITIONAL_PROJECT_DROPS
45
+ * doctrine: loosening values dropped, tightening values survive): a run param
46
+ * may still TIGHTEN `runtime.mode` to "scaffold" — the documented params
47
+ * dry-run channel ("Set runtime.mode=scaffold … only for dry runs", run tool
48
+ * banner) spawns no workers and is the SAFE direction. Loosening modes
49
+ * (auto/child-process/live-session — the R45-S2 escalation threat) are
50
+ * dropped like every other sensitive value.
51
+ */
52
+ const RUNPARAM_CONDITIONAL_KEEP: Record<string, (value: unknown) => boolean> = {
53
+ "runtime.mode": (value) => value === "scaffold",
54
+ };
55
+
56
+ function dropSensitivePatchPaths(patch: Record<string, unknown>): void {
57
+ for (const dotted of collectSensitiveConfigPaths()) {
58
+ const parts = dotted.split(".");
59
+ let node: Record<string, unknown> | undefined = patch;
60
+ for (let i = 0; node !== undefined && i < parts.length - 1; i++) {
61
+ const next: unknown = node[parts[i]!];
62
+ node = next !== null && typeof next === "object" && !Array.isArray(next) ? (next as Record<string, unknown>) : undefined;
63
+ }
64
+ if (node === undefined) continue;
65
+ const leaf = parts[parts.length - 1]!;
66
+ if (RUNPARAM_CONDITIONAL_KEEP[dotted]?.(node[leaf])) continue;
67
+ delete node[leaf];
68
+ }
69
+ }
70
+
27
71
  export function effectiveRunConfig(base: PiTeamsConfig, rawOverride: unknown): PiTeamsConfig {
28
72
  const patch = sanitizeObject(parseConfig(rawOverride));
73
+ dropSensitivePatchPaths(patch as Record<string, unknown>);
29
74
  return {
30
75
  ...base,
31
76
  ...patch,
@@ -21,6 +21,7 @@
21
21
  * 5. Otherwise → blocked with a reason telling the user what to pass.
22
22
  */
23
23
 
24
+ import { getCrewEnv } from "../../config/env-vars.ts";
24
25
  import { currentCrewDepth } from "../../runtime/model/pi-args.ts";
25
26
 
26
27
  export const DESTRUCTIVE_TEAM_ACTIONS = new Set([
@@ -91,6 +92,19 @@ export function shouldBlockDestructiveTeamAction(action: string | undefined, inp
91
92
  * - `create` / — when resolved scope is `user` (create defaults to user;
92
93
  * - `update` update is gated at the resolved resource's scope — see
93
94
  * the post-`resolveMutable` call in management.ts).
95
+ * - `workflow-` — ALWAYS (fix-wave 6, R11-S1): .dwf.ts scripts are
96
+ * `create`/ arbitrary code auto-loaded by every future session —
97
+ * `save`/ project tier included (`.crew/workflows/` is discovered
98
+ * `delete` per-repo) and delete reaches the user tier. This is a
99
+ * deliberate exception to "project stays worker-reachable".
100
+ * workflow-get/list (reads) stay open.
101
+ * - `import` / — at resolved scope `user` only (project/default open,
102
+ * - `export` mirroring handleImport's project default).
103
+ *
104
+ * Scope source (fix-wave 6, R11-F1): the canonical carrier is `config.scope`
105
+ * — the ONE key every scope-bearing handler reads. Top-level `params.scope`
106
+ * is ignored by the gate (as it already is by the handlers) so divergent
107
+ * carriers cannot smuggle a user-tier write past the gate.
94
108
  *
95
109
  * Reads (config/status/list, settings list/get, schedule `scheduled`) are
96
110
  * never gated. `dryRun` previews that write nothing pass through, mirroring
@@ -112,7 +126,7 @@ export class RootSessionRequiredError extends Error {
112
126
  * in-repo (broker tests).
113
127
  */
114
128
  export function isRootSession(env: NodeJS.ProcessEnv = process.env): boolean {
115
- if (env.PI_CREW_KIND === "subagent") return false;
129
+ if (getCrewEnv("PI_CREW_KIND", env) === "subagent") return false;
116
130
  try {
117
131
  return currentCrewDepth(env) === 0;
118
132
  } catch {
@@ -142,7 +156,9 @@ export function assertRootOnlyAction(
142
156
  new RootSessionRequiredError(
143
157
  `root-session required: team action '${action}'${subcommand ? ` (${subcommand})` : ""} ${what}. ` +
144
158
  "This session is a pi-crew worker (PI_CREW_KIND/subagent or depth>0); user-tier state persists beyond the task and is reserved for the user's root pi session. " +
145
- (what.includes("project") ? "" : "Re-run with scope:'project' for project-local effects, or from the root session."),
159
+ (what.includes("project")
160
+ ? ""
161
+ : "Re-run with config: {scope: 'project'} for project-local effects, or from the root session."),
146
162
  );
147
163
  switch (action) {
148
164
  case "schedule":
@@ -171,6 +187,24 @@ export function assertRootOnlyAction(
171
187
  case "update":
172
188
  if (scope === "user") return detail("rewrites a user-tier resource loaded by every future session");
173
189
  return undefined;
190
+ case "workflow-create":
191
+ case "workflow-save":
192
+ // R11-S1 (fix-wave 6 M1-3): a .dwf.ts is arbitrary code auto-loaded by
193
+ // every future session in the discovery dirs — project tier included
194
+ // (`.crew/workflows/` of this repo). Unconditional for workers, like
195
+ // autonomy: no worker-reachable scope (confirm:true is caller-
196
+ // controlled and thus no barrier).
197
+ return detail("writes a dynamic-workflow .dwf.ts script auto-loaded by future sessions (arbitrary-code surface)");
198
+ case "workflow-delete":
199
+ // The target's tier is resolved from the named workflow at delete time
200
+ // (not from params) and includes the user tier — fail closed for workers.
201
+ return detail("deletes a persistent dynamic-workflow file (user tier included)");
202
+ case "import":
203
+ if (scope === "user") return detail("imports a run bundle into user-scope state (all future sessions)");
204
+ return undefined;
205
+ case "export":
206
+ if (scope === "user") return detail("writes a user-scope run export (all future sessions)");
207
+ return undefined;
174
208
  default:
175
209
  return undefined;
176
210
  }
@@ -186,6 +220,9 @@ export interface RootGateParamsLike {
186
220
  subAction?: unknown;
187
221
  /** Advisory-only dryRun preview flag. */
188
222
  dryRun?: unknown;
223
+ /** Destructive-gate confirm flag — caller-controlled, so the root-gate
224
+ * deliberately does NOT treat it as a bypass (R11-S1 pin). */
225
+ confirm?: unknown;
189
226
  config?: unknown;
190
227
  }
191
228
 
@@ -193,15 +230,29 @@ function configRecord(config: unknown): Record<string, unknown> {
193
230
  return config && typeof config === "object" && !Array.isArray(config) ? (config as Record<string, unknown>) : {};
194
231
  }
195
232
 
196
- function resolveScopeParam(params: RootGateParamsLike): RootGateScope {
233
+ /**
234
+ * R11-F1 (fix-wave 6, M1-2): the canonical scope carrier is `config.scope` —
235
+ * the same key every scope-bearing handler reads (create/settings/config in
236
+ * their dispatch paths, import and workflow-create in their handlers). The
237
+ * previous `params.scope ?? config.scope` read let a divergent
238
+ * `{scope: "project", config: {scope: "user"}}` payload pass the gate while
239
+ * the handler wrote the user tier. Handlers ignore top-level `scope`, so the
240
+ * gate must too. Per-action defaults mirror each handler's own default when
241
+ * the carrier is absent: import/export default `project` (handleImport),
242
+ * everything else defaults `user` (fail-closed, like create/settings/config).
243
+ */
244
+ function resolveScopeParam(params: RootGateParamsLike, action?: string): RootGateScope {
197
245
  const fromConfig = configRecord(params.config).scope;
198
- const raw = params.scope ?? fromConfig;
199
- return raw === "project" ? "project" : "user";
246
+ if (fromConfig === "project") return "project";
247
+ if (fromConfig === "user") return "user";
248
+ return action === "import" || action === "export" ? "project" : "user";
200
249
  }
201
250
 
202
251
  /**
203
252
  * Facade resolver: derive (subcommand, scope) from raw team-tool params for
204
253
  * the gated actions and decide whether a NON-ROOT session must be blocked.
254
+ * R11-F1: scope resolves from `config.scope` only (canonical carrier — see
255
+ * {@link resolveScopeParam}); top-level `scope` never influences the gate.
205
256
  * `update` is special: its effective write scope is the matched resource's
206
257
  * scope, unknown until `resolveMutable` — the facade blocks explicit
207
258
  * `scope:"user"` and management.ts gates the resolved case.
@@ -216,7 +267,7 @@ export function rootGateBlockReason(params: RootGateParamsLike, env: NodeJS.Proc
216
267
  (typeof params.subAction === "string" ? params.subAction : undefined) ??
217
268
  (typeof cfg.subAction === "string" ? cfg.subAction : undefined);
218
269
  const subcommand = typeof rawSub === "string" ? rawSub.toLowerCase() : undefined;
219
- const scope = resolveScopeParam(params);
270
+ const scope = resolveScopeParam(params, action);
220
271
  const force = params.force === true || cfg.force === true;
221
272
 
222
273
  // Reads / previews never gated (destructive-gate dryRun convention).
@@ -233,18 +284,27 @@ export function rootGateBlockReason(params: RootGateParamsLike, env: NodeJS.Proc
233
284
  if (action === "schedule") {
234
285
  return assertRootOnlyAction("schedule", subcommand, scope, env)?.message;
235
286
  }
236
- // config: gate only the WRITE path (non-empty patch or unset paths).
287
+ // config: gate only the WRITE path. Carrier keys (scope/unset/subAction/
288
+ // dryRun/confirm/force/args) are dropped by the config schema
289
+ // (configPatchFromConfig → parseConfig), so a payload of ONLY carrier keys
290
+ // can never change the file — a read. Unknown junk keys stay
291
+ // write-classified (fail-closed). `args` was missing from this list and
292
+ // miscounted `config {args:"show"}` reads as writes (RT24-F1, live-caught
293
+ // 2026-10-18). Note parseConfig injects defaults (runtime.*), so
294
+ // patch-emptiness cannot be used here — see updateConfig skip-if-unchanged.
237
295
  if (action === "config") {
238
- const patchKeys = Object.keys(cfg).filter(
239
- (k) => !["scope", "unset", "subAction", "dryRun", "confirm", "force"].includes(k) && cfg[k] !== undefined,
240
- );
296
+ const carrierKeys = new Set(["scope", "unset", "subAction", "dryRun", "confirm", "force", "args"]);
297
+ const patchKeys = Object.keys(cfg).filter((k) => !carrierKeys.has(k) && cfg[k] !== undefined);
241
298
  const unset = Array.isArray(cfg.unset) ? cfg.unset.length > 0 : cfg.unset !== undefined;
242
299
  const isWrite = patchKeys.length > 0 || unset;
243
300
  if (!isWrite) return undefined;
244
301
  }
245
- // autonomy: gate only the WRITE path (patch present).
302
+ // autonomy: gate only the WRITE path (patch keys present). Same carrier
303
+ // reasoning as config — `args`/`subAction` are subcommand carriers, not
304
+ // autonomous-config fields.
246
305
  if (action === "autonomy") {
247
- const patchKeys = Object.keys(cfg).filter((k) => !["subAction", "dryRun", "confirm"].includes(k) && cfg[k] !== undefined);
306
+ const carrierKeys = new Set(["subAction", "dryRun", "confirm", "args"]);
307
+ const patchKeys = Object.keys(cfg).filter((k) => !carrierKeys.has(k) && cfg[k] !== undefined);
248
308
  if (patchKeys.length === 0) return undefined;
249
309
  }
250
310
  // settings: derive the subcommand from `config.args` ("set k v", "unset k",
@@ -86,7 +86,15 @@ export async function handleManageDomain(params: TeamToolParamsValue, ctx: TeamC
86
86
  ...(initialized.skippedFiles.length
87
87
  ? ["Skipped existing files:", ...initialized.skippedFiles.map((file) => `- ${file}`)]
88
88
  : []),
89
- `Config: ${initialized.configPath || "(none)"} (${initialized.configScope}${initialized.configCreated ? "; created" : initialized.configSkipped ? "; already existed" : "; unchanged"})`,
89
+ `Config: ${initialized.configPath || "(none)"} (${initialized.configScope}${
90
+ initialized.configBackupPath
91
+ ? "; overwritten — previous content backed up to " + initialized.configBackupPath
92
+ : initialized.configCreated
93
+ ? "; created"
94
+ : initialized.configSkipped
95
+ ? "; already existed — left untouched (pass config.overwrite: true to replace)"
96
+ : "; unchanged"
97
+ })`,
90
98
  `Ignore: ${initialized.gitignorePath} (${initialized.gitignoreUpdated ? "updated" : "already configured"})`,
91
99
  ].join("\n"),
92
100
  { action: "init", status: "ok" },
@@ -89,6 +89,26 @@ export function getScheduledJobs(
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * F12-M3: hard cap on scheduled jobs. `add` had no count limit — every job also
94
+ * creates a 1:1 scheduler timer and is persisted, so a project-scope caller could
95
+ * grow the store (and the in-memory timer map) without bound. 100 is well above
96
+ * any hand-authored schedule list and cheap to hold.
97
+ */
98
+ export const MAX_SCHEDULED_JOBS = 100;
99
+
100
+ /** @internal — count of persisted scheduled jobs for this cwd (R4-M-B4-adjacent: the
101
+ * cap bounds the on-disk store + the 1:1 scheduler timer map, so it counts BOTH
102
+ * tiers rather than the B2-gated registration view). */
103
+ function scheduledJobCount(cwd: string): number {
104
+ try {
105
+ const tiers = loadCrewSettingsTiers(cwd, undefined);
106
+ return [...(tiers.user.scheduledJobs ?? []), ...(tiers.project.scheduledJobs ?? [])].filter(isScheduledJobLike).length;
107
+ } catch {
108
+ return 0;
109
+ }
110
+ }
111
+
92
112
  /** Shape guard for the settings fallback — mirrors settings-store's private
93
113
  * `validateScheduledJob` (id + scheduleType + enabled) so invalid persisted
94
114
  * entries are skipped instead of reaching renderers. */
@@ -219,6 +239,17 @@ export function handleSchedule(params: TeamToolParamsValue, ctx: TeamContext): P
219
239
  return result(msg, { action: "schedule", status: "error" }, true);
220
240
  }
221
241
 
242
+ // F12-M3: enforce the cap BEFORE building/persisting the job so the caller gets
243
+ // a clear refusal instead of a silently-dropped write (every add also creates a
244
+ // 1:1 scheduler timer — an unbounded job list is an unbounded timer set).
245
+ if (scheduledJobCount(ctx.cwd) >= MAX_SCHEDULED_JOBS) {
246
+ return result(
247
+ `Scheduled job limit reached (${MAX_SCHEDULED_JOBS}). Remove finished schedules first: { action: 'schedule', subAction: 'list' } then subAction: 'remove', id: '<jobId>'.`,
248
+ { action: "schedule", status: "error" },
249
+ true,
250
+ );
251
+ }
252
+
222
253
  const { spec, schedule, scheduleType, intervalMs } = specResult;
223
254
  const next = nextRunTime(spec);
224
255
  if ("error" in next) return result(next.error, { action: "schedule", status: "error" }, true);
@@ -301,10 +332,13 @@ function scheduledJobsOf(settings: CrewSettings): import("../../runtime/scheduli
301
332
 
302
333
  function persistScheduledJob(cwd: string, job: import("../../runtime/scheduling/scheduler.ts").ScheduledJob): void {
303
334
  try {
304
- updateCrewSettings(cwd, (settings) => ({
305
- ...settings,
306
- scheduledJobs: [...scheduledJobsOf(settings), job],
307
- }));
335
+ updateCrewSettings(cwd, (settings) => {
336
+ const existing = scheduledJobsOf(settings);
337
+ // F12-M3: enforced here as well as at the tool boundary so no other writer
338
+ // can push past the cap into the persisted store.
339
+ if (existing.length >= MAX_SCHEDULED_JOBS) return settings;
340
+ return { ...settings, scheduledJobs: [...existing, job] };
341
+ });
308
342
  } catch {
309
343
  /* best-effort persistence */
310
344
  }
@@ -1,4 +1,5 @@
1
1
  import { loadConfig, updateConfig } from "../../config/config.ts";
2
+ import { isDangerousPath } from "../../config/config-validation.ts";
2
3
  import { suggestConfigKey } from "../../config/suggestions.ts";
3
4
  import { collectSensitiveConfigPaths } from "../../schema/sensitive-config-paths.ts";
4
5
  import { discoverPiThemes, formatThemesListing, setPiTheme } from "../../ui/theme-discovery.ts";
@@ -60,7 +61,11 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
60
61
  "reliability.ambientStatusInjection": true,
61
62
  "reliability.forcePreflight": false,
62
63
  "reliability.scopeModels": false,
63
- "telemetry.enabled": false,
64
+ // R45-F3: the real default is ON — observability installs the metric sink when
65
+ // `config.telemetry?.enabled !== false` (registration/observability.ts:109), so
66
+ // an unset key means enabled. This map only drives the "(default)" display;
67
+ // `false` here printed a wrong default for `team-settings get telemetry.enabled`.
68
+ "telemetry.enabled": true,
64
69
  "notifications.enabled": false,
65
70
  };
66
71
 
@@ -69,6 +74,12 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
69
74
  // ---------------------------------------------------------------------------
70
75
 
71
76
  function setNested(obj: Record<string, unknown>, path: string, value: unknown): void {
77
+ // R24-H1 (M1-1): walking a raw dotted key through `target["__proto__"]`
78
+ // resolves to Object.prototype via the [[Prototype]] getter — the write
79
+ // then lands on Object.prototype BEFORE configPatchFromConfig sanitizes.
80
+ // Refuse dangerous segments at the walker (shared proto-safe helpers in
81
+ // config-validation.ts).
82
+ if (isDangerousPath(path)) return;
72
83
  const keys = path.split(".");
73
84
  let target: Record<string, unknown> = obj;
74
85
  for (let i = 0; i < keys.length - 1; i++) {
@@ -81,10 +92,17 @@ function setNested(obj: Record<string, unknown>, path: string, value: unknown):
81
92
  }
82
93
 
83
94
  function getNested(obj: Record<string, unknown>, path: string): unknown {
95
+ // R45-S1 read-side (wave-6 M4-R2): mirror the write path's guard. A raw
96
+ // dotted key resolves INHERITED members without an own-property check —
97
+ // `get __proto__.toString` / `get constructor` walk Object.prototype and the
98
+ // value is printed by formatValue (function source) and returned in the typed
99
+ // result. Refuse any hop the object does not OWN. (The write path already
100
+ // rejects dangerous segments via isDangerousPath in setNested.)
84
101
  const keys = path.split(".");
85
102
  let current: unknown = obj;
86
103
  for (const key of keys) {
87
104
  if (!current || typeof current !== "object") return undefined;
105
+ if (!Object.hasOwn(current, key)) return undefined;
88
106
  current = (current as Record<string, unknown>)[key];
89
107
  }
90
108
  return current;
@@ -92,7 +110,12 @@ function getNested(obj: Record<string, unknown>, path: string): unknown {
92
110
 
93
111
  function formatValue(value: unknown, key?: string): string {
94
112
  if (value === undefined || value === null) {
95
- const def = key ? EFFECTIVE_DEFAULTS[key] : undefined;
113
+ // R45-S1 read-side (wave-6 M4-R2): `EFFECTIVE_DEFAULTS` is a plain object
114
+ // literal, so an unguarded `EFFECTIVE_DEFAULTS["constructor"]` resolves the
115
+ // INHERITED Object constructor and prints its source — the same
116
+ // inherited-member leak as the getNested walker (pinned by
117
+ // `get constructor`). Require an OWN entry.
118
+ const def = key && Object.hasOwn(EFFECTIVE_DEFAULTS, key) ? EFFECTIVE_DEFAULTS[key] : undefined;
96
119
  if (def !== undefined) return `${String(def)} (default)`;
97
120
  return "<not set>";
98
121
  }
@@ -145,6 +168,43 @@ function flattenConfig(obj: unknown, prefix: string = ""): string[] {
145
168
  return lines;
146
169
  }
147
170
 
171
+ // ---------------------------------------------------------------------------
172
+ // Sensitive-value redaction (US-030 / R45-S1)
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /** Marker printed in place of a schema-marked sensitive value. */
176
+ const SENSITIVE_REDACTION_MARKER = "***(redacted — sensitive)";
177
+
178
+ /** Schema-marked sensitive dotted paths — computed once from the same source
179
+ * the `get` path historically used, so `get`/`list`/`json` can never drift. */
180
+ const SENSITIVE_CONFIG_PATHS = collectSensitiveConfigPaths();
181
+
182
+ function isSensitiveConfigPath(dotted: string): boolean {
183
+ return SENSITIVE_CONFIG_PATHS.some((p) => dotted === p || dotted.startsWith(`${p}.`));
184
+ }
185
+
186
+ /**
187
+ * R45-S1 (deep-review 2026-10-19): `list` and `json` dump the WHOLE effective
188
+ * config, so without redaction they bypass the `get` path's US-030 redaction
189
+ * and echo sensitive values (webhook.secret, otlp.headers, …) verbatim into
190
+ * the session transcript — any in-session agent can read tool output. Returns
191
+ * a deep clone with every sensitive-marked value replaced by the marker;
192
+ * undefined/null are kept as-is (they leak nothing and stay invisible in both
193
+ * output formats).
194
+ */
195
+ function redactSensitiveConfig(value: unknown, prefix: string = ""): unknown {
196
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
197
+ const out: Record<string, unknown> = {};
198
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
199
+ const dotted = prefix ? `${prefix}.${key}` : key;
200
+ out[key] =
201
+ isSensitiveConfigPath(dotted) && child !== undefined && child !== null
202
+ ? SENSITIVE_REDACTION_MARKER
203
+ : redactSensitiveConfig(child, dotted);
204
+ }
205
+ return out;
206
+ }
207
+
148
208
  // ---------------------------------------------------------------------------
149
209
  // Known config keys — mirrors config-schema.ts + config.ts.
150
210
  // ---------------------------------------------------------------------------
@@ -318,7 +378,8 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
318
378
  // team-settings list — show ALL effective values (not just KNOWN_KEYS)
319
379
  if (!args || args === "list") {
320
380
  const lines = ["pi-crew effective settings:", `Config file: ${loaded.path}`, ""];
321
- const flatLines = flattenConfig(effective);
381
+ // R45-S1: full dump must pass through the same redaction as `get`.
382
+ const flatLines = flattenConfig(redactSensitiveConfig(effective));
322
383
  if (flatLines.length > 0) {
323
384
  lines.push(...flatLines);
324
385
  } else {
@@ -344,7 +405,8 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
344
405
  `// Sources: ${loaded.paths?.join(", ") ?? loaded.path}`,
345
406
  ...(loaded.warnings?.map((w) => `// WARNING: ${w}`) ?? []),
346
407
  "",
347
- JSON.stringify(effective, null, 2),
408
+ // R45-S1: full dump must pass through the same redaction as `get`.
409
+ JSON.stringify(redactSensitiveConfig(effective), null, 2),
348
410
  ];
349
411
  return result(lines.join("\n"), { ...OK } as never);
350
412
  }
@@ -475,11 +537,11 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
475
537
  // US-030 security review: a value whose dotted path is schema-marked
476
538
  // sensitive (webhook.secret, otlp.headers, …) must not be echoed into
477
539
  // the session transcript — any in-session agent can read tool output.
478
- const sensitive = collectSensitiveConfigPaths().some((path) => key === path || key.startsWith(`${path}.`));
540
+ const sensitive = isSensitiveConfigPath(key);
479
541
  const rendered = sensitive
480
542
  ? value === undefined || value === null
481
543
  ? "<not set>"
482
- : "***(redacted — sensitive)"
544
+ : SENSITIVE_REDACTION_MARKER
483
545
  : formatValue(value, key);
484
546
  return result(`${key} = ${rendered}${note}`, {
485
547
  ...OK,
@@ -492,6 +554,10 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
492
554
  if (args.startsWith("unset ")) {
493
555
  const key = args.slice(6).trim();
494
556
  if (!key) return result("Usage: team-settings unset <key>", { ...ERR }, true);
557
+ // R24-H2 (M1-1): refuse reserved Object.prototype key paths at the
558
+ // boundary — `unset __proto__.toString` would otherwise delete
559
+ // Object.prototype.toString via the [[Prototype]] getter walk.
560
+ if (isDangerousPath(key)) return result(`Refusing to unset '${key}': reserved Object.prototype key path`, { ...ERR }, true);
495
561
  try {
496
562
  const saved = updateConfig({}, { cwd: ctx.cwd, scope, unsetPaths: [key] });
497
563
  return result(`Unset ${key}\nSaved to: ${saved.path}`, {
@@ -516,6 +582,10 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
516
582
  const key = rest.slice(0, spaceIdx);
517
583
  const rawValue = rest.slice(spaceIdx + 1).trim();
518
584
  if (!key) return result("Usage: team-settings set <key> <value>", { ...ERR }, true);
585
+ // R24-H1 (M1-1): refuse reserved Object.prototype key paths at the
586
+ // boundary — `set __proto__.polluted 1` would otherwise pollute
587
+ // Object.prototype via the [[Prototype]] getter walk in setNested.
588
+ if (isDangerousPath(key)) return result(`Refusing to set '${key}': reserved Object.prototype key path`, { ...ERR }, true);
519
589
 
520
590
  const value = parseValue(rawValue);
521
591
  const patch: Record<string, unknown> = {};
@@ -27,7 +27,7 @@ interface CorruptEntry {
27
27
  reason: string;
28
28
  }
29
29
 
30
- interface StuckTask {
30
+ export interface StuckTask {
31
31
  runId: string;
32
32
  taskId: string;
33
33
  staleMs: number;
@@ -80,6 +80,18 @@ function readRunTasks(stateRoot: string): TeamTaskState[] {
80
80
  */
81
81
  export function detectStuckTasks(run: { runId: string; stateRoot: string }, now: number): StuckTask[] {
82
82
  const tasks = readRunTasks(run.stateRoot);
83
+ return stuckTasksFromRecords(tasks, run.runId, now);
84
+ }
85
+
86
+ /**
87
+ * R48-N1 (2026-10-19): pure stuck-task detection over an ALREADY-loaded task
88
+ * array. Extracted from {@link detectStuckTasks} so the status surface — which
89
+ * already holds `tasks` in memory — can consult the SAME external heartbeat-age
90
+ * signal without a second disk read. This is the EXTERNAL (timestamp-based)
91
+ * signal the status `needsAttention` surface previously never had: it does not
92
+ * trust the worker's self-reported `activityState`.
93
+ */
94
+ export function stuckTasksFromRecords(tasks: TeamTaskState[], runId: string, now: number): StuckTask[] {
83
95
  const stuck: StuckTask[] = [];
84
96
 
85
97
  for (const task of tasks) {
@@ -103,7 +115,7 @@ export function detectStuckTasks(run: { runId: string; stateRoot: string }, now:
103
115
  const staleMs = now - latest;
104
116
  if (staleMs > STUCK_TASK_THRESHOLD_MS) {
105
117
  stuck.push({
106
- runId: run.runId,
118
+ runId,
107
119
  taskId: task.id,
108
120
  staleMs,
109
121
  detail: `stale ${Math.round(staleMs / 60_000)}m (last activity: ${new Date(latest).toISOString()})`,