@event4u/agent-config 9.34.0 → 9.36.0

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/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +64 -77
  3. package/CONTRIBUTING.md +23 -14
  4. package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
  5. package/dist/agent-src/commands/roadmap/next.md +37 -5
  6. package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
  7. package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
  8. package/dist/agent-src/rules/improve-before-implement.md +1 -1
  9. package/dist/agent-src/rules/ui-audit-gate.md +35 -0
  10. package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
  11. package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
  12. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  13. package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
  14. package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
  15. package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
  16. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  17. package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
  18. package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
  19. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
  20. package/dist/agent-src/templates/AGENTS.md +1 -1
  21. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  22. package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
  23. package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
  24. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  25. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  26. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  27. package/dist/cli/registry.js +2 -2
  28. package/dist/cli/registry.js.map +1 -1
  29. package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
  30. package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-JP53LNVT.js} +12 -0
  31. package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
  32. package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-NR7HSN3P.js} +2 -2
  33. package/dist/cli-delegate/{chunk-REMEZSVP.js → chunk-SFGARMAB.js} +13 -3
  34. package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-U7CMAIOC.js} +7 -2
  35. package/dist/cli-delegate/cmd_conformance.js +4 -4
  36. package/dist/cli-delegate/cmd_doctor.js +3 -3
  37. package/dist/cli-delegate/cmd_export.js +3 -3
  38. package/dist/cli-delegate/cmd_handoff.js +1 -1
  39. package/dist/cli-delegate/cmd_migrate.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +3 -3
  41. package/dist/cli-delegate/cmd_refresh.js +1 -1
  42. package/dist/cli-delegate/cmd_session_recycle.js +8 -1
  43. package/dist/cli-delegate/cmd_settings_migrate.js +3 -3
  44. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  45. package/dist/cli-delegate/cmd_sync.js +3 -3
  46. package/dist/cli-delegate/cmd_uninstall.js +3 -3
  47. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  48. package/dist/cli-delegate/cmd_validate.js +3 -3
  49. package/dist/discovery/deprecation-report.md +1 -1
  50. package/dist/discovery/discovery-manifest.json +14 -14
  51. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  52. package/dist/discovery/discovery-manifest.summary.md +1 -1
  53. package/dist/discovery/dormancy-report.md +1 -1
  54. package/dist/discovery/orphan-report.md +1 -1
  55. package/dist/discovery/packs.json +2 -2
  56. package/dist/discovery/trust-report.md +1 -1
  57. package/dist/discovery/workspaces.json +2 -2
  58. package/dist/hooks/dispatch.js +789 -401
  59. package/dist/install/install.mjs +21 -4
  60. package/dist/mcp/registry-manifest.json +2 -2
  61. package/dist/mcp/server.json +2 -2
  62. package/dist/mcp/server.mjs +99 -7
  63. package/dist/router.json +1 -1
  64. package/dist/server/schemas/settings.js +6 -0
  65. package/dist/server/schemas/settings.js.map +1 -1
  66. package/docs/contracts/settings-classes.md +5 -2
  67. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
  68. package/package.json +2 -2
  69. package/src/agent-src/scripts/roadmap_gates.ts +119 -4
  70. package/src/agent-src/templates/AGENTS.md +1 -1
  71. package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
  72. package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
  73. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  74. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  75. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  76. package/src/config/agent-settings.template.yml +43 -2
  77. package/src/config/gate-coverage.yml +12 -0
  78. package/src/config/quorum-attendance-budget.json +10 -2
  79. package/src/scripts/_cli/cmd_doctor.ts +32 -0
  80. package/src/scripts/_cli/cmd_migrate.ts +26 -3
  81. package/src/scripts/_cli/cmd_refresh.ts +29 -0
  82. package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
  83. package/src/scripts/_dispatch.bash +6 -2
  84. package/src/scripts/_lib/hook_settings.ts +79 -0
  85. package/src/scripts/_lib/session_register.ts +67 -1
  86. package/src/scripts/_lib/ui_surface.ts +97 -0
  87. package/src/scripts/ai_council/config.ts +60 -0
  88. package/src/scripts/ai_council/events_log.ts +103 -4
  89. package/src/scripts/ai_council/quorum.ts +83 -6
  90. package/src/scripts/capture_skill_catalogue.ts +483 -0
  91. package/src/scripts/council_cli.ts +36 -1
  92. package/src/scripts/hook_manifest.yaml +18 -3
  93. package/src/scripts/hooks/concern_registry.ts +2 -0
  94. package/src/scripts/hooks/design_slop_hook.ts +1 -1
  95. package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
  96. package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
  97. package/src/scripts/lint_framework_leakage.ts +195 -28
  98. package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
  99. package/src/scripts/lint_roadmap_complexity.ts +60 -0
  100. package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
  101. package/src/scripts/memory_lookup.ts +1 -2
  102. package/src/scripts/report_consultation_rate.ts +358 -0
  103. package/src/scripts/report_skill_activation.ts +5 -1
  104. package/src/scripts/schemas/command.schema.json +4 -0
  105. package/src/scripts/schemas/skill.schema.json +4 -0
  106. package/src/scripts/session_register_hook.ts +238 -18
  107. package/src/scripts/sessions_cli.ts +377 -12
  108. package/src/server/schemas/settings.ts +12 -0
  109. /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
  110. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
  111. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
  112. /package/dist/agent-src/skills/react-shadcn-ui/{reference → references}/registry.md +0 -0
