@gtrabanco/pi-agentic-workflow 4.10.0 → 5.0.0-canary.20261104.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 (65) hide show
  1. package/README.md +15 -10
  2. package/dist/config/defaults.js +3 -8
  3. package/dist/config/merge.js +5 -20
  4. package/dist/config/schema.js +26 -98
  5. package/dist/config/types.d.ts +12 -59
  6. package/dist/extension/factory.d.ts +3 -13
  7. package/dist/extension/factory.js +1 -5
  8. package/dist/extension/index.js +18 -26
  9. package/dist/extension/telemetry-wiring.d.ts +67 -0
  10. package/dist/extension/telemetry-wiring.js +152 -0
  11. package/dist/session-telemetry.d.ts +104 -0
  12. package/dist/session-telemetry.js +161 -0
  13. package/dist/settings/console.js +1 -0
  14. package/dist/telemetry-sinks.d.ts +39 -0
  15. package/dist/telemetry-sinks.js +97 -0
  16. package/package.json +5 -5
  17. package/skills/audit-docs/SKILL.md +14 -3
  18. package/skills/audit-pr/references/03_AUDIT_PROCESS.md +10 -6
  19. package/skills/execute-phase/SKILL.md +10 -5
  20. package/skills/execute-phase/references/PREFLIGHT.md +2 -2
  21. package/skills/execute-phase/references/risk-tiers.md +105 -0
  22. package/skills/fold-findings/SKILL.md +12 -10
  23. package/skills/init-workspace/SKILL.md +18 -10
  24. package/skills/init-workspace/references/BOOTSTRAP_DISCOVERY.md +49 -9
  25. package/skills/init-workspace/references/BOOTSTRAP_WRITE.md +47 -20
  26. package/skills/init-workspace/references/SERENA.md +24 -0
  27. package/skills/init-workspace/references/UPGRADE.md +46 -19
  28. package/skills/phase-contract/SKILL.md +1 -1
  29. package/skills/pre-execution-review/references/LEDGERS.md +8 -4
  30. package/skills/pre-execution-review/references/POLICY.md +2 -2
  31. package/skills/product-audit/SKILL.md +9 -4
  32. package/skills/product-audit/references/AUDIT_DIMENSIONS.md +2 -0
  33. package/skills/product-audit/references/AUDIT_PROCESS.md +5 -2
  34. package/skills/review-a11y/SKILL.md +11 -6
  35. package/skills/review-brand/SKILL.md +11 -6
  36. package/skills/review-change/SKILL.md +1 -1
  37. package/skills/review-change/references/OUTPUT_AND_GUARDRAILS.md +8 -7
  38. package/skills/review-change/references/PERSIST_AND_DECIDE.md +15 -12
  39. package/skills/review-code/SKILL.md +26 -6
  40. package/skills/review-debt/SKILL.md +10 -5
  41. package/skills/review-design/SKILL.md +10 -5
  42. package/skills/review-implementation/references/CLASSIFY.md +94 -42
  43. package/skills/review-perf/SKILL.md +13 -8
  44. package/skills/review-security/SKILL.md +17 -7
  45. package/skills/review-seo/SKILL.md +10 -5
  46. package/skills/review-verify/SKILL.md +10 -5
  47. package/skills/triage-issue/SKILL.md +3 -2
  48. package/skills/triage-issue/references/FOLD_LEDGER.md +6 -5
  49. package/skills/unit-lane/SKILL.md +37 -4
  50. package/skills/unit-lane/references/DOCS.md +15 -3
  51. package/skills/unit-lane/references/IMPLEMENT.md +4 -1
  52. package/skills/unit-lane/references/RELEASE.md +4 -4
  53. package/skills/unit-lane/references/TESTS.md +12 -1
  54. package/skills/workflow-status/SKILL.md +2 -2
  55. package/skills/workflow-status/references/SENSOR_CORE.md +33 -4
  56. package/dist/extension/ci-guard.d.ts +0 -29
  57. package/dist/extension/ci-guard.js +0 -39
  58. package/dist/extension/cost-guard.d.ts +0 -30
  59. package/dist/extension/cost-guard.js +0 -50
  60. package/dist/extension/guard-util.d.ts +0 -26
  61. package/dist/extension/guard-util.js +0 -35
  62. package/dist/extension/history-guard.d.ts +0 -59
  63. package/dist/extension/history-guard.js +0 -132
  64. package/dist/extension/merge-guard.d.ts +0 -36
  65. package/dist/extension/merge-guard.js +0 -79
