@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
@@ -46,6 +46,13 @@
46
46
  * ./agent-config gates --all # every open blocker, grouped by owner
47
47
  * ./agent-config gates --json # machine-readable
48
48
  * ./agent-config gates --reply # reply-close form; empty when none
49
+ * ./agent-config gates --pending # staged actions awaiting confirmation
50
+ *
51
+ * `--pending` reads a different source — the staged-confirmation store, not the
52
+ * roadmap tree — and stays out of `--reply` on purpose. ADR-222 fixes the
53
+ * reply-close form at exactly ONE decision rendered in full; folding a second
54
+ * source into it would silently change which decision "the one" is, and that
55
+ * contract is not this step's to move.
49
56
  */
50
57
 
51
58
  import * as path from 'node:path';
@@ -58,6 +65,8 @@ import {
58
65
  blocker_needs_user as needsUser,
59
66
  type Blocker,
60
67
  } from './update_roadmap_progress.js';
68
+ import { listPending } from '../templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.js';
69
+ import type { StagedAction } from '../templates/scripts/work_engine/hooks/builtin/staged_confirmation.js';
61
70
 
62
71
  const _HERE = fileURLToPath(import.meta.url);
63
72
 
@@ -327,6 +336,89 @@ function renderJson(entries: readonly Entry[], all: boolean): string {
327
336
  );
328
337
  }
329
338
 
