@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
|
@@ -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 };
|
|
@@ -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/).
|
|
@@ -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
|
+
}
|