package/README.md CHANGED
@@ -64,7 +64,7 @@ Two JSON files, both optional:
64
64
  {
65
65
  "default": { "model": "anthropic/claude-opus-4-5", "thinking": "high" },
66
66
  "commands": {
67
- "plan-feature": { "model": ["anthropic/claude-sonnet-4-5", "openai/gpt-5.2"], "thinking": "medium" },
67
+ "unit-lane": { "model": ["anthropic/claude-sonnet-4-5", "openai/gpt-5.2"], "thinking": "medium" },
68
68
  "review-change": { "thinking": "max" }
69
69
  },
70
70
  "onUnavailableRoute": "stop",
@@ -84,7 +84,7 @@ whatever the session already had, because the shipped default route is
84
84
  resolves and has credentials, without touching the session while probing; when
85
85
  every entry is unusable the command stops (or, with `onUnavailableRoute`
86
86
  `inherit`, warns and runs on the current model), naming each candidate and why
87
- it was skipped. `plan-feature` above tries `anthropic/claude-sonnet-4-5` first,
87
+ it was skipped. `unit-lane` above tries `anthropic/claude-sonnet-4-5` first,
88
88
  then `openai/gpt-5.2`.
89
89
  - `thinking` is one of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`,
90
90
  or `"inherit"`.
@@ -263,8 +263,8 @@ nothing runs on a model you did not pick. To run anyway on the current model, se
263
263
  ## Your session after a command
264
264
 
265
265
  By default (**`onSettle: "keep"`**), the routed model and thinking level **stay**
266
- in the open chat window once the command settles. If `plan-feature` runs on
267
- `glm-5` and you want to tweak the plan it produced, your next prompt keeps
266
+ in the open chat window once the command settles. If `unit-lane` runs on
267
+ `glm-5` and you want to tweak the SPEC it produced, your next prompt keeps
268
268
  running on `glm-5` — same for a follow-up question. When you don't need the
269
269
  heavy model, switch it yourself with `/model` (or Ctrl+P) to something cheaper;
270
270
  nothing restores over your choice.
@@ -313,7 +313,7 @@ dialog.
313
313
 
314
314
  | You see | Means |
315
315
  | --- | --- |
316
- | `refused: invalid configuration` | A config file was rejected. The same message names the field, e.g. `$.commands.plan-feature.model`. Run `/agentic-workflow-settings` to see the file, or fix the JSON. |
316
+ | `refused: invalid configuration` | A config file was rejected. The same message names the field, e.g. `$.commands.unit-lane.model`. Run `/agentic-workflow-settings` to see the file, or fix the JSON. |
317
317
  | `stopped: the configured model` … `is not in the model registry` | The reference is wrong or the provider is not configured. Use `/model` to see the exact `provider/modelId`. |
318
318
  | `has no configured credentials` | The model exists but you cannot use it yet. Authenticate, or set `onUnavailableRoute` to `inherit`. |
319
319
  | `could not be selected` | Pi refused the switch. The command stops with the reason — unless `onUnavailableRoute` is `inherit`, in which case it warns and runs on your current model. |
@@ -324,11 +324,16 @@ dialog.
324
324
 
325
325
  ## Notes
326
326
 
327
- - Verified against the peer floor Pi 0.99.1 (2026-09-29) (`pi install`, package skills, friendly
328
- command registration, routed set/clear, settings console round-trip, `sendUserMessage`
329
- with prompt template expansion). Also verified against Pi 1.2.0 (2026-10-14) via a local
330
- two-leg `tsc --noEmit` run (legs `0.99.1` + `latest`, the `latest` leg resolving to pi
331
- 1.2.0; COMPAT-OK); continuously against the
327
+ - Verified against the peer floor Pi 1.1.0 (2026-11-03): the session telemetry
328
+ reads `tool_execution_end.durationMs`, which 1.0.4's published types do not
329
+ carry and 1.1.0's do (`npm pack @earendil-works/pi-coding-agent@1.0.4` and
330
+ `@1.1.0`, then `dist/core/extensions/types.d.ts` — 0 vs 2 occurrences). The
331
+ pre-telemetry surface was verified behaviourally at Pi 0.99.1 (2026-09-29)
332
+ (`pi install`, package skills, friendly command registration, routed set/clear,
333
+ settings console round-trip, `sendUserMessage` with prompt template expansion).
334
+ Also verified against Pi 1.5.0 (2026-11-02) via a local two-leg `tsc --noEmit`
335
+ run (legs `0.99.1` + `latest`, the `latest` leg resolving to pi 1.5.0;
336
+ COMPAT-OK); continuously against the
332
337
  `latest` dist-tag by the `pi-compat-guard` PR gate (the leg is a moving tripwire,
333
338
  not a pinned version — a breaking release shows red here first).
334
339
  - The package declares Pi as a peer dependency; it bundles no copy of Pi.
@@ -20,14 +20,9 @@ export const DEFAULT_CONFIG = {
20
20
  // Feature 66 — the memory substrate is ask-first: nothing is provisioned
21
21
  // until the operator opts in.
22
22
  memory: { engram: false },
23
- // Tool-call guards ship all-off: they are this repository's working rules,
24
- // not a package opinion — a project opts in per guard in its config file.
25
- guards: {
26
- merge: false,
27
- history: false,
28
- ci: { create: false, run: false },
29
- expensiveTests: { maxTokens: 0, patterns: [] },
30
- },
23
+ // Issue #383 — the measured acceptance is opt-in: a fresh install measures
24
+ // nothing until an operator turns it on.
25
+ telemetry: { enabled: false },
31
26
  profiles: {},
32
27
  profileOrder: [],
33
28
  profileFallback: { ...DEFAULT_PROFILE_FALLBACK },
@@ -132,9 +132,11 @@ export function mergeConfigs(globalFile = {}, projectFile = {}) {
132
132
  memory: {
133
133
  engram: pick(false, projectFile.memory?.engram, globalFile.memory?.engram) === true,
134
134
  },
135
- // Tool-call guards resolve like every other knob: project wins, then
136
- // global, then the shipped OFF. A guard no scope mentions stays disabled.
137
- guards: mergeGuards(globalFile.guards, projectFile.guards),
135
+ // Issue #383 — the same boolean resolution: project, then global, then the
136
+ // shipped "no".
137
+ telemetry: {
138
+ enabled: pick(false, projectFile.telemetry?.enabled, globalFile.telemetry?.enabled) === true,
139
+ },
138
140
  profiles: mergeProfiles(globalFile.profiles, projectFile.profiles),
139
141
  profileOrder: pick([], projectFile.profileOrder, globalFile.profileOrder),
140
142
  profileFallback: mergeProfileFallback(globalFile.profileFallback, projectFile.profileFallback),
@@ -147,23 +149,6 @@ export function mergeConfigs(globalFile = {}, projectFile = {}) {
147
149
  merged.advance = advance;
148
150
  return merged;
149
151
  }
150
- /** Guards merge whole-knob: a boolean takes project-then-global, and the
151
- * expensive-tests block resolves field-by-field (patterns are replaced, not
152
- * concatenated, so a project override is predictable — no hidden union). */
153
- function mergeGuards(globalGuards, projectGuards) {
154
- return {
155
- merge: pick(DEFAULT_CONFIG.guards.merge, projectGuards?.merge, globalGuards?.merge),
156
- history: pick(DEFAULT_CONFIG.guards.history, projectGuards?.history, globalGuards?.history),
157
- ci: {
158
- create: pick(DEFAULT_CONFIG.guards.ci.create, projectGuards?.ci?.create, globalGuards?.ci?.create),
159
- run: pick(DEFAULT_CONFIG.guards.ci.run, projectGuards?.ci?.run, globalGuards?.ci?.run),
160
- },
161
- expensiveTests: {
162
- maxTokens: pick(DEFAULT_CONFIG.guards.expensiveTests.maxTokens, projectGuards?.expensiveTests?.maxTokens, globalGuards?.expensiveTests?.maxTokens),
163
- patterns: pick(DEFAULT_CONFIG.guards.expensiveTests.patterns, projectGuards?.expensiveTests?.patterns, globalGuards?.expensiveTests?.patterns),
164
- },
165
- };
166
- }
167
152
  /** Project-over-global merge of the advance (conductor) knobs (feature 62).
168
153
  * Absent from the result when neither scope declares any — the resolved
169
154
  * shape of pre-62 configs is byte-stable. */
@@ -21,7 +21,7 @@ const ROOT_KEYS = new Set([
21
21
  "profileOrder",
22
22
  "profileFallback",
23
23
  "memory",
24
- "guards",
24
+ "telemetry",
25
25
  ]);
26
26
  const ROUTE_KEYS = new Set(["model", "thinking"]);
27
27
  const COMMAND_NAME = /^[a-z0-9][a-z0-9._-]*$/u;
@@ -333,7 +333,7 @@ function validateConfig(value) {
333
333
  if (!ROOT_KEYS.has(key)) {
334
334
  issues.push({
335
335
  path: `$.${displayKey(key)}`,
336
- message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle, pathProtection, advance, recommendedModels, profiles, profileOrder, profileFallback, memory, guards)`,
336
+ message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle, pathProtection, advance, recommendedModels, profiles, profileOrder, profileFallback, memory, telemetry)`,
337
337
  });
338
338
  }
339
339
  }
@@ -403,16 +403,16 @@ function validateConfig(value) {
403
403
  config.recommendedModels = value.recommendedModels;
404
404
  }
405
405
  }
406
+ if (value.telemetry !== undefined) {
407
+ const telemetry = checkTelemetry(value.telemetry, "$.telemetry", issues);
408
+ if (telemetry)
409
+ config.telemetry = telemetry;
410
+ }
406
411
  if (value.memory !== undefined) {
407
412
  const memory = checkMemory(value.memory, "$.memory", issues);
408
413
  if (memory)
409
414
  config.memory = memory;
410
415
  }
411
- if (value.guards !== undefined) {
412
- const guards = checkGuards(value.guards, "$.guards", issues);
413
- if (guards)
414
- config.guards = guards;
415
- }
416
416
  if (value.profiles !== undefined) {
417
417
  if (!isRecord(value.profiles)) {
418
418
  issues.push({ path: "$.profiles", message: "must be an object mapping profile names to profile definitions" });
@@ -448,125 +448,53 @@ function validateConfig(value) {
448
448
  * the loader can treat "nothing declared" like "nothing present" without a
449
449
  * second read.
450
450
  */
451
- /** Strict validator for the `memory` consent block (feature 66, AC3). */
452
- function checkMemory(value, path, issues) {
451
+ /** Strict validator for the `telemetry` block (issue #383): one boolean switch. */
452
+ function checkTelemetry(value, path, issues) {
453
453
  if (!isRecord(value)) {
454
454
  issues.push({ path, message: `must be a JSON object, got ${describe(value)}` });
455
455
  return undefined;
456
456
  }
457
457
  let unknown = false;
458
458
  for (const key of Object.keys(value)) {
459
- if (key !== "engram") {
459
+ if (key !== "enabled") {
460
460
  unknown = true;
461
- issues.push({ path: `${path}.${displayKey(key)}`, message: `unknown memory key "${key}" (allowed: engram)` });
461
+ issues.push({ path: `${path}.${displayKey(key)}`, message: `unknown telemetry key "${key}" (allowed: enabled)` });
462
462
  }
463
463
  }
464
464
  const out = {};
465
- if (value.engram !== undefined) {
466
- if (typeof value.engram !== "boolean") {
467
- issues.push({ path: `${path}.engram`, message: `must be a boolean, got ${describe(value.engram)}` });
465
+ if (value.enabled !== undefined) {
466
+ if (typeof value.enabled !== "boolean") {
467
+ issues.push({ path: `${path}.enabled`, message: `must be a boolean, got ${describe(value.enabled)}` });
468
468
  }
469
469
  else {
470
- out.engram = value.engram;
470
+ out.enabled = value.enabled;
471
471
  }
472
472
  }
473
473
  return unknown ? undefined : out;
474
474
  }
475
- /** Strict validator for the `guards` block. Everything absent stays off. */
476
- function checkGuards(value, path, issues) {
475
+ /** Strict validator for the `memory` consent block (feature 66, AC3). */
476
+ function checkMemory(value, path, issues) {
477
477
  if (!isRecord(value)) {
478
478
  issues.push({ path, message: `must be a JSON object, got ${describe(value)}` });
479
479
  return undefined;
480
480
  }
481
- const out = {};
482
- for (const key of ["merge", "history"]) {
483
- if (value[key] !== undefined) {
484
- if (typeof value[key] !== "boolean") {
485
- issues.push({ path: `${path}.${key}`, message: `must be a boolean, got ${describe(value[key])}` });
486
- }
487
- else {
488
- out[key] = value[key];
489
- }
490
- }
491
- }
492
- if (value.ci !== undefined) {
493
- const ciPath = `${path}.ci`;
494
- if (!isRecord(value.ci)) {
495
- issues.push({ path: ciPath, message: `must be a JSON object, got ${describe(value.ci)}` });
496
- }
497
- else {
498
- const ci = {};
499
- for (const key of ["create", "run"]) {
500
- if (value.ci[key] !== undefined) {
501
- if (typeof value.ci[key] !== "boolean") {
502
- issues.push({ path: `${ciPath}.${key}`, message: `must be a boolean, got ${describe(value.ci[key])}` });
503
- }
504
- else {
505
- ci[key] = value.ci[key];
506
- }
507
- }
508
- }
509
- for (const key of Object.keys(value.ci)) {
510
- if (key !== "create" && key !== "run") {
511
- issues.push({ path: `${ciPath}.${displayKey(key)}`, message: `unknown ci key "${key}" (allowed: create, run)` });
512
- }
513
- }
514
- out.ci = ci;
515
- }
516
- }
517
- if (value.expensiveTests !== undefined) {
518
- out.expensiveTests = checkExpensiveTests(value.expensiveTests, `${path}.expensiveTests`, issues);
519
- }
481
+ let unknown = false;
520
482
  for (const key of Object.keys(value)) {
521
- if (key !== "merge" && key !== "history" && key !== "ci" && key !== "expensiveTests") {
522
- issues.push({ path: `${path}.${displayKey(key)}`, message: `unknown guards key "${key}" (allowed: merge, history, ci, expensiveTests)` });
483
+ if (key !== "engram") {
484
+ unknown = true;
485
+ issues.push({ path: `${path}.${displayKey(key)}`, message: `unknown memory key "${key}" (allowed: engram)` });
523
486
  }
524
487
  }
525
- return out;
526
- }
527
- function checkExpensiveTests(value, path, issues) {
528
- if (!isRecord(value)) {
529
- issues.push({ path, message: `must be a JSON object, got ${describe(value)}` });
530
- return undefined;
531
- }
532
488
  const out = {};
533
- if (value.maxTokens !== undefined) {
534
- if (typeof value.maxTokens !== "number" || !Number.isInteger(value.maxTokens) || value.maxTokens < 0) {
535
- issues.push({ path: `${path}.maxTokens`, message: `must be a non-negative integer, got ${describe(value.maxTokens)}` });
536
- }
537
- else {
538
- out.maxTokens = value.maxTokens;
539
- }
540
- }
541
- if (value.patterns !== undefined) {
542
- if (!Array.isArray(value.patterns)) {
543
- issues.push({ path: `${path}.patterns`, message: `must be an array, got ${describe(value.patterns)}` });
489
+ if (value.engram !== undefined) {
490
+ if (typeof value.engram !== "boolean") {
491
+ issues.push({ path: `${path}.engram`, message: `must be a boolean, got ${describe(value.engram)}` });
544
492
  }
545
493
  else {
546
- const patterns = [];
547
- value.patterns.forEach((entry, index) => {
548
- const entryPath = `${path}.patterns[${index}]`;
549
- if (!isRecord(entry) || typeof entry.match !== "string" || entry.match === "") {
550
- issues.push({ path: entryPath, message: `must be an object with a non-empty string "match", got ${describe(entry)}` });
551
- return;
552
- }
553
- if (typeof entry.tokens !== "number" || !Number.isInteger(entry.tokens) || entry.tokens < 0) {
554
- issues.push({ path: `${entryPath}.tokens`, message: `must be a non-negative integer, got ${describe(entry.tokens)}` });
555
- return;
556
- }
557
- try {
558
- new RegExp(entry.match, "u");
559
- }
560
- catch {
561
- issues.push({ path: `${entryPath}.match`, message: `not a valid regular expression: ${entry.match}` });
562
- return;
563
- }
564
- patterns.push({ match: entry.match, tokens: entry.tokens });
565
- });
566
- out.patterns = patterns;
494
+ out.engram = value.engram;
567
495
  }
568
496
  }
569
- return out;
497
+ return unknown ? undefined : out;
570
498
  }
571
499
  export function parseConfigFile(text) {
572
500
  if (text.trim() === "")
@@ -78,9 +78,8 @@ export interface ConfigFile {
78
78
  /** Feature 66 (AC3/D3): the memory-substrate consent surface — Engram's MCP
79
79
  * registration is ask-first, so nothing is provisioned unless this says so. */
80
80
  memory?: MemoryConfigFile;
81
- /** Tool-call guards (owner-gated merges, history, CI, expensive tests).
82
- * Everything absent stays off. */
83
- guards?: GuardsConfigFile;
81
+ /** Issue #383: the per-unit cost sample. */
82
+ telemetry?: TelemetryConfigFile;
84
83
  /** Named user profiles keyed by profile name (feature 63, AC5). */
85
84
  profiles?: Record<string, ProfileFile>;
86
85
  /** Ordered list of profile names to probe; empty means ["default"] only
@@ -108,60 +107,14 @@ export interface MemoryConfigFile {
108
107
  export interface MemoryConfig {
109
108
  engram: boolean;
110
109
  }
111
- /**
112
- * One tool-call guard, as an operator WRITES it (this repository's working
113
- * rules, made mechanical; every other project gets none of it unless it
114
- * configures it). Each guard ships disabled — an absent key is OFF.
115
- *
116
- * merge every direct merge route is owner-only: `git merge`,
117
- * `gh pr merge`, and `git pull`'s merge form.
118
- * history pushes are decided by asking git (`git push --dry-run`):
119
- * a forced update, a ref delete or a push to `main` is
120
- * declined — one mechanism covering every flag spelling and
121
- * git alias.
122
- * ci CI is owner-gated: creating workflow files and triggering
123
- * workflow runs are blocked.
124
- * expensiveTests commands matching a configured pattern are estimated in
125
- * tokens; above `maxTokens` they are blocked. `tokens: 0`
126
- * blocks every match regardless of estimate.
127
- *
128
- * The only way through a guard is the explicit per-command token — the
129
- * command's first word — after an owner authorization in the same turn:
130
- * `AW_ALLOW=merge`, `AW_ALLOW=force`, `AW_ALLOW=ci`, `AW_ALLOW=cost`.
131
- */
132
- export interface GuardsConfigFile {
133
- merge?: boolean;
134
- history?: boolean;
135
- ci?: GuardsCiFile;
136
- expensiveTests?: GuardsExpensiveTestsFile;
137
- }
138
- export interface GuardsCiFile {
139
- create?: boolean;
140
- run?: boolean;
141
- }
142
- export interface GuardsExpensiveTestsFile {
143
- /** Estimated-token ceiling; a match above it is blocked. `0` (default)
144
- * blocks every match regardless of estimate. */
145
- maxTokens?: number;
146
- /** Command patterns (regex source, `u` flag) and their estimated cost. */
147
- patterns?: GuardsCostPattern[];
148
- }
149
- export interface GuardsCostPattern {
150
- match: string;
151
- tokens: number;
152
- }
153
- /** What the extension READS: every guard knob resolved. */
154
- export interface GuardsConfig {
155
- merge: boolean;
156
- history: boolean;
157
- ci: {
158
- create: boolean;
159
- run: boolean;
160
- };
161
- expensiveTests: {
162
- maxTokens: number;
163
- patterns: GuardsCostPattern[];
164
- };
110
+ /** What an operator WRITES under `telemetry` (issue #383). */
111
+ export interface TelemetryConfigFile {
112
+ /** Opt in to the per-unit cost sample. Absent or `false` measures nothing. */
113
+ enabled?: boolean;
114
+ }
115
+ /** What the extension READS: the telemetry switch, resolved (issue #383). */
116
+ export interface TelemetryConfig {
117
+ enabled: boolean;
165
118
  }
166
119
  export interface ConfigIssue {
167
120
  path: string;
@@ -191,8 +144,8 @@ export interface EffectiveConfig {
191
144
  profileFallback: ProfileFallbackConfig;
192
145
  /** Feature 66: the memory-substrate consent surface. */
193
146
  memory: MemoryConfig;
194
- /** The effective tool-call guards (all off unless a config file enables them). */
195
- guards: GuardsConfig;
147
+ /** Issue #383: the measured acceptance is opt-in, like every other runtime cost. */
148
+ telemetry: TelemetryConfig;
196
149
  /** Feature 63: declaredness of the implicit `default` profile. */
197
150
  declared: {
198
151
  default: RouteDeclaration;
@@ -2,10 +2,6 @@ import type { LoadedConfig } from "../config/load.js";
2
2
  import type { Catalogue } from "../routing/catalogue.js";
3
3
  import type { Router } from "../routing/dispatch.js";
4
4
  import { dirtyWorktreeWarning, receiptGuard } from "./receipt-guard.js";
5
- import { mergeGuard } from "./merge-guard.js";
6
- import { historyGuard } from "./history-guard.js";
7
- import { ciGuard } from "./ci-guard.js";
8
- import { costGuard } from "./cost-guard.js";
9
5
  import type { ExtensionSurface, InvocationContext, ModelRef, RoutingControls } from "../routing/types.js";
10
6
  import type { HintStore } from "../routing/state.js";
11
7
  /**
@@ -58,18 +54,12 @@ export interface ExtensionHandle<M extends ModelRef = ModelRef> {
58
54
  catalogue: Catalogue;
59
55
  /**
60
56
  * The Pi-free predicates the adapter (`index.ts`) binds to Pi's lifecycle
61
- * events: the inline-receipt block guard, the owner-gated tool guards (merge,
62
- * history, CI, cost — all config-gated, all off unless a project enables
63
- * them), and the dirty-worktree notice. They travel with the handle so the
64
- * entry stays a thin translation layer and the decisions are unit-tested with
65
- * no session (issues #182, #278).
57
+ * events: the inline-receipt block guard and the dirty-worktree notice. They
58
+ * travel with the handle so the entry stays a thin translation layer and the
59
+ * decisions are unit-tested with no session (issue #182).
66
60
  */
67
61
  guards: {
68
62
  receiptGuard: typeof receiptGuard;
69
- mergeGuard: typeof mergeGuard;
70
- historyGuard: typeof historyGuard;
71
- ciGuard: typeof ciGuard;
72
- costGuard: typeof costGuard;
73
63
  dirtyWorktreeWarning: typeof dirtyWorktreeWarning;
74
64
  };
75
65
  }
@@ -2,10 +2,6 @@ import { loadConfig } from "../config/load.js";
2
2
  import { readCatalogue } from "../routing/catalogue.js";
3
3
  import { createRouter } from "../routing/dispatch.js";
4
4
  import { dirtyWorktreeWarning, receiptGuard } from "./receipt-guard.js";
5
- import { mergeGuard } from "./merge-guard.js";
6
- import { historyGuard } from "./history-guard.js";
7
- import { ciGuard } from "./ci-guard.js";
8
- import { costGuard } from "./cost-guard.js";
9
5
  import { ADVANCE_COMMAND, SETTINGS_COMMAND, SETTINGS_COMMAND_ALIAS } from "../routing/types.js";
10
6
  import { registerAdvanceCommand } from "./conductor-command.js";
11
7
  import { createProfileStateStore, stateFilePath } from "../routing/state.js";
@@ -82,5 +78,5 @@ export function createExtension(deps) {
82
78
  description: "Show and configure per-command model routing",
83
79
  handler: settingsHandler,
84
80
  });
85
- return { router, catalogue, guards: { receiptGuard, mergeGuard, historyGuard, ciGuard, costGuard, dirtyWorktreeWarning } };
81
+ return { router, catalogue, guards: { receiptGuard, dirtyWorktreeWarning } };
86
82
  }
@@ -9,6 +9,7 @@ import { readGitStatusBounded } from "./receipt-guard.js";
9
9
  import { createPickerComponent, PICKER_MAX_VISIBLE, pagedSelect } from "../settings/picker.js";
10
10
  import { createHintStore, createSerenaReadinessStore, stateFilePath } from "../routing/state.js";
11
11
  import { createSerenaReadinessGate, readInitWorkspaceRevision } from "../routing/serena-readiness.js";
12
+ import { createTelemetryWiring } from "./telemetry-wiring.js";
12
13
  import { provisionEngram, readDeclaredServerNamesFrom, resolveEngramBinary, declaredMcpServerPaths } from "./engram.js";
13
14
  import { runSettingsConsole } from "../settings/console.js";
14
15
  import { readConfigFile, writeConfigFile } from "../settings/store.js";
@@ -89,11 +90,14 @@ function toInvocationContext(ctx) {
89
90
  hasConfiguredAuth: (model) => ctx.modelRegistry.hasConfiguredAuth(model),
90
91
  };
91
92
  }
92
- function toRegistrar(pi) {
93
+ function toRegistrar(pi, noteCommand) {
93
94
  return {
94
95
  registerCommand: (name, options) => pi.registerCommand(name, {
95
96
  ...(options.description ? { description: options.description } : {}),
96
97
  handler: async (args, ctx) => {
98
+ // Issue #383: the session learns which unit it is working on from the
99
+ // command that names it, never from a model's claim.
100
+ noteCommand(name, String(args ?? "").split(/\s+/).filter(Boolean));
97
101
  await options.handler(args, toInvocationContext(ctx));
98
102
  },
99
103
  }),
@@ -229,8 +233,13 @@ export default function extension(pi) {
229
233
  // Said once per session: an override that was ignored (or a malformed file)
230
234
  // is reported the first time the guard runs, never on every tool call.
231
235
  let reportedDegradations = false;
236
+ // Issue #383 — the session accumulator. Pi builds a fresh extension instance
237
+ // per session (its runtime factory rebuilds the services, and the resource
238
+ // loader re-runs every factory), so one instance owns one session's sample.
239
+ let telemetry = null;
240
+ const noteCommand = (name, args) => telemetry?.noteCommand(name, args);
232
241
  const { router, guards } = createExtension({
233
- registrar: toRegistrar(pi),
242
+ registrar: toRegistrar(pi, noteCommand),
234
243
  // Resolved per call: the router must never hold a session-bound object
235
244
  // between turns, because Pi can hand a new session to the same extension.
236
245
  surface: () => ({
@@ -276,6 +285,13 @@ export default function extension(pi) {
276
285
  ctx.ui.notify(message, "warning");
277
286
  const projectTrusted = ctx.isProjectTrusted();
278
287
  const loaded = loadConfig({ agentDir, cwd: ctx.cwd, projectTrusted });
288
+ if (loaded.config.telemetry?.enabled === true && telemetry === null) {
289
+ // The id is read once, here: pi invalidates a captured context after the
290
+ // session is replaced, and one extension instance serves one session.
291
+ const sessionId = ctx.sessionManager.getSessionId();
292
+ telemetry = createTelemetryWiring({ root: ctx.cwd, enabled: true, sessionId: () => sessionId });
293
+ telemetry.register(pi);
294
+ }
279
295
  const report = provisionEngram({
280
296
  consented: loaded.config.memory?.engram === true,
281
297
  configInvalid: !loaded.ok,
@@ -296,32 +312,8 @@ export default function extension(pi) {
296
312
  // names the script that proves the receipt landed (issue #182). The guard
297
313
  // itself filters to `bash`, so every tool call is checked — nesting it under
298
314
  // one tool name would make the block unreachable for the calls it exists for.
299
- //
300
- // Owner-gated tool guards, all config-driven (`guards` in the config files;
301
- // every guard ships OFF — a project opts in per guard):
302
- // merge `git merge`, `gh pr merge`, `git pull`'s merge form (issue #278).
303
- // history pushes decided by git itself (`git push --dry-run`): a forced
304
- // update, a ref delete or a push to `main` is declined.
305
- // ci writing workflow definitions and triggering workflow runs.
306
- // cost commands estimated above the configured token budget.
307
- // The only way through any of them is the explicit per-command token
308
- // (`AW_ALLOW=merge|force|ci|cost`) after an owner instruction in the turn.
309
315
  pi.on("tool_call", (event, ctx) => {
310
316
  const command = "command" in event.input && typeof event.input.command === "string" ? event.input.command : undefined;
311
- const inputPath = "path" in event.input && typeof event.input.path === "string" ? event.input.path : undefined;
312
- const { guards: policy } = loadConfig({ agentDir, cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() }).config;
313
- const merge = guards.mergeGuard({ toolName: event.toolName, command }, policy.merge);
314
- if (merge.block)
315
- return merge;
316
- const history = guards.historyGuard({ toolName: event.toolName, command }, policy.history, { cwd: ctx.cwd });
317
- if (history.block)
318
- return history;
319
- const ci = guards.ciGuard({ toolName: event.toolName, command, path: inputPath }, policy.ci);
320
- if (ci.block)
321
- return ci;
322
- const cost = guards.costGuard({ toolName: event.toolName, command }, policy.expensiveTests);
323
- if (cost.block)
324
- return cost;
325
317
  const receipt = guards.receiptGuard({ toolName: event.toolName, command });
326
318
  if (receipt.block)
327
319
  return receipt;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * telemetry-wiring.ts — attaches the session accumulator to Pi's lifecycle
3
+ * (issue #383 S4/S4b, P5).
4
+ *
5
+ * The accumulator and its sink are pure; this file is the only place that knows
6
+ * Pi exists. It is a factory over a `pi`-shaped object so the suite can drive the
7
+ * five events directly, and so `#394` can add a second sink without touching how
8
+ * the session is observed.
9
+ *
10
+ * Two decisions worth knowing when reading it:
11
+ *
12
+ * - **The unit comes from the command that was dispatched**, resolved against
13
+ * the project's own directories. A session that ran no unit-scoped command
14
+ * emits nothing: a sample without a unit would be a row the ledger cannot
15
+ * attribute, which is worse than no row.
16
+ * - **The sample names its session.** The sink's idempotency guard compares a
17
+ * whole row, so the session id in `source` is what keeps a second session of
18
+ * the same unit — the same day, the same tree — from being written over.
19
+ * - **Off registers no listener at all.** A listener that wakes up to find itself
20
+ * disabled is a cost with no feature, so `enabled: false` means `pi.on` is
21
+ * never called.
22
+ */
23
+ import { type TelemetrySink } from "../session-telemetry.js";
24
+ /** The events this wiring listens to. */
25
+ export declare const TELEMETRY_EVENTS: readonly ["session_start", "turn_start", "turn_end", "tool_execution_end", "session_shutdown"];
26
+ /** The slice of `pi` the wiring needs. Pi hands every handler its context. */
27
+ export interface PiLike {
28
+ on(event: string, handler: (event?: unknown, ctx?: unknown) => void): void;
29
+ }
30
+ export interface TelemetryWiringOptions {
31
+ readonly root: string;
32
+ readonly enabled: boolean;
33
+ readonly sinks?: readonly TelemetrySink[];
34
+ /** Where the ledger lives, relative to `root`. */
35
+ readonly ledgerPath?: string;
36
+ /** How many paths this session changed; injected so the suite owns git too. */
37
+ readonly changedPaths?: () => number;
38
+ /** The HEAD the session started on, captured at `session_start`. */
39
+ readonly sessionStartSha?: () => string;
40
+ /**
41
+ * The id of the session being measured, so two sessions that touch the same
42
+ * unit on the same day are two samples instead of one. Read lazily: the
43
+ * caller already holds the live context when it builds the wiring.
44
+ */
45
+ readonly sessionId?: () => string | undefined;
46
+ readonly now?: () => number;
47
+ readonly date?: () => string;
48
+ readonly source?: string;
49
+ }
50
+ export interface TelemetryWiring {
51
+ /** Note a dispatched command, so the session knows which unit it is working on. */
52
+ noteCommand(command: string, args: readonly string[]): void;
53
+ register(pi: PiLike): void;
54
+ }
55
+ /**
56
+ * How many distinct paths this session touched: the commits since the HEAD it
57
+ * started on, plus whatever is still uncommitted. It is the session's own scope —
58
+ * not the branch's — because the sample is the session's cost, and a long-lived
59
+ * branch would otherwise be charged to whichever unit happened to end it.
60
+ */
61
+ export declare function sessionChangedPaths(root: string, startSha: string): number;
62
+ /**
63
+ * Build the wiring. Everything it cannot know is injected: the project root, the
64
+ * sinks, the clock, the path count, the session id and the session's starting
65
+ * commit.
66
+ */
67
+ export declare function createTelemetryWiring(options: TelemetryWiringOptions): TelemetryWiring;