@event4u/agent-config 9.25.0 → 9.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +87 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/conformance.md +2 -2
  5. package/dist/agent-src/commands/review/changes.md +6 -5
  6. package/dist/agent-src/commands/roadmap/next.md +194 -0
  7. package/dist/agent-src/commands/roadmap.md +12 -4
  8. package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
  9. package/dist/agent-src/rules/delegation-policy.md +3 -2
  10. package/dist/agent-src/rules/design-fidelity.md +17 -1
  11. package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
  12. package/dist/agent-src/rules/evaluator-independence.md +9 -3
  13. package/dist/agent-src/rules/session-canary.md +2 -0
  14. package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
  15. package/dist/agent-src/rules/token-budget-discipline.md +6 -3
  16. package/dist/agent-src/rules/ui-audit-gate.md +3 -0
  17. package/dist/agent-src/rules/user-interaction.md +3 -2
  18. package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
  19. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  20. package/dist/cli-delegate/cmd_route_audit.js +4 -1
  21. package/dist/cli-delegate/cmd_route_explain.js +4 -1
  22. package/dist/discovery/deprecation-report.md +1 -1
  23. package/dist/discovery/discovery-manifest.json +94 -23
  24. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  25. package/dist/discovery/discovery-manifest.summary.md +4 -4
  26. package/dist/discovery/orphan-report.md +1 -1
  27. package/dist/discovery/packs.json +11 -8
  28. package/dist/discovery/trust-report.md +3 -3
  29. package/dist/discovery/workspaces.json +9 -6
  30. package/dist/hooks/dispatch.js +355 -63
  31. package/dist/mcp/registry-manifest.json +3 -3
  32. package/dist/mcp/server.json +2 -2
  33. package/dist/router.json +1 -1
  34. package/dist/server/nicknameResolver.js +116 -0
  35. package/dist/server/nicknameResolver.js.map +1 -0
  36. package/dist/server/routes/ping.js +29 -11
  37. package/dist/server/routes/ping.js.map +1 -1
  38. package/dist/shared/interactiveContext.js +73 -0
  39. package/dist/shared/interactiveContext.js.map +1 -0
  40. package/dist/shared/nicknamePrefill.js +59 -0
  41. package/dist/shared/nicknamePrefill.js.map +1 -0
  42. package/dist/shared/settingsAsks.js +128 -0
  43. package/dist/shared/settingsAsks.js.map +1 -0
  44. package/dist/shared/settingsConsent.js +93 -0
  45. package/dist/shared/settingsConsent.js.map +1 -0
  46. package/package.json +1 -1
  47. package/src/config/agents-paths.yml +15 -0
  48. package/src/config/gate-coverage.yml +29 -1
  49. package/src/config/gate-violation-baselines.json +13 -7
  50. package/src/scripts/_cli/cmd_route_audit.ts +4 -1
  51. package/src/scripts/_cli/cmd_route_explain.ts +9 -1
  52. package/src/scripts/ai-video/stitch.sh +27 -9
  53. package/src/scripts/before_complete_hook.ts +23 -4
  54. package/src/scripts/bench_ab_v2_run.ts +272 -16
  55. package/src/scripts/bench_ab_v2_stats.ts +156 -3
  56. package/src/scripts/check_completion_review.ts +80 -3
  57. package/src/scripts/check_corpus_staleness.ts +692 -0
  58. package/src/scripts/check_gate_coverage.ts +67 -0
  59. package/src/scripts/check_hook_bundle_freshness.ts +183 -0
  60. package/src/scripts/conformance_scan.ts +47 -17
  61. package/src/scripts/dispatch_r2_reviewer.ts +31 -2
  62. package/src/scripts/git_authorization_hook.ts +75 -10
  63. package/src/scripts/hook_manifest.yaml +19 -0
  64. package/src/scripts/hooks/block_no_verify.ts +3 -1
  65. package/src/scripts/hooks/block_unauthorized_git.ts +133 -16
  66. package/src/scripts/hooks/design_slop_hook.ts +13 -1
  67. package/src/scripts/hooks/dispatch_hook.ts +97 -2
  68. package/src/scripts/hooks/evidence_independence.ts +40 -6
  69. package/src/scripts/language_mirror_hook.ts +200 -11
  70. package/src/scripts/lint_abstraction_thresholds.ts +41 -8
  71. package/src/scripts/lint_agents_layout.ts +2 -0
  72. package/src/scripts/lint_hook_manifest.ts +29 -0
  73. package/src/scripts/memory_learn_hook.ts +109 -6
  74. package/src/scripts/pr_url_reminder_hook.ts +17 -2
  75. package/src/scripts/release.ts +38 -1
  76. package/src/scripts/schemas/review-findings.schema.json +99 -0
  77. package/src/shared/interactiveContext.ts +98 -0
  78. package/src/shared/nicknamePrefill.ts +96 -0
  79. package/src/shared/settingsAsks.ts +176 -0
  80. package/src/shared/settingsConsent.ts +136 -0