@@ -163,6 +163,27 @@ expect([403, 404]).toContain((await api.get(`/users/${otherTenantUser.id}`)).sta
163
163
 
164
164
  Deriving from seeded data is necessary but **not sufficient** — a test that only reads back the exact fields it seeded is still overfit (a mutation in the code survives it). A real test also exercises boundary (empty / null / max / Unicode), error (missing / invalid), and, on security-sensitive paths, an abuse case (IDOR, injection string, XSS payload). Coverage of *cases*, not just lines.
165
165
 
166
+ **The adversarial variant, and it is the one that looks safest.** A *negative* test — the abuse case just named — fails the same way, but nothing about it reads as weak: it names an attack, it asserts a refusal, it goes green. It passes because **something other than the control under test** produced that refusal. Two shapes, both from one real review:
167
+
168
+ - **The hostile value sits only in the argument.** A guard rejecting a path-traversing `token` was pinned by planting a fixture whose own `token` field held the *legitimate* value while `'../outside'` was passed as the argument. Downstream, an ordinary token comparison mismatched and returned exactly the expected refusal — delete the guard and the test still passes. The hostile value belongs in the **field under test**, not only in the call.
169
+ - **The assertion cannot fail.** The same test asserted the planted file still existed after a rejected claim. The two directories were siblings, so both `../x.json` joins resolved to one path and the rename could never have moved it — a green no implementation could turn red.
170
+
171
+ Gate:
172
+
173
+ ```
174
+ BEFORE trusting a negative test:
175
+ Delete or invert the control it claims to pin. Does it FAIL?
176
+ RESTORE THE CONTROL IMMEDIATELY — before any other edit, before any commit.
177
+ The mutation is a probe, never a change. A deleted guard left deleted is
178
+ a shipped vulnerability produced by a test-quality check.
179
+ IF it still passes: it is pinning something else. Name what, then fix the test.
180
+ IF an assertion cannot fail for ANY implementation: delete THE ASSERTION —
181
+ it is decoration. Only after proving vacuity, and never to turn a red test
182
+ green — that is Anti-Pattern 7 below.
183
+ ```
184
+
185
+ No mutation-testing rig required: comment the control out, run that one spec, put it back. A negative test nobody has ever watched fail is a claim, not evidence.
186
+
166
187
  ### Anti-Pattern 7 — Gaming the green (test-integrity)
167
188
 
168
189
  Symptom: a test fails, and the "fix" edits the *test* instead of the code — `.skip` / `it.skip` / `xfail` / `@Disabled` on the failing case, deleting the failing assertion, loosening `toBe(x)` to `toBeTruthy()`, or rewriting the `expected` value to whatever the (possibly buggy) code now emits. The suite goes green while the behavior stays broken — the worst outcome, because green now *hides* the bug. This is the automation-bias trap: under pressure to finish, the agent optimizes the signal (green) instead of the target (correct behavior).
@@ -211,6 +232,7 @@ This is the test-surface instance of the `autonomous-execution` N=3 / allowlist-
211
232
  - Do NOT mark a story complete until at least one test was watched failing first.
212
233
  - Do NOT hardcode an expected value the code will emit, or pin a regex to an incidental fixed date/id — derive from inputs and seeded data, and vary the input across cases.
213
234
  - Do NOT skip / `xfail` / delete a failing test, or rewrite its `expected` to the code's current output, to get a green run — fix the code, or state a real spec change in the diff.
235
+ - Do NOT trust a negative test you have never watched fail — delete the control it pins, confirm the test goes red, restore the control immediately.
214
236
 
215
237
  ## Auto-trigger keywords
216
238
 
@@ -225,6 +247,9 @@ This is the test-surface instance of the `autonomous-execution` N=3 / allowlist-
225
247
  - gaming the green
226
248
  - skip failing test
227
249
  - test integrity
250
+ - negative test
251
+ - vacuous assertion
252
+ - abuse-case test
228
253
 
229
254
  ## Provenance
230
255
 
@@ -14,7 +14,7 @@
14
14
  ## Pointers
15
15
 
16
16
  - **Filling out this AGENTS.md** — section templates, capability bullets, multi-agent entry flow, monorepo layout: [`consumer-agents-md-guide`](.augment/contexts/contracts/consumer-agents-md-guide.md).
17
- - **Behavior rules (always active)** — [`.augment/rules/`](.augment/rules/). Kernel rules are **immutable**; tighten only via the override registry.
17
+ - **Behavior rules** (9 always-on; rest routed) — [`.augment/rules/`](.augment/rules/). Kernel rules are **immutable**; tighten only via the override registry.
18
18
  - **Skills (on-demand expertise)** — domain skills surfaced by description, invoked on trigger match: [`.augment/skills/`](.augment/skills/).
19
19
  - **Commands (workflows)** — slash-commands the agent runs end-to-end (`/work`, `/create-pr`, …): [`.augment/commands/`](.augment/commands/).
20
20
  - **Project-specific docs** — your own architecture notes, roadmaps, sessions, contexts: [`agents/`](agents/).
@@ -39,7 +39,7 @@ schema_version: 1
39
39
  # CI guard: a release bump of `package.json` must update this value
40
40
  # in lockstep — see scripts/check_template_pin_drift.ts (road-to-
41
41
  # portable-runtime-and-update-check P3.3).
42
- agent_config_version: "9.34.0"
42
+ agent_config_version: "9.36.0"
43
43
 
44
44
  # --- Project identity ---
45
45
  project:
@@ -388,8 +388,7 @@ function* _iter_knowledge_entries(): Generator<[string, Record<string, unknown>]
388
388
  /**
389
389
  * Naive relevance score: max over keys of (glob-match | substring).
390
390
  *
391
- * Good enough for the `absent` path where retrieval is best-effort.
392
- * The `present` path returns a real score from agent-memory.
391
+ * Retrieval is best-effort by design — this is the only scoring path.
393
392
  */
394
393
  export function _score(entry: Record<string, unknown>, keys: string[]): number {
395
394
  if (keys.length === 0) {
@@ -214,10 +214,8 @@ export interface SignalRecord {
214
214
  /**
215
215
  * Append a signal entry. Returns the written record, or null when skipped.
216
216
  *
217
- * On `present` backend, routing to the package is a no-op here today —
218
- * the package adapter is wired in `road-to-agent-memory-integration.md`
219
- * Phase 3. For now, the file path is the single source of truth so
220
- * merge-safety is preserved in every mode.
217
+ * The file path is the single source of truth: there is no external backend
218
+ * to route to, so merge-safety holds in every mode.
221
219
  */
222
220
  export function emit(
223
221
  entry_type: string,
@@ -41,6 +41,22 @@ type Any = unknown;
41
41
 
42
42
  const _BLOCK_REASON_PREFIX = 'decision_gate';
43
43
 
44
+ /**
45
+ * Stage an action for confirmation and return the surface lines that name it.
46
+ *
47
+ * An injected collaborator rather than a direct import of
48
+ * `staged_confirmation_store.js`, for two reasons. The gate is called from a
49
+ * hook bus with no repo root and no clock of its own, and — the load-bearing
50
+ * one — an absent stager keeps the `ask` path byte-identical to what ships
51
+ * today. The confirmation primitive is deliberately UNBOUND until blocker
52
+ * `confirmation-degraded-host-semantics` is decided (5 of 8 hosts have no
53
+ * `pre_tool_use` slot), so reaching it has to be an explicit opt-in and not a
54
+ * behaviour change that arrives with an upgrade.
55
+ */
56
+ export interface ConfirmationStager {
57
+ stage(input: { action: string; object: string; source: string }): readonly string[];
58
+ }
59
+
44
60
  /**
45
61
  * Evaluate decision-engine gates on every `AFTER_STEP`.
46
62
  *
@@ -49,9 +65,11 @@ const _BLOCK_REASON_PREFIX = 'decision_gate';
49
65
  */
50
66
  export class DecisionGateHook {
51
67
  private readonly _settings: DecisionEngineSettings;
68
+ private readonly _stager: ConfirmationStager | null;
52
69
 
53
- constructor(settings: DecisionEngineSettings) {
70
+ constructor(settings: DecisionEngineSettings, stager: ConfirmationStager | null = null) {
54
71
  this._settings = settings;
72
+ this._stager = stager;
55
73
  }
56
74
 
57
75
  /** Register the gate callback on `AFTER_STEP`. */
@@ -109,10 +127,22 @@ export class DecisionGateHook {
109
127
  DecisionGateHook._surface(decision, 'ask_timeout'),
110
128
  );
111
129
  }
112
- throw new HookHalt(
113
- `${_BLOCK_REASON_PREFIX}:${decision.gate_id}`,
114
- DecisionGateHook._surface(decision),
115
- );
130
+ // `stop` and `ask` share this exit: both halt, which is the policy —
131
+ // ask and action are strictly sequential, so an interactive gate does
132
+ // not act while it asks. What `ask` adds, and only when a stager is
133
+ // injected, is a token on the surface so the answer can be matched to
134
+ // THIS question and consumed exactly once. The halt reason is
135
+ // deliberately unchanged: it is the identity downstream readers match on.
136
+ let surface = DecisionGateHook._surface(decision);
137
+ if (action === 'ask' && this._stager !== null) {
138
+ const extra = this._stager.stage({
139
+ action: `work_engine:advance:${decision.gate_id}`,
140
+ object: `phase ${decision.phase}`,
141
+ source: _BLOCK_REASON_PREFIX,
142
+ });
143
+ surface = [...surface, '', ...extra];
144
+ }
145
+ throw new HookHalt(`${_BLOCK_REASON_PREFIX}:${decision.gate_id}`, surface);
116
146
  }
117
147
 
118
148
  // -- formatting helpers -------------------------------------------
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Staged confirmation — the pure half of exactly-once confirmed execution.
3
+ *
4
+ * `src/rules/non-destructive-by-default.md` already carries the whole policy:
5
+ * an irreversible action is confirmed on THIS turn, ask and action are strictly
6
+ * sequential ("Never act while asking"), and the approval names the exact
7
+ * object. What the tree had no mechanism for is the *sequencing itself* — a
8
+ * staged action, a confirmation that matches it, and the guarantee that a
9
+ * second approval of the same stage does not fire the action twice.
10
+ *
11
+ * Two properties carry that guarantee, and they are split across two files on
12
+ * purpose:
13
+ *
14
+ * - **Identity is derived, not invented.** {@link stageAction} hashes the
15
+ * action, the exact object, and a caller-supplied nonce into a token, so a
16
+ * confirmation can only match the stage it was issued for. Restaging the
17
+ * same object under a fresh nonce yields a different token — an approval
18
+ * never carries over to "the next one like it".
19
+ * - **The claim is atomic, and it lives in the store.** Nothing here can be
20
+ * exactly-once by itself: two processes reading the same `pending` record
21
+ * and both deciding "execute" is a check-then-act race no pure function can
22
+ * close. This module decides WHAT the outcome should be;
23
+ * `staged_confirmation_store.ts` makes the transition atomic with a
24
+ * directory rename, which either succeeds once or fails.
25
+ *
26
+ * Nothing binds on this yet, deliberately. `pre_tool_use` exists on 3 of 8
27
+ * platform rows (`hook_manifest.yaml`), so a guard that claimed to enforce
28
+ * confirmation would be prose on the other five while reading like a control —
29
+ * the failure `src/rules/ui-audit-gate.md` names about its own scope. The
30
+ * decision is deferred behind blocker `confirmation-degraded-host-semantics`;
31
+ * until it lands, callers opt in explicitly.
32
+ *
33
+ * Pure: no filesystem, no clock, no randomness. `now` and `nonce` are
34
+ * parameters so a test pins a whole lifecycle without faking globals.
35
+ */
36
+
37
+ import { createHash } from 'node:crypto';
38
+
39
+ /**
40
+ * Lifecycle of one staged action. `expired` is not a stored state — it is
41
+ * derived from the clock by {@link stageStatus}, so a stale pending record
42
+ * cannot read as awaiting-approval merely because nobody swept it.
43
+ */
44
+ export type StageState = 'pending' | 'confirmed' | 'declined';
45
+
46
+ /** What {@link confirmOnce} concluded. Only `execute` authorizes the action. */
47
+ export type ConfirmOutcome =
48
+ | 'execute'
49
+ | 'already-confirmed'
50
+ | 'expired'
51
+ | 'declined'
52
+ | 'token-mismatch';
53
+
54
+ /**
55
+ * The `on_block_fallback` vocabulary from
56
+ * `work_engine/scoring/decision_engine.ts` (`stop` / `warn`, default `stop`),
57
+ * reused rather than re-invented: this module extends that gate's ask path, so
58
+ * a second fallback vocabulary would be a second contract to keep in sync.
59
+ */
60
+ export type NonInteractiveFallback = 'stop' | 'warn';
61
+
62
+ /**
63
+ * Default staging window. A confirmation the user never gives must not sit in
64
+ * the store as an approval waiting to be collected: an abandoned stage that
65
+ * stays valid indefinitely is a pre-approved irreversible action, which is the
66
+ * opposite of what the policy asks for. Fifteen minutes is long enough for a
67
+ * human to read the surface and answer, short enough that a turn which ended
68
+ * hours ago cannot authorize anything.
69
+ */
70
+ export const STAGE_TTL_MS = 15 * 60 * 1000;
71
+
72
+ export interface StagedAction {
73
+ /** Confirmation token — derived, never guessable from the object alone. */
74
+ readonly token: string;
75
+ /** What would fire: a command name, a skill slug, a concern id. */
76
+ readonly action: string;
77
+ /**
78
+ * The exact object the approval names — a path, a URL, a recipient, an
79
+ * amount. The policy's "names the exact object" clause lives here, which is
80
+ * why it is required rather than optional: a stage with no object is a
81
+ * category confirmation, and the rule forbids exactly that.
82
+ */
83
+ readonly object: string;
84
+ /** Who staged it — the surface a `gates --pending` reader needs. */
85
+ readonly source: string;
86
+ readonly staged_at: string;
87
+ readonly expires_at: string;
88
+ readonly state: StageState;
89
+ /** Set on the transition out of `pending`, never rewritten afterwards. */
90
+ readonly resolved_at?: string;
91
+ }
92
+
93
+ /** ISO-8601 seconds, matching the runtime records already in the tree. */
94
+ function iso(ms: number): string {
95
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
96
+ }
97
+
98
+ /**
99
+ * Derive the confirmation token.
100
+ *
101
+ * The nonce is a parameter and not read from a clock or an RNG here, so the
102
+ * derivation is reproducible in a test. It is also what makes two stages of the
103
+ * same object distinct: without it, approving a delete of `x` once would
104
+ * produce a token that matches every future delete of `x`.
105
+ *
106
+ * The separator is NUL, written as an escape sequence and never as a raw byte.
107
+ * NUL is the right separator — it cannot occur in an action, a path or a nonce,
108
+ * so no two different triples concatenate to the same string. A RAW NUL in a
109
+ * text source would make git, grep and file(1) treat the whole file as binary
110
+ * and skip it silently, which `lint_hidden_unicode` blocks on; the escape keeps
111
+ * the domain separation and leaves the file readable by every tool.
112
+ */
113
+ export function deriveToken(action: string, object: string, nonce: string): string {
114
+ return createHash('sha256')
115
+ .update(`${action}\u0000${object}\u0000${nonce}`)
116
+ .digest('hex')
117
+ .slice(0, 16);
118
+ }
119
+
120
+ /** Stage an action. Returns the record; it is the caller's job to persist it. */
121
+ export function stageAction(args: {
122
+ action: string;
123
+ object: string;
124
+ source: string;
125
+ nonce: string;
126
+ now: number;
127
+ ttlMs?: number;
128
+ }): StagedAction {
129
+ const ttl = args.ttlMs ?? STAGE_TTL_MS;
130
+ return {
131
+ token: deriveToken(args.action, args.object, args.nonce),
132
+ action: args.action,
133
+ object: args.object,
134
+ source: args.source,
135
+ staged_at: iso(args.now),
136
+ expires_at: iso(args.now + ttl),
137
+ state: 'pending',
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Effective status at `now` — `expired` is derived, not stored.
143
+ *
144
+ * A pending record past its `expires_at` reads as `expired` even though no
145
+ * sweep has run, so the guarantee does not depend on a pruner having been
146
+ * called. Prune is housekeeping; this is the decision.
147
+ */
148
+ export function stageStatus(stage: StagedAction, now: number): StageState | 'expired' {
149
+ if (stage.state === 'pending' && Date.parse(stage.expires_at) <= now) {
150
+ return 'expired';
151
+ }
152
+ return stage.state;
153
+ }
154
+
155
+ export function isExpired(stage: StagedAction, now: number): boolean {
156
+ return stageStatus(stage, now) === 'expired';
157
+ }
158
+
159
+ /**
160
+ * Decide what a confirmation attempt means. `execute` — and only `execute` —
161
+ * authorizes the action, and it is returned at most once per stage because the
162
+ * caller writes the returned record back before acting.
163
+ *
164
+ * The token is compared in full: a confirmation for a different stage is
165
+ * `token-mismatch` rather than a silent no-op, because a mismatch is a real
166
+ * signal (a stale surface, a copied token) and swallowing it would hide it.
167
+ */
168
+ export function confirmOnce(
169
+ stage: StagedAction,
170
+ token: string,
171
+ now: number,
172
+ ): { outcome: ConfirmOutcome; stage: StagedAction } {
173
+ if (token !== stage.token) {
174
+ return { outcome: 'token-mismatch', stage };
175
+ }
176
+ const status = stageStatus(stage, now);
177
+ if (status === 'confirmed') {
178
+ return { outcome: 'already-confirmed', stage };
179
+ }
180
+ if (status === 'declined') {
181
+ return { outcome: 'declined', stage };
182
+ }
183
+ if (status === 'expired') {
184
+ return { outcome: 'expired', stage };
185
+ }
186
+ return {
187
+ outcome: 'execute',
188
+ stage: { ...stage, state: 'confirmed', resolved_at: iso(now) },
189
+ };
190
+ }
191
+
192
+ /** Decline a stage. Idempotent for the same reason `confirmOnce` is. */
193
+ export function declineStage(
194
+ stage: StagedAction,
195
+ token: string,
196
+ now: number,
197
+ ): { declined: boolean; stage: StagedAction } {
198
+ if (token !== stage.token || stageStatus(stage, now) !== 'pending') {
199
+ return { declined: false, stage };
200
+ }
201
+ return { declined: true, stage: { ...stage, state: 'declined', resolved_at: iso(now) } };
202
+ }
203
+
204
+ /**
205
+ * What a non-interactive context does with a stage.
206
+ *
207
+ * Both fallbacks REFUSE — `decision_engine`'s `stop` and `warn` differ in how
208
+ * loudly they surface, never in whether the gated thing proceeds, and a staged
209
+ * irreversible action is the last place to invent an auto-approve. Naming the
210
+ * `warn` case explicitly is the point: a reader looking for the permissive
211
+ * branch finds it absent on purpose.
212
+ */
213
+ export function nonInteractiveDecision(fallback: NonInteractiveFallback): {
214
+ execute: false;
215
+ surface: 'halt' | 'warn';
216
+ } {
217
+ return { execute: false, surface: fallback === 'warn' ? 'warn' : 'halt' };
218
+ }
219
+
220
+ /**
221
+ * The confirmation surface, in the numbered-options shape
222
+ * `src/rules/user-interaction.md` requires. Returned as lines rather than
223
+ * written, so the same text serves a hook halt, a CLI pane, and a chat reply.
224
+ *
225
+ * `confirmWith` / `declineWith` are the caller's channel and are NOT defaulted
226
+ * to a command name. Which channel confirms a staged action is precisely what
227
+ * blocker `confirmation-degraded-host-semantics` has not decided — a
228
+ * `pre_tool_use` reply on three hosts, prose on the other five — and printing
229
+ * an invocation that does not exist yet would put a false instruction into a
230
+ * shipped surface. Until a binding caller supplies one, the surface names the
231
+ * token and says plainly that no channel is wired.
232
+ */
233
+ export function confirmationSurface(
234
+ stage: StagedAction,
235
+ channel?: { confirmWith?: string; declineWith?: string },
236
+ ): string[] {
237
+ const confirm = channel?.confirmWith;
238
+ const decline = channel?.declineWith;
239
+ return [
240
+ `Confirmation required: ${stage.action}`,
241
+ `Object: ${stage.object}`,
242
+ `Staged by: ${stage.source} · token ${stage.token} · expires ${stage.expires_at}`,
243
+ confirm === undefined
244
+ ? `1) Confirm — quote token ${stage.token} back to the caller that staged it.`
245
+ : `1) Confirm — ${confirm}`,
246
+ decline === undefined
247
+ ? '2) Decline — say so; the stage is resolved without firing.'
248
+ : `2) Decline — ${decline}`,
249
+ '3) Do nothing — the stage expires and the action never fires.',
250
+ 'Pending stages: `agent-config gates --pending`.',
251
+ ];
252
+ }