@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +64 -77
- package/CONTRIBUTING.md +23 -14
- package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
- package/dist/agent-src/commands/roadmap/next.md +37 -5
- package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
- package/dist/agent-src/rules/improve-before-implement.md +1 -1
- package/dist/agent-src/rules/ui-audit-gate.md +35 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
- package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
- package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
- package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
- package/dist/agent-src/templates/AGENTS.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/dist/cli/registry.js +2 -2
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
- package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-JP53LNVT.js} +12 -0
- package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
- package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-NR7HSN3P.js} +2 -2
- package/dist/cli-delegate/{chunk-REMEZSVP.js → chunk-SFGARMAB.js} +13 -3
- package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-U7CMAIOC.js} +7 -2
- package/dist/cli-delegate/cmd_conformance.js +4 -4
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_export.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +3 -3
- package/dist/cli-delegate/cmd_refresh.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +8 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +3 -3
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +3 -3
- package/dist/cli-delegate/cmd_uninstall.js +3 -3
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +14 -14
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +789 -401
- package/dist/install/install.mjs +21 -4
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +99 -7
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +6 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +5 -2
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
- package/package.json +2 -2
- package/src/agent-src/scripts/roadmap_gates.ts +119 -4
- package/src/agent-src/templates/AGENTS.md +1 -1
- package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/src/config/agent-settings.template.yml +43 -2
- package/src/config/gate-coverage.yml +12 -0
- package/src/config/quorum-attendance-budget.json +10 -2
- package/src/scripts/_cli/cmd_doctor.ts +32 -0
- package/src/scripts/_cli/cmd_migrate.ts +26 -3
- package/src/scripts/_cli/cmd_refresh.ts +29 -0
- package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
- package/src/scripts/_dispatch.bash +6 -2
- package/src/scripts/_lib/hook_settings.ts +79 -0
- package/src/scripts/_lib/session_register.ts +67 -1
- package/src/scripts/_lib/ui_surface.ts +97 -0
- package/src/scripts/ai_council/config.ts +60 -0
- package/src/scripts/ai_council/events_log.ts +103 -4
- package/src/scripts/ai_council/quorum.ts +83 -6
- package/src/scripts/capture_skill_catalogue.ts +483 -0
- package/src/scripts/council_cli.ts +36 -1
- package/src/scripts/hook_manifest.yaml +18 -3
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/design_slop_hook.ts +1 -1
- package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
- package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
- package/src/scripts/lint_framework_leakage.ts +195 -28
- package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
- package/src/scripts/lint_roadmap_complexity.ts +60 -0
- package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
- package/src/scripts/memory_lookup.ts +1 -2
- package/src/scripts/report_consultation_rate.ts +358 -0
- package/src/scripts/report_skill_activation.ts +5 -1
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/schemas/skill.schema.json +4 -0
- package/src/scripts/session_register_hook.ts +238 -18
- package/src/scripts/sessions_cli.ts +377 -12
- package/src/server/schemas/settings.ts +12 -0
- /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
- /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
- /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
- /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
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
218
|
-
*
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
}
|