@@ -0,0 +1,99 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "review-findings.schema.json",
4
+ "title": "Review findings",
5
+ "description": "ONE findings shape for both review tracks. All six external reviewers of 9.18.1 converged on a single ask: the R2 gate hand-parses Markdown and carries `unbalanced-fence` and `malformed-row` as first-class violation kinds — i.e. it has defect classes FOR ITS OWN PARSER. This schema is deliberately NOT a new format: it is the shape `self_review_gate.ts` already emits under `--findings-out` and `check_finding_dispositions.ts` already hand-validates in code, lifted into a file both tracks can point at. Inventing a second JSON shape here would have been the exact fragmentation the reviewers complained about.",
6
+ "type": "object",
7
+ "required": [
8
+ "schema_version",
9
+ "findings"
10
+ ],
11
+ "additionalProperties": true,
12
+ "properties": {
13
+ "schema_version": {
14
+ "enum": [
15
+ 1
16
+ ],
17
+ "description": "Pinned. `check_finding_dispositions.parse_ledger` already rejects anything else, so this is a promotion of an existing check rather than a new constraint. Spelled as a one-member `enum` rather than `const`: the repo's own Draft-07 subset validator (validate_frontmatter.ts) enforces `enum` and silently ignores `const`, so `const` here would have been a pin nothing checked."
18
+ },
19
+ "release": {
20
+ "type": "string",
21
+ "description": "Version the ledger belongs to. Present on the release-findings ledger (agents/evidence/release-findings/<version>.json); absent on a bare --findings-out emission, which is why it is not required here."
22
+ },
23
+ "findings": {
24
+ "type": "array",
25
+ "description": "May be empty. An empty array is an honest null, not a missing field — the distinction the Markdown track spells with its own §2.3 line. The item shape is INLINED rather than `$ref`-ed for the same measured reason: the subset validator does not resolve `$ref`, so a referenced definition would have validated nothing.",
26
+ "items": {
27
+ "type": "object",
28
+ "required": [
29
+ "severity",
30
+ "kind",
31
+ "title"
32
+ ],
33
+ "additionalProperties": true,
34
+ "properties": {
35
+ "finding_id": {
36
+ "type": "string",
37
+ "pattern": "^[0-9a-f]{12}$",
38
+ "description": "First 12 hex of sha256(`kind|title|file`), per findingId() in self_review_gate.ts. Stable across runs, which is what lets the disposition ledger key on it. NOT required: an in-flight finding may not have been keyed yet, and requiring it would make the schema reject the shape the model returns before the gate stamps it."
39
+ },
40
+ "severity": {
41
+ "enum": [
42
+ "critical",
43
+ "high",
44
+ "medium",
45
+ "low"
46
+ ],
47
+ "description": "The JSON track's four levels. The Markdown track's own severity vocabulary is a superset in spirit but a different list; mapping between them is a caller's job, not this schema's — see the mapping note in the description of `kind`."
48
+ },
49
+ "kind": {
50
+ "enum": [
51
+ "security",
52
+ "claim",
53
+ "correctness",
54
+ "style"
55
+ ],
56
+ "description": "Finding class. Load-bearing for the id: findingId() hashes kind|title|file, so a kind change is a new finding rather than an edit of the old one."
57
+ },
58
+ "title": {
59
+ "type": "string",
60
+ "minLength": 1,
61
+ "maxLength": 300,
62
+ "description": "Truncated at 300 by parseFindings() in self_review_gate.ts; the bound is pinned here so a producer cannot emit a title the consumer will silently cut."
63
+ },
64
+ "detail": {
65
+ "type": "string",
66
+ "maxLength": 2000,
67
+ "description": "Truncated at 2000 by parseFindings(). Same reasoning as `title`."
68
+ },
69
+ "file": {
70
+ "type": "string",
71
+ "description": "Repo-relative path. Optional because a finding can be about the change as a whole; findingId() folds an absent file to the empty string so the id stays stable either way."
72
+ },
73
+ "status": {
74
+ "enum": [
75
+ "open",
76
+ "fixed",
77
+ "accepted_risk",
78
+ "false_positive"
79
+ ],
80
+ "description": "Disposition. `fixed` / `false_positive` / `accepted_risk` are the three terminal states check_finding_dispositions accepts; `open` is the initial state. NOTE the underscore spelling — the Markdown track writes `accepted-risk` with a hyphen in its Status cell, and the two vocabularies are deliberately not unified here: renaming either would break committed artefacts on that track."
81
+ },
82
+ "rationale": {
83
+ "type": "string",
84
+ "description": "Why the disposition. Required by check_finding_dispositions for a non-`fixed` terminal status; enforced there rather than here because the requirement is conditional on the workflow stage, not on the document."
85
+ },
86
+ "verified_by": {
87
+ "type": "string",
88
+ "description": "Who or what confirmed the disposition."
89
+ },
90
+ "date": {
91
+ "type": "string",
92
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
93
+ "description": "ISO calendar date of the disposition."
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Is there a human at the other end of this execution?
3
+ *
4
+ * Phase 4 of `road-to-zero-ceremony-settings`: *"Skip cleanly in non-TTY, CI,
5
+ * and headless contexts: no file, defaults, no questions, ever."* A settings
6
+ * question posed where nobody can answer it is not a question — it is either a
7
+ * hang or a silently-taken default wearing a prompt's clothes.
8
+ *
9
+ * WHY THIS MODULE EXISTS RATHER THAN A FOURTH INLINE CHECK. The tree already
10
+ * carries several CI-detection shapes that disagree:
11
+ *
12
+ * - `set && !== '0'` — `src/cli/firstRunNotice.ts`, `src/cli/initRouting.ts`
13
+ * - truthy only — `src/scripts/install.ts`, `src/scripts/release.ts`
14
+ * - exact match — `src/scripts/_lib/exec_evidence.ts` (`'true' | '1'`)
15
+ * - widest, three variables — `src/scripts/reach_doctor.ts`
16
+ * (`CI`, `GITHUB_ACTIONS`, `AGENT_CONFIG_CI`)
17
+ *
18
+ * This module takes the **widest** shape, because the failure modes are
19
+ * asymmetric: a false "interactive" hangs an automated run or fabricates a
20
+ * consent, while a false "non-interactive" merely declines to ask and takes a
21
+ * conservative default — which is the documented behaviour anyway.
22
+ *
23
+ * It deliberately does NOT rewrite those four call sites. Their behaviour is
24
+ * correct for what they gate (a printed notice, a browser launch), and
25
+ * converting them here would be a drive-by change to code this concern does not
26
+ * own. The divergence is recorded rather than repaired — see the roadmap note on
27
+ * Phase 4 step 3.
28
+ *
29
+ * Pure — the environment and the TTY flags are arguments, never read from
30
+ * `process` inside the predicate, so a test never depends on the machine it
31
+ * runs on.
32
+ */
33
+
34
+ /** Why an execution cannot host a question. `null` means it can. */
35
+ export type NonInteractiveReason =
36
+ | 'ci'
37
+ | 'no-ui-requested'
38
+ | 'not-a-tty'
39
+ | 'headless';
40
+
41
+ export interface SessionProbe {
42
+ /** `process.env`, or a fixture. */
43
+ env: NodeJS.ProcessEnv;
44
+ /** `process.stdin.isTTY === true`. */
45
+ stdinTty: boolean;
46
+ /** `process.stdout.isTTY === true`. */
47
+ stdoutTty: boolean;
48
+ /**
49
+ * A display-less environment (SSH without forwarding, Linux without
50
+ * `DISPLAY`). Supplied by the caller because probing it is platform work,
51
+ * and `src/cli/commands/uiServe.ts` already owns that probe.
52
+ */
53
+ headless?: boolean;
54
+ }
55
+
56
+ /**
57
+ * The CI variables this predicate honours, widest-first.
58
+ *
59
+ * A variable counts as set when it is present and not the literal `0` — the
60
+ * `set && !== '0'` convention already used by the first-run notice, kept so a
61
+ * consumer who exports `CI=0` to mean "not CI" is believed.
62
+ */
63
+ const CI_ENV_KEYS = ['CI', 'GITHUB_ACTIONS', 'AGENT_CONFIG_CI'] as const;
64
+
65
+ function _flagSet(env: NodeJS.ProcessEnv, key: string): boolean {
66
+ const raw = (env[key] ?? '').trim();
67
+ return raw !== '' && raw !== '0';
68
+ }
69
+
70
+ /**
71
+ * The reason this execution may not ask, or `null` when a human is reachable.
72
+ *
73
+ * Order is deliberate and is the order a reader should think in: an explicit
74
+ * request not to be asked outranks a capability probe, and CI outranks
75
+ * everything because a CI run that asks is a CI run that hangs.
76
+ */
77
+ export function nonInteractiveReason(probe: SessionProbe): NonInteractiveReason | null {
78
+ for (const key of CI_ENV_KEYS) {
79
+ if (_flagSet(probe.env, key)) {
80
+ return 'ci';
81
+ }
82
+ }
83
+ if (_flagSet(probe.env, 'AGENT_CONFIG_NO_UI')) {
84
+ return 'no-ui-requested';
85
+ }
86
+ if (!probe.stdinTty || !probe.stdoutTty) {
87
+ return 'not-a-tty';
88
+ }
89
+ if (probe.headless === true) {
90
+ return 'headless';
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /** Convenience inverse — `true` when a question is answerable. */
96
+ export function isInteractiveSession(probe: SessionProbe): boolean {
97
+ return nonInteractiveReason(probe) === null;
98
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * What to put in the nickname field before the user types anything.
3
+ *
4
+ * Phase 4 step 2 of `road-to-zero-ceremony-settings`: *"Ask the nickname once,
5
+ * prefilled from git user name then `$USER`, so accepting is one keypress."*
6
+ * `src/rules/settings-ask-protocol.md` states the chain and closes it with a
7
+ * negation — **"Never `$USER` alone."**
8
+ *
9
+ * WHY THIS MODULE EXISTS. The rule shipped that chain; the one surface that
10
+ * actually prefills shipped a different one. `src/server/routes/ping.ts` read
11
+ * `userInfo().username` and nothing else — precisely the `$USER`-alone shape the
12
+ * rule forbids. A rule and the code it describes disagreeing is drift, not a
13
+ * preference, so the chain now has exactly one implementation and both the
14
+ * server route and the tests read it from here.
15
+ *
16
+ * WHY GIT COMES FIRST. `$USER` is a login handle (`mathiasberg`); `git config
17
+ * user.name` is the name the human chose to be called by (`Mathias Berg`). The
18
+ * whole point of the prefill is that accepting costs one keypress, and that only
19
+ * holds when the prefilled value is the one they would have typed. Note the
20
+ * lookup is deliberately NOT `--local`: the merged config includes
21
+ * `~/.gitconfig`, so the wizard still finds a name when it runs outside a repo.
22
+ *
23
+ * Pure: the git lookup is injected, so the resolver is a function of its probe
24
+ * alone and a test never depends on the machine it runs on — the same shape as
25
+ * `interactiveContext.ts` and `settingsConsent.ts`. The Node-side half (running
26
+ * git, falling back to `userInfo()`) lives in `src/server/nicknameResolver.ts`,
27
+ * because `src/shared/**` may not import Node built-ins and the lint enforces it.
28
+ *
29
+ * @see src/server/nicknameResolver.ts — the impure half
30
+ * @see src/rules/settings-ask-protocol.md § Worked example — the nickname
31
+ * @see docs/contracts/settings-classes.md
32
+ */
33
+
34
+ /**
35
+ * Where a candidate name came from, so a caller can say so.
36
+ *
37
+ * `os-account` is the `userInfo()` floor, and it is a distinct member rather
38
+ * than a reuse of `env-user` on purpose: the whole claim of the floor is that
39
+ * its RANK changed (last, not first), and a rank nothing can observe is not a
40
+ * rank. A caller reporting provenance would otherwise name the wrong rung on
41
+ * exactly the path the floor exists to serve.
42
+ *
43
+ * `none` is a real outcome, not an error: a machine with no git identity, no
44
+ * `USER` and no resolvable account is unusual but legal, and the ask still
45
+ * works — it just starts empty.
46
+ */
47
+ export type NicknameSource =
48
+ | 'git-user-name'
49
+ | 'env-user'
50
+ | 'env-username'
51
+ | 'os-account'
52
+ | 'none';
53
+
54
+ export interface NicknamePrefill {
55
+ /** The prefill value, or `''` when nothing resolved. */
56
+ name: string;
57
+ source: NicknameSource;
58
+ }
59
+
60
+ export interface NicknameProbe {
61
+ /** `process.env`, or a fixture. */
62
+ env: NodeJS.ProcessEnv;
63
+ /**
64
+ * `git config user.name`, or `undefined` when git is absent, unconfigured,
65
+ * or too slow. Injected so the resolver stays pure.
66
+ */
67
+ gitUserName?: string | undefined;
68
+ }
69
+
70
+ /** Trim, and treat a whitespace-only value as absent. */
71
+ function _clean(value: string | undefined): string {
72
+ return (value ?? '').trim();
73
+ }
74
+
75
+ /**
76
+ * The prefill for the nickname ask, following the documented chain.
77
+ *
78
+ * Order is the rule's, and the negation at the end of it is the load-bearing
79
+ * part: `$USER` is a fallback, never the first answer.
80
+ */
81
+ export function nicknamePrefill(probe: NicknameProbe): NicknamePrefill {
82
+ const git = _clean(probe.gitUserName);
83
+ if (git !== '') {
84
+ return { name: git, source: 'git-user-name' };
85
+ }
86
+ const user = _clean(probe.env['USER']);
87
+ if (user !== '') {
88
+ return { name: user, source: 'env-user' };
89
+ }
90
+ // Windows spells it differently; the rule names it explicitly.
91
+ const username = _clean(probe.env['USERNAME']);
92
+ if (username !== '') {
93
+ return { name: username, source: 'env-username' };
94
+ }
95
+ return { name: '', source: 'none' };
96
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The one-question budget, as a function rather than as a promise.
3
+ *
4
+ * Phase 5 of `road-to-zero-ceremony-settings`. The rule
5
+ * `src/rules/settings-ask-protocol.md` states the budget — at most ONE settings
6
+ * question per command execution, every further undecided key taking its
7
+ * conservative default silently and being named in the end-summary. Prose is
8
+ * where that obligation has to live, because no gate can count the questions in
9
+ * a chat turn.
10
+ *
11
+ * But the *split* is not a judgement call: given the keys an execution needs
12
+ * decided, which one is asked and what the others resolve to is fully
13
+ * determined. Leaving that determined part in prose too would mean the roadmap's
14
+ * own exit criterion — "a planted fixture needing three B decisions asks once,
15
+ * assumes two conservatively, and lists both in the summary" — had nothing to
16
+ * run against. So the split is computed here and the rule points at it.
17
+ *
18
+ * What this module does NOT do, stated so nobody mistakes it for the fence:
19
+ * it does not ask, it does not write, and it cannot stop an agent that ignores
20
+ * it. The write fence is one layer down and is real —
21
+ * `src/scripts/_cli/cmd_settings_set.ts` and the Fastify settings route both
22
+ * refuse every C-class key and fail closed on an unreadable class contract.
23
+ *
24
+ * Pure — no I/O, no clock, no environment.
25
+ *
26
+ * @see src/rules/settings-ask-protocol.md
27
+ * @see docs/contracts/settings-classes.md
28
+ */
29
+
30
+ import { classOfPath, isConservativeDefault, type SettingsClass } from './settingsClasses.js';
31
+
32
+ /** A key this execution resolved without asking, and how to change it later. */
33
+ export interface SilentDefault {
34
+ key: string;
35
+ /** The value taken — the template default, which for B is conservative. */
36
+ value: unknown;
37
+ /** The exact command that changes it, for the end-summary line. */
38
+ command: string;
39
+ }
40
+
41
+ /** A key that never reaches the ask path, and the reason it does not. */
42
+ export interface SkippedKey {
43
+ key: string;
44
+ reason:
45
+ | 'class-a-never-asked'
46
+ | 'class-c-guarded'
47
+ | 'unclassified'
48
+ | 'already-decided'
49
+ | 'non-conservative-default';
50
+ }
51
+
52
+ export interface AskPlan {
53
+ /** The single key that gets this execution's question, or `null` for none. */
54
+ ask: string | null;
55
+ /** Undecided keys that took their default silently, in the order given. */
56
+ silent: SilentDefault[];
57
+ /** Keys excluded from the ask path entirely, with the reason. */
58
+ skipped: SkippedKey[];
59
+ }
60
+
61
+ /**
62
+ * The end-summary line for a silently defaulted key.
63
+ *
64
+ * `--source manual` and not `jit-answer`: the user did not answer a question, so
65
+ * stamping the write as a just-in-time answer would record a consent nobody
66
+ * gave. The provenance vocabulary exists to keep exactly that distinction.
67
+ */
68
+ export function changeCommand(key: string): string {
69
+ return `agent-config settings:set ${key} <value> --source manual`;
70
+ }
71
+
72
+ /**
73
+ * Plan one execution's settings asks.
74
+ *
75
+ * `needed` is the keys this execution genuinely cannot proceed without, in the
76
+ * order they became needed — the caller's own ordering is respected because the
77
+ * first key to actually block the run is the one that earned the question.
78
+ *
79
+ * `decided` is every key already settled on any resolution layer. A decided key
80
+ * is not a candidate: re-asking a settled question is the cheap question the
81
+ * ask-discipline rules forbid, and it is the single most likely way this budget
82
+ * gets spent on nothing.
83
+ *
84
+ * `defaultOf` supplies the template default. It returns `undefined` for a key
85
+ * the template does not carry, which is treated as unclassified rather than as
86
+ * a default of `undefined` — a missing template entry is a defect, not a value.
87
+ */
88
+ export function planSettingsAsks(
89
+ needed: readonly string[],
90
+ classes: ReadonlyMap<string, SettingsClass>,
91
+ defaultOf: (key: string) => unknown,
92
+ decided: ReadonlySet<string> = new Set(),
93
+ options: { interactive?: boolean } = {},
94
+ ): AskPlan {
95
+ const plan: AskPlan = { ask: null, silent: [], skipped: [] };
96
+ const seen = new Set<string>();
97
+ // Non-TTY / CI / headless: nobody can answer, so the budget is ZERO rather
98
+ // than one. Every candidate takes its conservative default and the plan
99
+ // carries no ask — "no questions, ever", as a computed fact rather than as a
100
+ // promise the caller has to remember. Nothing is written either: this
101
+ // function never persists, and with no ask there is no `jit-answer` write.
102
+ const interactive = options.interactive ?? true;
103
+
104
+ for (const key of needed) {
105
+ // A duplicate in the caller's list must not consume the budget twice.
106
+ if (seen.has(key)) {
107
+ continue;
108
+ }
109
+ seen.add(key);
110
+
111
+ if (decided.has(key)) {
112
+ plan.skipped.push({ key, reason: 'already-decided' });
113
+ continue;
114
+ }
115
+
116
+ const cls = classOfPath(classes, key);
117
+ if (cls === undefined) {
118
+ // Unclassified is guarded everywhere else in this system; it is
119
+ // guarded here too. Never fall back to "probably fine".
120
+ plan.skipped.push({ key, reason: 'unclassified' });
121
+ continue;
122
+ }
123
+ if (cls === 'A') {
124
+ plan.skipped.push({ key, reason: 'class-a-never-asked' });
125
+ continue;
126
+ }
127
+ if (cls === 'C') {
128
+ // A C key set to `ask` still produces a RUNTIME question, but that
129
+ // answer lasts one run and is never persisted, so it is not this
130
+ // budget's business. Persisting it stays on the human path.
131
+ plan.skipped.push({ key, reason: 'class-c-guarded' });
132
+ continue;
133
+ }
134
+
135
+ const fallback = defaultOf(key);
136
+ if (fallback === undefined) {
137
+ plan.skipped.push({ key, reason: 'unclassified' });
138
+ continue;
139
+ }
140
+
141
+ if (interactive && plan.ask === null) {
142
+ plan.ask = key;
143
+ continue;
144
+ }
145
+
146
+ if (!isConservativeDefault(fallback)) {
147
+ // Taking a permissive value silently would be a decision made in
148
+ // the user's name — the one outcome the Iron Law names outright.
149
+ // Surfacing it as skipped forces the caller to deal with it instead
150
+ // of inheriting a permission nobody granted.
151
+ plan.skipped.push({ key, reason: 'non-conservative-default' });
152
+ continue;
153
+ }
154
+
155
+ plan.silent.push({ key, value: fallback, command: changeCommand(key) });
156
+ }
157
+
158
+ return plan;
159
+ }
160
+
161
+ /**
162
+ * The end-summary block for a plan, or `null` when nothing was defaulted.
163
+ *
164
+ * `null` rather than an empty string so a caller cannot append a heading with no
165
+ * rows under it. This text rides INSIDE the single end-summary that
166
+ * `direct-answers` already requires — it is never a second summary.
167
+ */
168
+ export function silentDefaultsSummary(plan: AskPlan): string | null {
169
+ if (plan.silent.length === 0) {
170
+ return null;
171
+ }
172
+ const lines = plan.silent.map(
173
+ (s) => `- \`${s.key}\` → ${JSON.stringify(s.value)} (default) · change: \`${s.command}\``,
174
+ );
175
+ return `Settings resolved without asking this run:\n${lines.join('\n')}`;
176
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Is a permissive B-class value a RECORDED CONSENT, or just a value?
3
+ *
4
+ * Phase 5 of `road-to-zero-ceremony-settings`, and the answer to that
5
+ * roadmap's Risk 3 — *"the provenance stamp becomes decoration; an unread stamp
6
+ * is cost with no return"*. Before this module the sidecar
7
+ * `settings/.agent-settings.provenance.json` was written by `settings:set` and
8
+ * read by exactly one caller, the GUI, to **display** it. Nothing decided
9
+ * anything on it. This is the reader that makes it load-bearing.
10
+ *
11
+ * The distinction it draws, which a bare value cannot: `true` that a human chose
12
+ * and `true` that the machine inferred are the same byte and are not the same
13
+ * permission. `auto-detected` is the provenance a machine writes about itself —
14
+ * a locale, an installed binary, a detected host. Letting it grant a consent
15
+ * would mean the agent could arrive at its own permission by observing the
16
+ * world, which is the confused-deputy shape in miniature.
17
+ *
18
+ * Modelled on `src/scripts/hooks/block_unauthorized_git.ts`: answer from a
19
+ * recorded fact rather than from the model's recollection, and treat a missing
20
+ * record as NOT granted rather than as probably-fine.
21
+ *
22
+ * WHAT THIS IS NOT. It is not a hook and it does not intercept anything. No
23
+ * B-class key currently gates an action through a guard — the two that gate
24
+ * behaviour at all (`personal.open_edited_files`, `memory.learn_on_session_end`)
25
+ * are read as values by the installer, the wizard and one session_end concern,
26
+ * and the *action* they govern is prose. So the enforcement gradient is honest
27
+ * and steep: this function is deterministic, the obligation to call it is
28
+ * model-carried, and on a prose-only host ask-once can degrade to ask-never.
29
+ *
30
+ * Pure — no I/O. Callers supply the value and the sidecar entry.
31
+ *
32
+ * @see src/rules/settings-ask-protocol.md
33
+ * @see docs/contracts/settings-classes.md
34
+ */
35
+
36
+ import { isConservativeDefault, type SettingsClass } from './settingsClasses.js';
37
+
38
+ /** The provenance vocabulary `settings:set` writes. Mirrors its own enum. */
39
+ export type ConsentSource = 'auto-detected' | 'jit-answer' | 'manual' | 'gui';
40
+
41
+ /**
42
+ * The sources that can carry a human decision.
43
+ *
44
+ * `jit-answer` is a question the user answered. `gui` is a human at the settings
45
+ * editor. `manual` is a hand-edit or an explicit CLI write on the user's
46
+ * instruction. `auto-detected` is excluded on purpose — see the module header.
47
+ */
48
+ const HUMAN_SOURCES: readonly ConsentSource[] = ['jit-answer', 'gui', 'manual'];
49
+
50
+ export type ConsentVerdict =
51
+ /** A human decided, and the value permits the action. */
52
+ | 'granted'
53
+ /** The value is the conservative default — absent and no are the same thing. */
54
+ | 'withheld-default'
55
+ /** Permissive, but nothing records who decided it. Fail closed. */
56
+ | 'withheld-unrecorded'
57
+ /** Permissive and recorded, but recorded as a machine inference. */
58
+ | 'withheld-machine-inferred'
59
+ /** Not a consent question at all: A needs none, C is human-only. */
60
+ | 'not-a-consent-key';
61
+
62
+ export interface ConsentQuery {
63
+ /** The key's class, from the contract — `undefined` means unclassified. */
64
+ cls: SettingsClass | undefined;
65
+ /** The effective value after normal resolution. */
66
+ value: unknown;
67
+ /**
68
+ * The sidecar entry for this key, or `undefined` when none exists.
69
+ *
70
+ * `undefined` covers both "the sidecar has no row" and "there is no sidecar",
71
+ * which are the same fact from here: nothing records a decision.
72
+ */
73
+ source?: ConsentSource | undefined;
74
+ /**
75
+ * `true` when the value came from a file only a human can write — a
76
+ * project-local `.agent-settings.yml` or a hand-edited user file.
77
+ *
78
+ * The class contract is explicit that *"the user may edit anything in their
79
+ * own file with an editor"*, so a hand-edit IS the consent even though it
80
+ * carries no stamp. Without this the check would refuse the one path the
81
+ * contract guarantees, which is how a fence becomes a bug.
82
+ */
83
+ handEdited?: boolean;
84
+ }
85
+
86
+ /**
87
+ * The verdict for one consent-gated action.
88
+ *
89
+ * Order matters: class first (a C key never reaches a consent question at all),
90
+ * then the value (conservative means no, regardless of provenance), then the
91
+ * record.
92
+ */
93
+ export function consentVerdict(query: ConsentQuery): ConsentVerdict {
94
+ if (query.cls !== 'B') {
95
+ return 'not-a-consent-key';
96
+ }
97
+ if (isConservativeDefault(query.value)) {
98
+ return 'withheld-default';
99
+ }
100
+ if (query.handEdited === true) {
101
+ return 'granted';
102
+ }
103
+ if (query.source === undefined) {
104
+ return 'withheld-unrecorded';
105
+ }
106
+ if (!HUMAN_SOURCES.includes(query.source)) {
107
+ return 'withheld-machine-inferred';
108
+ }
109
+ return 'granted';
110
+ }
111
+
112
+ /** `true` only for `granted` — every other verdict withholds. */
113
+ export function consentGranted(query: ConsentQuery): boolean {
114
+ return consentVerdict(query) === 'granted';
115
+ }
116
+
117
+ /**
118
+ * One line explaining a withheld verdict, for the surface that refused.
119
+ *
120
+ * `null` for `granted`, so a caller cannot accidentally print a refusal reason
121
+ * on the allow path.
122
+ */
123
+ export function withheldReason(verdict: ConsentVerdict, key: string): string | null {
124
+ switch (verdict) {
125
+ case 'granted':
126
+ return null;
127
+ case 'withheld-default':
128
+ return `${key} is at its conservative default — absent and no are the same answer.`;
129
+ case 'withheld-unrecorded':
130
+ return `${key} permits this, but nothing records who decided it. Ask, or set it via the GUI.`;
131
+ case 'withheld-machine-inferred':
132
+ return `${key} was auto-detected, not decided. A machine inference is not a consent.`;
133
+ case 'not-a-consent-key':
134
+ return `${key} is not a class-B consent key — this gate does not apply to it.`;
135
+ }
136
+ }