339
+ /**
340
+ * `--pending`: staged actions awaiting a human confirmation.
341
+ *
342
+ * A second source under the same verb, and the reason it belongs here rather
343
+ * than in a new command is the verb's own definition — "open decisions that
344
+ * need you, as actions". A staged irreversible action IS one, and the most
345
+ * literal kind: the roadmap blockers above are decisions a human owes a plan,
346
+ * this is a decision a human owes an action that is already loaded and waiting.
347
+ *
348
+ * Expired stages are counted, never listed as actionable. An expired stage
349
+ * cannot execute (`stageStatus` derives that from the clock, not from a sweep
350
+ * having run), so rendering it beside a live one would put two rows in front of
351
+ * the reader where only one is a decision.
352
+ */
353
+ function renderPending(root: string, now: number): string {
354
+ const rows = listPending(root, now);
355
+ const live = rows.filter((r) => r.status === 'pending');
356
+ const expired = rows.filter((r) => r.status === 'expired');
357
+ const lines: string[] = [];
358
+
359
+ if (live.length === 0) {
360
+ lines.push(
361
+ expired.length === 0
362
+ ? 'No staged actions awaiting confirmation.'
363
+ : `Nothing awaits your confirmation. ${expired.length} stage` +
364
+ `${expired.length !== 1 ? 's' : ''} expired unconfirmed — the action never fired.`,
365
+ );
366
+ return lines.join('\n') + '\n';
367
+ }
368
+
369
+ lines.push(
370
+ `${live.length} staged action${live.length !== 1 ? 's' : ''} await` +
371
+ `${live.length === 1 ? 's' : ''} your confirmation` +
372
+ (expired.length > 0 ? ` · ${expired.length} expired unconfirmed` : ''),
373
+ );
374
+ let n = 0;
375
+ for (const { stage } of live) {
376
+ n += 1;
377
+ lines.push(...renderStage(stage, n));
378
+ }
379
+ lines.push('');
380
+ return lines.join('\n') + '\n';
381
+ }
382
+
383
+ function renderStage(stage: StagedAction, index: number): string[] {
384
+ const head = `${index} · ${stage.action}`;
385
+ const tail = `token: ${stage.token}`;
386
+ const dashes = Math.max(3, WIDTH - head.length - tail.length - 6);
387
+ return [
388
+ '',
389
+ `── ${head} ${'─'.repeat(dashes)} ${tail}`.trimEnd(),
390
+ // The object is the first field on purpose: an approval that does not
391
+ // name the exact object is the thing non-destructive-by-default forbids,
392
+ // so it is what the reader must see before anything else.
393
+ ...field('Object:', stage.object),
394
+ ...field('Staged by:', stage.source),
395
+ ...field('Expires:', stage.expires_at),
396
+ ];
397
+ }
398
+
399
+ function renderPendingJson(root: string, now: number): string {
400
+ const rows = listPending(root, now);
401
+ return (
402
+ JSON.stringify(
403
+ {
404
+ awaitingYou: rows.filter((r) => r.status === 'pending').length,
405
+ expired: rows.filter((r) => r.status === 'expired').length,
406
+ staged: rows.map(({ stage, status }) => ({
407
+ token: stage.token,
408
+ action: stage.action,
409
+ object: stage.object,
410
+ source: stage.source,
411
+ stagedAt: stage.staged_at,
412
+ expiresAt: stage.expires_at,
413
+ status,
414
+ })),
415
+ },
416
+ null,
417
+ 2,
418
+ ) + '\n'
419
+ );
420
+ }
421
+
330
422
  function _isDir(p: string): boolean {
331
423
  try {
332
424
  return fs.statSync(p).isDirectory();
@@ -340,8 +432,8 @@ function _isDir(p: string): boolean {
340
432
  * `agents/roadmaps`, otherwise fall back to the git toplevel (monorepo
341
433
  * sub-project support).
342
434
  */
343
- function _resolveRepoRoot(start: string): string {
344
- if (_isDir(path.join(start, 'agents', 'roadmaps'))) {
435
+ function _resolveRepoRoot(start: string, marker = path.join('agents', 'roadmaps')): string {
436
+ if (_isDir(path.join(start, marker))) {
345
437
  return start;
346
438
  }
347
439
  try {
@@ -351,7 +443,7 @@ function _resolveRepoRoot(start: string): string {
351
443
  timeout: 10_000,
352
444
  });
353
445
  const top = (r.stdout || '').trim();
354
- if (r.status === 0 && top !== '' && _isDir(path.join(top, 'agents', 'roadmaps'))) {
446
+ if (r.status === 0 && top !== '' && _isDir(path.join(top, marker))) {
355
447
  return top;
356
448
  }
357
449
  } catch {
@@ -365,6 +457,18 @@ function main(argv?: readonly string[]): number {
365
457
  const all = args.includes('--all');
366
458
  const json = args.includes('--json');
367
459
  const reply = args.includes('--reply');
460
+ const pending = args.includes('--pending');
461
+
462
+ // `--pending` resolves against `agents/runtime/`, not `agents/roadmaps/`,
463
+ // and is answered BEFORE the roadmaps-directory exit below: a staged action
464
+ // is independent of whether this project plans in roadmaps at all, so
465
+ // "no roadmaps here" is not an answer to "what awaits my confirmation".
466
+ if (pending) {
467
+ const root = _resolveRepoRoot(process.cwd(), path.join('agents', 'runtime'));
468
+ const now = Date.now();
469
+ process.stdout.write(json ? renderPendingJson(root, now) : renderPending(root, now));
470
+ return 0;
471
+ }
368
472
 
369
473
  const repoRoot = _resolveRepoRoot(process.cwd());
370
474
  const roadmapRoot = path.join(repoRoot, 'agents', 'roadmaps');
@@ -412,5 +516,16 @@ if (_isCliEntry()) {
412
516
  process.exitCode = main();
413
517
  }
414
518
 
415
- export { main, needsUser, regroupTodo, wrap, collectEntries, render, renderJson, renderReply };
519
+ export {
520
+ main,
521
+ needsUser,
522
+ regroupTodo,
523
+ wrap,
524
+ collectEntries,
525
+ render,
526
+ renderJson,
527
+ renderReply,
528
+ renderPending,
529
+ renderPendingJson,
530
+ };
416
531
  export type { Entry };
@@ -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/).
@@ -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
+ }