@event4u/agent-config 9.33.0 → 9.35.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 +154 -145
- package/CONTRIBUTING.md +23 -14
- package/dist/agent-src/commands/agent-handoff.md +15 -3
- package/dist/agent-src/commands/roadmap/next.md +37 -5
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
- package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
- package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
- package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
- 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-PMQ6Z3T3.js → chunk-2TGMGLBL.js} +11 -4
- package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
- package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
- package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
- package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
- package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
- package/dist/cli-delegate/cmd_conformance.js +2 -2
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +7 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +13 -13
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +2 -2
- package/dist/discovery/dormancy-report.md +17 -0
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +3 -3
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +3 -3
- package/dist/hooks/dispatch.js +355 -85
- package/dist/install/install.mjs +3 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +192 -12
- package/dist/server/schemas/settings.js +1 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +3 -2
- package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
- package/package.json +2 -2
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- package/src/agent-src/scripts/roadmap_gates.ts +119 -4
- 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 +11 -2
- package/src/config/quorum-attendance-budget.json +11 -3
- package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
- package/src/scripts/_dispatch.bash +6 -2
- package/src/scripts/_lib/env_kill_switch.ts +37 -0
- package/src/scripts/_lib/security_lint.ts +18 -4
- package/src/scripts/_lib/self_repair.ts +60 -0
- package/src/scripts/_lib/self_repair_store.ts +65 -5
- package/src/scripts/_lib/session_register.ts +67 -1
- package/src/scripts/_lib/subagent_capsule.ts +78 -1
- package/src/scripts/ai_council/config.ts +60 -0
- package/src/scripts/ai_council/events_log.ts +105 -6
- package/src/scripts/ai_council/orchestrator.ts +38 -0
- package/src/scripts/ai_council/prompts.ts +165 -3
- package/src/scripts/ai_council/quorum.ts +87 -0
- package/src/scripts/ai_team/review_gate.ts +2 -2
- package/src/scripts/build_discovery_manifest.ts +165 -0
- package/src/scripts/check_claims.ts +51 -2
- package/src/scripts/ci_time_ratio.ts +3 -1
- package/src/scripts/council_cli.ts +36 -1
- package/src/scripts/discovery_graph.ts +201 -27
- package/src/scripts/dispatch_r2_reviewer.ts +102 -1
- package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
- package/src/scripts/lint_framework_leakage.ts +195 -28
- package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
- package/src/scripts/lint_handoffs.ts +92 -2
- package/src/scripts/lint_hedge_words.ts +317 -0
- package/src/scripts/lint_mcp_config_security.ts +1 -1
- package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
- package/src/scripts/memory_lookup.ts +1 -2
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/schemas/skill.schema.json +4 -0
- package/src/scripts/self_repair_cli.ts +26 -1
- package/src/scripts/session_register_hook.ts +238 -18
- package/src/scripts/sessions_cli.ts +377 -12
- package/src/scripts/skill_linter.ts +58 -2
- package/src/server/schemas/settings.ts +3 -0
|
@@ -12,18 +12,53 @@ probability cause.
|
|
|
12
12
|
|
|
13
13
|
## Lookup
|
|
14
14
|
|
|
15
|
-
| Signature (what you see) | Likely cause | First check |
|
|
16
|
-
|
|
17
|
-
| **Tool/agent loop** — same tool called >2× with near-identical args, no progress | Missing stop condition; reading without acting; ambiguous state | Stop. State the hypothesis in one line. Act or ask — do not re-read. (`context-hygiene` 15/25 rule) |
|
|
18
|
-
| **HTTP 429 / "rate limit" / "quota"** | Too-frequent calls; shared quota; missing backoff | Inspect response headers (`Retry-After`, `X-RateLimit-*`); add exponential backoff; check if a loop is firing the calls |
|
|
19
|
-
| **`ECONNREFUSED` / connection refused** | Service not up; wrong host/port; container not started | Is the service running? (`docker compose ps`, health endpoint); is the port right? (`lsof -i :PORT`); is it bound to `127.0.0.1` vs `0.0.0.0`? |
|
|
20
|
-
| **`ENOENT` / "no such file or directory"** | Wrong cwd; path relative vs absolute; file not generated yet | Print the *resolved absolute* path; check cwd; confirm a prior step actually wrote the file |
|
|
21
|
-
| **Timeout / hang** | Blocking I/O; deadlock; awaiting a condition that never holds | What is it waiting on? Add a log right before the blocking call; bound the wait; check for a missing `await`/`return` |
|
|
22
|
-
| **`OOM` / killed (137) / heap out of memory** | Unbounded collection; loading all rows; leak in a loop | What grows per iteration? Stream/paginate instead of loading all; check `--max-old-space-size` only after finding the growth |
|
|
23
|
-
| **`EACCES` / permission denied** | Wrong file mode; running as wrong user; read-only mount | `ls -l` the target; check the process user; is the path a read-only volume? |
|
|
24
|
-
| **Flaky test (passes alone, fails in suite)** | Shared state; order dependence; real clock/random; unawaited async | Run the test in isolation vs in suite; look for shared fixtures, global state, `Date.now()`/random, missing `await` |
|
|
25
|
-
| **Works locally, fails in CI** | Env/config drift; missing service; absent secret; path/case sensitivity | Diff env (`printenv`); is the service/secret present in CI? Linux is case-sensitive — check filename casing |
|
|
26
|
-
| **Intermittent / "sometimes"** | Race; timing; network; non-deterministic order | Reproduce with condition-based waiting, not fixed sleeps (`systematic-debugging` § Condition-based waiting); log timestamps |
|
|
15
|
+
| ID | Signature (what you see) | Likely cause | First check |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| `tool-loop` | **Tool/agent loop** — same tool called >2× with near-identical args, no progress | Missing stop condition; reading without acting; ambiguous state | Stop. State the hypothesis in one line. Act or ask — do not re-read. (`context-hygiene` 15/25 rule) |
|
|
18
|
+
| `rate-limited` | **HTTP 429 / "rate limit" / "quota"** | Too-frequent calls; shared quota; missing backoff | Inspect response headers (`Retry-After`, `X-RateLimit-*`); add exponential backoff; check if a loop is firing the calls |
|
|
19
|
+
| `connection-refused` | **`ECONNREFUSED` / connection refused** | Service not up; wrong host/port; container not started | Is the service running? (`docker compose ps`, health endpoint); is the port right? (`lsof -i :PORT`); is it bound to `127.0.0.1` vs `0.0.0.0`? |
|
|
20
|
+
| `path-not-found` | **`ENOENT` / "no such file or directory"** | Wrong cwd; path relative vs absolute; file not generated yet | Print the *resolved absolute* path; check cwd; confirm a prior step actually wrote the file |
|
|
21
|
+
| `hang` | **Timeout / hang** | Blocking I/O; deadlock; awaiting a condition that never holds | What is it waiting on? Add a log right before the blocking call; bound the wait; check for a missing `await`/`return` |
|
|
22
|
+
| `out-of-memory` | **`OOM` / killed (137) / heap out of memory** | Unbounded collection; loading all rows; leak in a loop | What grows per iteration? Stream/paginate instead of loading all; check `--max-old-space-size` only after finding the growth |
|
|
23
|
+
| `permission-denied` | **`EACCES` / permission denied** | Wrong file mode; running as wrong user; read-only mount | `ls -l` the target; check the process user; is the path a read-only volume? |
|
|
24
|
+
| `order-dependent-test` | **Flaky test (passes alone, fails in suite)** | Shared state; order dependence; real clock/random; unawaited async | Run the test in isolation vs in suite; look for shared fixtures, global state, `Date.now()`/random, missing `await` |
|
|
25
|
+
| `local-ci-drift` | **Works locally, fails in CI** | Env/config drift; missing service; absent secret; path/case sensitivity | Diff env (`printenv`); is the service/secret present in CI? Linux is case-sensitive — check filename casing |
|
|
26
|
+
| `race` | **Intermittent / "sometimes"** | Race; timing; network; non-deterministic order | Reproduce with condition-based waiting, not fixed sleeps (`systematic-debugging` § Condition-based waiting); log timestamps |
|
|
27
|
+
| `stale-capability-claim` | **Budget exhausted plus repeated attempts at a tool that is not available** | A capability the session claimed for itself instead of resolving — the council, a hook slot, a subagent primitive, an MCP server assumed present | Ask the live verb, not the tree: `agent-config hooks:status` for slot bindings, `agent-config routing:doctor` for per-field capability **provenance** (`registry` / `live-probe` / `default`), `agent-config council:status` for council availability. A `false` from `default` records that nobody answered, not that the host cannot |
|
|
28
|
+
|
|
29
|
+
### Naming convention for IDs
|
|
30
|
+
|
|
31
|
+
An id is a short kebab handle so a signature can be **cited by name** — the
|
|
32
|
+
first column was prose, so nothing could reference a row.
|
|
33
|
+
|
|
34
|
+
One rule governs new ids: **where a row's signature is a stable string the
|
|
35
|
+
tooling already prints, the id is that string verbatim** rather than a kebab
|
|
36
|
+
synonym. `skill_linter.ts` emits a closed set of `Issue` codes —
|
|
37
|
+
`missing_section`, `missing_conditional_section`, `invalid_execution_handler` —
|
|
38
|
+
and a row about one of those is looked up by the code the reader just saw in the
|
|
39
|
+
output. No current row is of that class; the convention is stated for the next
|
|
40
|
+
one, so the table does not accumulate two names for the same failure.
|
|
41
|
+
|
|
42
|
+
## Discrimination drills
|
|
43
|
+
|
|
44
|
+
A row's "first check" is only worth following if the documented signature is what
|
|
45
|
+
actually appears. Each drill breaks the named layer and asserts the documented
|
|
46
|
+
symptom — run it once when you doubt a row, or when adding one. They are text a
|
|
47
|
+
human runs; nothing here is a CI gate.
|
|
48
|
+
|
|
49
|
+
| ID | Drill — break this, expect that |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `tool-loop` | Ask for a fact that is genuinely absent from the tree (a config key nothing defines). Expect repeated searches with shrinking variation and no edit — that shape, not an error message, is the signature. |
|
|
52
|
+
| `rate-limited` | Fire the provider's own rate limit with a tight loop against a cheap endpoint. Expect `429` **plus** `Retry-After`; a `429` with no such header is a different failure (a gateway, not the API). |
|
|
53
|
+
| `connection-refused` | Stop the service and call it (`docker compose stop <svc>`). Expect `ECONNREFUSED` immediately. If it hangs instead, the host resolves but nothing listens — that is `hang`, not this row. |
|
|
54
|
+
| `path-not-found` | Run a path-taking script from a different cwd. Expect `ENOENT` naming a path that is *almost* right — the near-miss is the tell that the path is relative, not missing. |
|
|
55
|
+
| `hang` | `await` a promise nothing resolves. Expect no output and no exit, distinguishable from `race` by being 100% reproducible. |
|
|
56
|
+
| `out-of-memory` | Accumulate rows in an array inside a loop over a large fixture. Expect the heap message and exit 137 — and note that the reported line is where allocation failed, not where the growth is. |
|
|
57
|
+
| `permission-denied` | `chmod 000` a fixture and read it. Expect `EACCES`, not `ENOENT` — confusing the two sends you looking for a missing file that is right there. |
|
|
58
|
+
| `order-dependent-test` | Add a module-level mutable to two tests in one file. Expect green alone, red in suite. Reversing the file order should move the failure — if it does not, the cause is shared *external* state, not order. |
|
|
59
|
+
| `local-ci-drift` | Rename a fixture's casing (`Foo.ts` → `foo.ts`) and import the old name. Expect green on macOS, red on Linux CI. The generalisation: anything the local filesystem is lenient about. |
|
|
60
|
+
| `race` | Replace a condition-based wait with a fixed `sleep` shorter than the operation. Expect intermittent failure whose rate moves with machine load — load-sensitivity is what separates this from `order-dependent-test`. |
|
|
61
|
+
| `stale-capability-claim` | Assert a capability without asking for it, then compare against the live verb. `agent-config routing:doctor` prints provenance per field; a field whose provenance is `default` was never established by anything — which is exactly the state a confident self-claim is indistinguishable from. |
|
|
27
62
|
|
|
28
63
|
## How to use
|
|
29
64
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@event4u/agent-config",
|
|
3
3
|
"mcpName": "io.github.event4u-app/agent-config",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.35.0",
|
|
5
5
|
"description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"build:mcp-catalog": "./scripts-run src/scripts/build_mcp_catalog --write --strict",
|
|
78
78
|
"build": "npm run build:cli && npm run build:install-bundle && npm run build:hooks && npm run build:mcp-bundle && npm run build:cli-delegate && npm run build:ui",
|
|
79
79
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.scripts.json",
|
|
80
|
-
"lint:ts": "eslint 'src/**/*.ts'",
|
|
80
|
+
"lint:ts": "eslint --cache 'src/**/*.ts'",
|
|
81
81
|
"test:ts": "vitest run",
|
|
82
82
|
"lint:publint": "publint",
|
|
83
83
|
"prepack": "npm run build && npm run build:discovery && npm run build:mcp-manifest && node src/scripts/prepack-check.mjs",
|
|
@@ -128,12 +128,42 @@ function _branch_touched_paths(root: string, base: string): Set<string> | null {
|
|
|
128
128
|
return out;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Paths where a repo-relative path is DATA, not a live reference — so
|
|
133
|
+
* rewriting it falsifies a record instead of keeping a link resolvable.
|
|
134
|
+
*
|
|
135
|
+
* Two classes, and the second is why this predicate exists at all:
|
|
136
|
+
*
|
|
137
|
+
* - `agents/evidence/**` — a committed record of what was true, or of what a
|
|
138
|
+
* reviewer was shown, at one point in time. Retroactively editing it makes it
|
|
139
|
+
* claim something that did not happen, which is the failure
|
|
140
|
+
* `evaluator-independence` exists to prevent ("the prompt ships with the
|
|
141
|
+
* verdict" is worthless if a later sweep can edit the prompt).
|
|
142
|
+
* - `*.patch` anywhere — a patch's `diff --git a/… b/…` and `+++ b/…` lines are
|
|
143
|
+
* the payload. Rewriting them detaches the patch from the commit it records
|
|
144
|
+
* AND makes `git apply` target a path the diff never created.
|
|
145
|
+
*
|
|
146
|
+
* Measured 2026-08-11: archiving one roadmap rewrote four frozen artefacts
|
|
147
|
+
* under `agents/evidence/reviews/`, including the `diff.patch` headers of a
|
|
148
|
+
* recorded review input. Nothing caught it — the sweep reported it as
|
|
149
|
+
* "4 ref(s) migrated", which reads like success.
|
|
150
|
+
*/
|
|
151
|
+
const _FROZEN_RECORD_PREFIXES: readonly string[] = ['agents/evidence/'];
|
|
152
|
+
|
|
153
|
+
/** Is `rel` a frozen record whose path strings must not be rewritten? */
|
|
154
|
+
export function _is_frozen_record(rel: string): boolean {
|
|
155
|
+
const norm = rel.split(path.sep).join('/');
|
|
156
|
+
return _FROZEN_RECORD_PREFIXES.some((p) => norm.startsWith(p)) || norm.endsWith('.patch');
|
|
157
|
+
}
|
|
158
|
+
|
|
131
159
|
/**
|
|
132
160
|
* Rewrite full-path references `old_rel` → `new_rel` in tracked files.
|
|
133
161
|
*
|
|
134
162
|
* Only the exact repo-relative path is rewritten (bare-filename mentions are
|
|
135
163
|
* left alone). The archived file's own path never matches because the search
|
|
136
|
-
* string is the un-archived path.
|
|
164
|
+
* string is the un-archived path. Frozen records are skipped — see
|
|
165
|
+
* `_is_frozen_record`; a link left dangling in a historical record is correct,
|
|
166
|
+
* because the record describes a tree that no longer exists.
|
|
137
167
|
*/
|
|
138
168
|
function _inbound_ref_rewrite(
|
|
139
169
|
root: string,
|
|
@@ -165,6 +195,10 @@ function _inbound_ref_rewrite(
|
|
|
165
195
|
// skip the roadmap file itself
|
|
166
196
|
continue;
|
|
167
197
|
}
|
|
198
|
+
if (_is_frozen_record(rel)) {
|
|
199
|
+
// A record of a past state — rewriting it falsifies the record.
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
168
202
|
const fp = path.join(root, rel);
|
|
169
203
|
let text: string;
|
|
170
204
|
try {
|
|
@@ -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,
|
|
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,
|
|
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 {
|
|
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 };
|
|
@@ -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
|
+
}
|