@event4u/agent-config 16.0.0 → 16.1.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +297 -137
- package/CONTRIBUTING.md +9 -0
- package/README.md +2 -2
- package/dist/agent-src/commands/analyze/inbox.md +26 -0
- package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
- package/dist/agent-src/commands/challenge-me/closure.md +178 -0
- package/dist/agent-src/commands/challenge-me.md +9 -2
- package/dist/agent-src/commands/council/default.md +25 -5
- package/dist/agent-src/commands/feature/plan.md +28 -1
- package/dist/agent-src/commands/feature/roadmap.md +28 -1
- package/dist/agent-src/commands/implement-ticket.md +27 -0
- package/dist/agent-src/commands/jira-ticket.md +27 -0
- package/dist/agent-src/commands/pr/create.md +29 -0
- package/dist/agent-src/commands/pr/merge.md +60 -9
- package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
- package/dist/agent-src/commands/roadmap/create.md +74 -27
- package/dist/agent-src/commands/roadmap/materialize.md +27 -0
- package/dist/agent-src/commands/roadmap/process-full.md +123 -18
- package/dist/agent-src/commands/team.md +22 -0
- package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
- package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +47 -0
- package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
- package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
- package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
- package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
- package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
- package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
- package/dist/agent-src/rules/autonomous-execution.md +15 -8
- package/dist/agent-src/rules/delegation-policy.md +23 -45
- package/dist/agent-src/rules/design-fidelity.md +32 -37
- package/dist/agent-src/rules/evaluator-independence.md +43 -49
- package/dist/agent-src/rules/test-first.md +41 -0
- package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
- package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
- package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
- package/dist/agent-src/skills/database/data/manifest.json +4 -4
- package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
- package/dist/agent-src/skills/design-review/SKILL.md +50 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
- package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
- package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
- package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
- package/dist/agent-src/templates/agent-settings.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +188 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
- package/dist/cli-delegate/check_update_banner.js +40 -2
- package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
- package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
- package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
- package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
- package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
- package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
- package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
- package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
- package/dist/cli-delegate/{chunk-H32DGUJH.js → chunk-P6ZHP5W2.js} +1 -1
- package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
- package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
- package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
- package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
- package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +90 -56
- package/dist/cli-delegate/cmd_doctor.js +7 -5
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +9 -7
- package/dist/cli-delegate/cmd_fleet.js +4 -2
- package/dist/cli-delegate/cmd_handoff.js +4 -4
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_preflight.js +4 -2
- package/dist/cli-delegate/cmd_prune.js +10 -8
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_settings_get.js +28 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +10 -8
- package/dist/cli-delegate/cmd_uninstall.js +16 -13
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +12 -10
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +85 -38
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +10 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +8 -6
- package/dist/hooks/dispatch.js +1832 -1007
- package/dist/install/claudeRuleRewrite.js +14 -3
- package/dist/install/conflict.js +60 -10
- package/dist/install/conflictTracker.js +218 -0
- package/dist/install/fsPrimitives.js +54 -0
- package/dist/install/install.mjs +2007 -1481
- package/dist/install/manifestFiles.js +259 -0
- package/dist/install/preflight.js +14 -2
- package/dist/install/preserve.js +157 -0
- package/dist/install/recordedOwnership.js +132 -0
- package/dist/install/txlog.js +11 -2
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +398 -39
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +3 -2
- package/dist/scripts/_lib/fs_atomic.js +167 -0
- package/dist/scripts/_lib/installed_tools.js +460 -0
- package/dist/scripts/_lib/lean_projection_mode.js +3 -2
- package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
- package/dist/server/routes/install.js +8 -0
- package/dist/server/routes/wizard.js +9 -3
- package/dist/server/schemas/settings.js +17 -5
- package/dist/shared/missionExecution.js +97 -0
- package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
- package/dist/ui/index.html +1 -1
- package/docs/MIGRATION.md +103 -0
- package/docs/contracts/settings-classes.md +26 -7
- package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
- package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
- package/docs/guidelines/design-fidelity-routing.md +29 -0
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
- package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
- package/src/config/agent-settings.template.yml +139 -27
- package/src/config/auto-rule-budget.json +16 -0
- package/src/config/ci-local-parity.yml +18 -5
- package/src/config/continuity-surface.json +21 -0
- package/src/config/gate-coverage.yml +146 -13
- package/src/config/gate-violation-baselines.json +6 -6
- package/src/config/hook-token-budget.json +2 -0
- package/src/config/loop-surfaces.yaml +215 -0
- package/src/config/platform-anchor.json +3 -2
- package/src/config/release-gate-locality.yml +11 -0
- package/src/config/routing-coverage-seed.json +18 -4
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/install/claudeRuleRewrite.ts +16 -2
- package/src/install/conflict.ts +65 -9
- package/src/install/conflictTracker.ts +299 -0
- package/src/install/fsPrimitives.ts +54 -0
- package/src/install/manifestFiles.ts +312 -0
- package/src/install/preflight.ts +16 -2
- package/src/install/preserve.ts +205 -0
- package/src/install/recordedOwnership.ts +149 -0
- package/src/install/txlog.ts +11 -2
- package/src/install/types.ts +19 -4
- package/src/scripts/_cli/cmd_conformance.ts +88 -15
- package/src/scripts/_cli/cmd_doctor.ts +4 -3
- package/src/scripts/_cli/cmd_route_explain.ts +9 -2
- package/src/scripts/_cli/cmd_settings_get.ts +44 -0
- package/src/scripts/_cli/doctor_execution.ts +90 -0
- package/src/scripts/_lib/activation_payload.ts +247 -0
- package/src/scripts/_lib/agent_settings.ts +4 -2
- package/src/scripts/_lib/authority_path.ts +102 -0
- package/src/scripts/_lib/cascade_base.ts +94 -0
- package/src/scripts/_lib/changelog_eras.ts +7 -2
- package/src/scripts/_lib/continuation_ladder.ts +149 -2
- package/src/scripts/_lib/council_transport.ts +129 -0
- package/src/scripts/_lib/delivery_ready.ts +112 -0
- package/src/scripts/_lib/forge_protection.ts +267 -0
- package/src/scripts/_lib/hook_platform_keys.ts +27 -0
- package/src/scripts/_lib/json_python_parity.ts +127 -0
- package/src/scripts/_lib/lean_projection_mode.ts +14 -3
- package/src/scripts/_lib/loop_surfaces.ts +382 -0
- package/src/scripts/_lib/mission_record.ts +190 -0
- package/src/scripts/_lib/obligation_frequency.ts +217 -0
- package/src/scripts/_lib/obligations.ts +402 -0
- package/src/scripts/_lib/orchestration_record.ts +11 -20
- package/src/scripts/_lib/release_highlights.ts +199 -10
- package/src/scripts/_lib/release_holds.ts +493 -0
- package/src/scripts/_lib/release_migration_gate.ts +184 -0
- package/src/scripts/_lib/rides_along.ts +102 -0
- package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
- package/src/scripts/_lib/test_delta.ts +166 -0
- package/src/scripts/_lib/test_provenance.ts +171 -0
- package/src/scripts/_lib/typed_op_grant.ts +246 -0
- package/src/scripts/_lib/typed_op_watch.ts +185 -0
- package/src/scripts/ai_council/config.ts +8 -11
- package/src/scripts/ai_council/fallback_config.ts +5 -3
- package/src/scripts/ai_council/ownership_classes.ts +118 -0
- package/src/scripts/ask_block_census.ts +285 -15
- package/src/scripts/check_enforcement_matrix.ts +624 -0
- package/src/scripts/check_gate_reachability.ts +173 -10
- package/src/scripts/check_generator_sync.ts +954 -0
- package/src/scripts/check_preamble_payload_budget.ts +75 -2
- package/src/scripts/check_release_highlights.ts +33 -1
- package/src/scripts/check_release_holds.ts +567 -0
- package/src/scripts/check_rule_projection_integrity.ts +22 -12
- package/src/scripts/check_test_delta.ts +294 -0
- package/src/scripts/check_test_weakening.ts +299 -0
- package/src/scripts/closure_scan.ts +333 -0
- package/src/scripts/compile_hook_manifest.ts +43 -3
- package/src/scripts/council_record_shape.ts +209 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +88 -9
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/design_pass_hook.ts +128 -3
- package/src/scripts/hooks/evidence_independence.ts +96 -0
- package/src/scripts/hooks/memory_recall_hook.ts +540 -0
- package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
- package/src/scripts/hooks/rule_inject_hook.ts +49 -1
- package/src/scripts/hooks/run_continuation_hook.ts +17 -20
- package/src/scripts/hooks_doctor.ts +85 -0
- package/src/scripts/hooks_status.ts +19 -0
- package/src/scripts/install.ts +106 -206
- package/src/scripts/janitor.ts +7 -0
- package/src/scripts/lint_decision_classes.ts +464 -0
- package/src/scripts/lint_hook_manifest.ts +11 -0
- package/src/scripts/lint_major_migration_sections.ts +450 -0
- package/src/scripts/lint_plan_risk_register.ts +47 -3
- package/src/scripts/lint_roadmap_blockers.ts +61 -0
- package/src/scripts/lint_roadmap_producers.ts +202 -0
- package/src/scripts/measure_rule_budget.ts +101 -5
- package/src/scripts/new_roadmap.ts +37 -0
- package/src/scripts/orchestration_record.ts +0 -5
- package/src/scripts/release.ts +14 -98
- package/src/scripts/release_drill.ts +267 -1
- package/src/scripts/release_publication.ts +51 -0
- package/src/scripts/report_enforcement_drift.ts +566 -0
- package/src/scripts/report_host_injection_effect.ts +3 -11
- package/src/scripts/report_standing_payload_by_host.ts +26 -152
- package/src/scripts/schemas/agent-settings.schema.json +23 -0
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/scope_growth.ts +219 -0
- package/src/scripts/trigger_eval_grandfather.json +0 -1
- package/src/scripts/ui_conformance_probe.ts +622 -0
- package/src/server/schemas/settings.ts +31 -7
- package/src/shared/missionExecution.ts +126 -0
- package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
|
@@ -80,6 +80,223 @@ export function is_frequency(v: unknown): v is Frequency {
|
|
|
80
80
|
return typeof v === 'string' && (FREQUENCIES as readonly string[]).includes(v);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// ------------------------------------------------- declared enforcement class
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The CLOSED set of classes a rule may DECLARE in `enforced_by:`.
|
|
87
|
+
*
|
|
88
|
+
* It lives here, beside `Frequency`, because these are the two halves of one
|
|
89
|
+
* question and splitting them across files is how a third taxonomy gets born.
|
|
90
|
+
* `enforced_by` says *what kind of thing carries this*; `obligation_frequency`
|
|
91
|
+
* says *how often it comes due*. Neither answers the other, and
|
|
92
|
+
* `check_enforcement_coverage.ts` joins both.
|
|
93
|
+
*
|
|
94
|
+
* DECLARED IS NOT RESOLVED, and conflating them is the live trap.
|
|
95
|
+
* This set is what an AUTHOR may write. It is deliberately NARROWER than
|
|
96
|
+
* `Resolution` in `check_enforcement_coverage.ts:79-88`, which is what the
|
|
97
|
+
* RESOLVER may conclude. Four of the resolver's values — `validator-local`,
|
|
98
|
+
* `unwired`, `missing`, and resolved-`observer` — are findings about whether
|
|
99
|
+
* the declared carrier is actually reachable. No author can declare them,
|
|
100
|
+
* because they are not facts about intent.
|
|
101
|
+
*
|
|
102
|
+
* The asymmetry is load-bearing rather than accidental. A rule declaring
|
|
103
|
+
* `hook:design-pass` whose hook is `fail_closed: false` resolves to `observer`
|
|
104
|
+
* (`check_enforcement_coverage.ts:421-422`). Measured 2026-09-13: 11 declared
|
|
105
|
+
* hooks become 1 effective hook and 10 effective observers exactly this way.
|
|
106
|
+
* A reader who takes the resolved table for the declared one concludes the
|
|
107
|
+
* frontmatter is lying when it is merely being resolved.
|
|
108
|
+
*
|
|
109
|
+
* `observer` AND `none` ARE FIRST-CLASS HERE, and always were.
|
|
110
|
+
* Recorded because a proposed five-value taxonomy had no slot for `observer`,
|
|
111
|
+
* and adopting it would have silently remapped a value the schema already
|
|
112
|
+
* accepts and the census already counts. The falsifier is the schema pattern
|
|
113
|
+
* itself (`src/scripts/schemas/rule.schema.json`, `enforced_by.items.pattern`),
|
|
114
|
+
* which admits `observer:<reason>` and bare `none`. There was no decision to
|
|
115
|
+
* take: the set below is a transcription of a constraint the tree enforces,
|
|
116
|
+
* not a new vocabulary competing with it.
|
|
117
|
+
*
|
|
118
|
+
* Keep this in sync with that pattern. It is the same set written twice — once
|
|
119
|
+
* where JSON Schema can check authored frontmatter, once where TypeScript can
|
|
120
|
+
* check code — and `tests/scripts/obligation_frequency.test.ts` pins the
|
|
121
|
+
* two together so the pair cannot drift silently.
|
|
122
|
+
*/
|
|
123
|
+
export type EnforcementClass =
|
|
124
|
+
| 'hook'
|
|
125
|
+
| 'validator'
|
|
126
|
+
| 'test'
|
|
127
|
+
| 'observer'
|
|
128
|
+
| 'instruction-only'
|
|
129
|
+
| 'none';
|
|
130
|
+
|
|
131
|
+
export const ENFORCEMENT_CLASSES: readonly EnforcementClass[] = [
|
|
132
|
+
'hook',
|
|
133
|
+
'validator',
|
|
134
|
+
'test',
|
|
135
|
+
'observer',
|
|
136
|
+
'instruction-only',
|
|
137
|
+
'none',
|
|
138
|
+
] as const;
|
|
139
|
+
|
|
140
|
+
export function is_enforcement_class(v: unknown): v is EnforcementClass {
|
|
141
|
+
return typeof v === 'string' && (ENFORCEMENT_CLASSES as readonly string[]).includes(v);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The class a single declared `enforced_by:` entry belongs to.
|
|
146
|
+
*
|
|
147
|
+
* `null` for anything outside the closed set — never a guess and never a
|
|
148
|
+
* silent `none`, because `none` is itself a meaningful declaration ("recorded
|
|
149
|
+
* gap") and returning it for an unparseable string would convert a schema
|
|
150
|
+
* violation into a legitimate-looking record.
|
|
151
|
+
*
|
|
152
|
+
* Bare `none` is matched exactly; every other class carries a `:` payload the
|
|
153
|
+
* schema requires to be non-empty.
|
|
154
|
+
*/
|
|
155
|
+
export function enforcement_class_of(declared: string): EnforcementClass | null {
|
|
156
|
+
const trimmed = declared.trim();
|
|
157
|
+
if (trimmed === 'none') return 'none';
|
|
158
|
+
const colon = trimmed.indexOf(':');
|
|
159
|
+
if (colon <= 0) return null;
|
|
160
|
+
const head = trimmed.slice(0, colon);
|
|
161
|
+
if (!is_enforcement_class(head) || head === 'none') return null;
|
|
162
|
+
// A prefix with an empty payload is a schema violation, not a declaration.
|
|
163
|
+
if (trimmed.slice(colon + 1).trim() === '') return null;
|
|
164
|
+
return head;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The class for a whole rule's `enforced_by:` list.
|
|
169
|
+
*
|
|
170
|
+
* A rule may declare several carriers; the ledger row carries ONE class, so a
|
|
171
|
+
* choice is forced. It takes the STRONGEST by {@link ENFORCEMENT_CLASSES}
|
|
172
|
+
* order, which is the same direction `check_enforcement_coverage.ts`'s `RANK`
|
|
173
|
+
* runs, so the two never disagree about which of two declarations dominates.
|
|
174
|
+
*
|
|
175
|
+
* An empty list — a rule declaring nothing at all — is `none`. That is the
|
|
176
|
+
* honest reading: the roadmap step requiring this says the row carries `none`
|
|
177
|
+
* when the rule declares none, and a rule with no declaration has made no
|
|
178
|
+
* claim this could contradict.
|
|
179
|
+
*/
|
|
180
|
+
/**
|
|
181
|
+
* Classes whose discharge a machine could, in principle, observe.
|
|
182
|
+
*
|
|
183
|
+
* The discriminator is not severity — it is whether a mechanical discharge
|
|
184
|
+
* EXISTS to look for. `hook`, `validator` and `test` name a carrier that runs
|
|
185
|
+
* and leaves a trace. `observer`, `instruction-only` and `none` name,
|
|
186
|
+
* respectively, an instrument that cannot block, an honestly model-carried
|
|
187
|
+
* obligation, and no carrier at all — none of the three produces an artefact a
|
|
188
|
+
* detector could read, so a detector refusing on one would be demanding
|
|
189
|
+
* evidence nobody can produce.
|
|
190
|
+
*
|
|
191
|
+
* Measured 2026-09-13 over the frozen routing corpus: 69 of 97 delivered rules
|
|
192
|
+
* are class `none`. A detector without this filter would fire on the large
|
|
193
|
+
* majority of every turn, which is the "one false block and the operator
|
|
194
|
+
* disables the carrier for good" failure the risk register ranks first.
|
|
195
|
+
*
|
|
196
|
+
* The source set's proposed taxonomy also named a `judge` class that never
|
|
197
|
+
* refuses. It has no representation here because it is not in the declared
|
|
198
|
+
* vocabulary the schema admits, and adding a value to make a sentence
|
|
199
|
+
* transcribe literally would be inventing the second taxonomy this work exists
|
|
200
|
+
* to avoid. Its INTENT — a class whose discharge is a human judgement and so
|
|
201
|
+
* must never be refused on — is carried by `observer` and `instruction-only`,
|
|
202
|
+
* both excluded below.
|
|
203
|
+
*/
|
|
204
|
+
export const REFUSABLE_CLASSES: ReadonlySet<EnforcementClass> = new Set<EnforcementClass>([
|
|
205
|
+
'hook',
|
|
206
|
+
'validator',
|
|
207
|
+
'test',
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
/** Whether a detector may ever refuse on this class. */
|
|
211
|
+
export function may_refuse_on(cls: EnforcementClass): boolean {
|
|
212
|
+
return REFUSABLE_CLASSES.has(cls);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The class declared by a rule's frontmatter, read straight off the body text.
|
|
217
|
+
*
|
|
218
|
+
* Lives here rather than in the ledger that calls it, so every piece of
|
|
219
|
+
* reasoning about the class vocabulary sits in one module — the roadmap's
|
|
220
|
+
* second-ranked risk is this set drifting from the census that ratchets it,
|
|
221
|
+
* and two readers in two files is the first step of that drift.
|
|
222
|
+
*
|
|
223
|
+
* Deliberately NOT a general YAML parser and deliberately not the census's
|
|
224
|
+
* `read_frontmatter`: this runs inside a hook on the prompt path, where the
|
|
225
|
+
* cost of importing the coverage module's whole wiring corpus is paid on every
|
|
226
|
+
* fire, and the only key it needs is one list. The accepted shapes are exactly
|
|
227
|
+
* the two the rule schema permits — inline `enforced_by: [a, b]` and a block
|
|
228
|
+
* of `- ` items.
|
|
229
|
+
*
|
|
230
|
+
* A rule that declares nothing is `none`, which is the honest reading: it has
|
|
231
|
+
* made no claim, so there is none to contradict.
|
|
232
|
+
*/
|
|
233
|
+
export function enforcement_class_from_frontmatter(text: string): EnforcementClass {
|
|
234
|
+
if (!text.startsWith('---\n')) return 'none';
|
|
235
|
+
const end = text.indexOf('\n---\n', 4);
|
|
236
|
+
if (end === -1) return 'none';
|
|
237
|
+
const lines = text.slice(4, end).split('\n');
|
|
238
|
+
|
|
239
|
+
const entries: string[] = [];
|
|
240
|
+
let collecting = false;
|
|
241
|
+
for (const line of lines) {
|
|
242
|
+
if (collecting) {
|
|
243
|
+
const item = /^\s+-\s*(.+?)\s*$/.exec(line);
|
|
244
|
+
if (item !== null) {
|
|
245
|
+
entries.push(unquote(item[1] as string));
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// Any non-item line ends the block. Checked before anything else so
|
|
249
|
+
// a following key can never be read as a carrier declaration.
|
|
250
|
+
if (/^\S/.test(line)) collecting = false;
|
|
251
|
+
if (!collecting && /^\s*$/.test(line)) continue;
|
|
252
|
+
if (!collecting) break;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const m = /^enforced_by:\s*(.*)$/.exec(line);
|
|
256
|
+
if (m === null) continue;
|
|
257
|
+
const rest = (m[1] ?? '').trim();
|
|
258
|
+
if (rest === '') {
|
|
259
|
+
collecting = true;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const inline = /^\[(.*)\]$/.exec(rest);
|
|
263
|
+
if (inline !== null) {
|
|
264
|
+
for (const part of (inline[1] as string).split(',')) {
|
|
265
|
+
const v = unquote(part.trim());
|
|
266
|
+
if (v !== '') entries.push(v);
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
entries.push(unquote(rest));
|
|
270
|
+
}
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
return enforcement_class_for(entries);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Strip one layer of matching quotes, which the schema permits on any entry. */
|
|
277
|
+
function unquote(raw: string): string {
|
|
278
|
+
const s = raw.trim();
|
|
279
|
+
if (s.length >= 2 && ((s[0] === '"' && s.endsWith('"')) || (s[0] === "'" && s.endsWith("'")))) {
|
|
280
|
+
return s.slice(1, -1);
|
|
281
|
+
}
|
|
282
|
+
return s;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function enforcement_class_for(declared: readonly string[]): EnforcementClass {
|
|
286
|
+
let best: EnforcementClass = 'none';
|
|
287
|
+
let bestRank = ENFORCEMENT_CLASSES.length;
|
|
288
|
+
for (const entry of declared) {
|
|
289
|
+
const cls = enforcement_class_of(entry);
|
|
290
|
+
if (cls === null) continue;
|
|
291
|
+
const rank = ENFORCEMENT_CLASSES.indexOf(cls);
|
|
292
|
+
if (rank < bestRank) {
|
|
293
|
+
bestRank = rank;
|
|
294
|
+
best = cls;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return best;
|
|
298
|
+
}
|
|
299
|
+
|
|
83
300
|
export type Root = 'lifecycle' | 'tool-call' | 'repository' | 'external-event' | 'none';
|
|
84
301
|
|
|
85
302
|
const ROOT_OF: Record<Frequency, Root> = {
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The obligation ledger — write side.
|
|
3
|
+
*
|
|
4
|
+
* This repository already knows which obligations it delivered into a turn.
|
|
5
|
+
* `hooks/rule_inject_hook.ts` writes one record per session naming every rule
|
|
6
|
+
* whose body it injected, and until now the only reads of that path were the
|
|
7
|
+
* writer itself and its own test. The tree could say what it delivered and
|
|
8
|
+
* nothing could ask whether the turn discharged any of it.
|
|
9
|
+
*
|
|
10
|
+
* This module is the state that closes that gap, and ONLY the state. It adds
|
|
11
|
+
* no reader, no detector and no refusal — those arrive in later phases of
|
|
12
|
+
* `road-to-a-ledger-that-closes-the-loop`, deliberately after the rows exist,
|
|
13
|
+
* so that a detector is written against real recorded shapes instead of
|
|
14
|
+
* against an imagined one.
|
|
15
|
+
*
|
|
16
|
+
* WHAT A `delivered` ROW MEANS, AND WHAT IT MAY NEVER BE CITED FOR.
|
|
17
|
+
* A row records that an EMITTER RAN. It does not record that a rule reached
|
|
18
|
+
* the model, and it may not be cited as evidence that one did. That question
|
|
19
|
+
* is council-locked as blocked-by-architecture (2026-08-31, two convergent
|
|
20
|
+
* seats) on a reproducible probe: an obligation present in the shipped tree
|
|
21
|
+
* was absent from the operator's installed copy, so installation proves
|
|
22
|
+
* availability and not exposure. The lock tested a different mechanism than
|
|
23
|
+
* this one, which is why the write side is allowed at all — but a reader who
|
|
24
|
+
* turns these rows into a compliance claim has made exactly the assertion the
|
|
25
|
+
* council refused.
|
|
26
|
+
*
|
|
27
|
+
* WHY THE PATH COMES FROM A HELPER AND NOT A LITERAL.
|
|
28
|
+
* `statePathFor` below is the same shape as `before_complete_hook.ts:128` —
|
|
29
|
+
* `session_state_file(<dir>, session_id)` — and shares that function rather
|
|
30
|
+
* than reimplementing it. The digest keying matters: it removes the
|
|
31
|
+
* filename-length failure mode for long ids, and it is the property an
|
|
32
|
+
* in-file `session_id` integrity check is written against. A second
|
|
33
|
+
* hand-rolled sanitiser here would be a second shape, and two shapes for one
|
|
34
|
+
* concept is how a session's state becomes unfindable by the tool meant to
|
|
35
|
+
* read it.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
atomic_write_json,
|
|
43
|
+
is_replay_mode,
|
|
44
|
+
session_state_file,
|
|
45
|
+
update_json_under_lock,
|
|
46
|
+
} from '../hooks/state_io.js';
|
|
47
|
+
import { type EnforcementClass, is_enforcement_class } from './obligation_frequency.js';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Directory holding one ledger file per session.
|
|
51
|
+
*
|
|
52
|
+
* Under `agents/runtime/state/`, which is gitignored and janitor-pruned, and
|
|
53
|
+
* which carries a `disposition: excluded` row in
|
|
54
|
+
* `src/config/continuity-surface.json` — this is per-session instrumentation,
|
|
55
|
+
* not continuity state a later session must be able to resume from.
|
|
56
|
+
*/
|
|
57
|
+
export const OBLIGATION_STATE_DIR = path.join('agents', 'runtime', 'state', 'obligations');
|
|
58
|
+
|
|
59
|
+
/** Path of one session's ledger. Same shape as the existing consumer. */
|
|
60
|
+
export function statePathFor(session_id: string): string {
|
|
61
|
+
return session_state_file(OBLIGATION_STATE_DIR, session_id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One rule delivered into this session, with the class its frontmatter declares.
|
|
66
|
+
*
|
|
67
|
+
* `cls` rather than `class` because `class` is a reserved word, and a field
|
|
68
|
+
* named around a keyword is worse than a field named honestly. The serialised
|
|
69
|
+
* key is `class`, which is what a reader of the JSON expects to see.
|
|
70
|
+
*/
|
|
71
|
+
export interface DeliveredRow {
|
|
72
|
+
/** Rule id, as the injector names it. */
|
|
73
|
+
readonly rule: string;
|
|
74
|
+
/** Declared enforcement class, or `none` when the rule declares none. */
|
|
75
|
+
readonly cls: EnforcementClass;
|
|
76
|
+
/** ISO-8601, second precision. */
|
|
77
|
+
readonly at: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One obligation discharged this session.
|
|
82
|
+
*
|
|
83
|
+
* Written by the concern that ALREADY made the decision — the design-pass
|
|
84
|
+
* concern knows the UI audit is fresh, so it is the only thing that can say so
|
|
85
|
+
* without re-deriving a verdict it does not own. A discharge nobody observed
|
|
86
|
+
* is never inferred: absence of a row means nothing was recorded, not that
|
|
87
|
+
* nothing happened, and the shadow rows below are labelled accordingly.
|
|
88
|
+
*/
|
|
89
|
+
export interface DischargeRow {
|
|
90
|
+
/** Rule id whose obligation this discharges. */
|
|
91
|
+
readonly rule: string;
|
|
92
|
+
/** What observed it — a concern name, so a reader can go and check. */
|
|
93
|
+
readonly by: string;
|
|
94
|
+
readonly at: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One turn-end reading that WOULD have refused, had anything been armed.
|
|
99
|
+
*
|
|
100
|
+
* The whole point of the shadow phase: the detector runs, the verdict is
|
|
101
|
+
* recorded, and nothing is refused. A row here is evidence about the
|
|
102
|
+
* detector's behaviour, never about the turn's compliance.
|
|
103
|
+
*/
|
|
104
|
+
export interface ShadowRow {
|
|
105
|
+
readonly at: string;
|
|
106
|
+
/** The missing SET, as one row. Never one row per obligation — see `appendShadow`. */
|
|
107
|
+
readonly missing: readonly string[];
|
|
108
|
+
/** 1 for the first reading of a given missing set, incrementing after. */
|
|
109
|
+
readonly attempt: number;
|
|
110
|
+
/** Always true today: a row is only written when the detector would refuse. */
|
|
111
|
+
readonly would_refuse: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The on-disk shape of one session's ledger. */
|
|
115
|
+
export interface ObligationLedger {
|
|
116
|
+
/** Cheap integrity check against a copied or hand-edited file. */
|
|
117
|
+
readonly session_id: string;
|
|
118
|
+
readonly delivered: readonly DeliveredRow[];
|
|
119
|
+
readonly discharged: readonly DischargeRow[];
|
|
120
|
+
readonly shadow: readonly ShadowRow[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The JSON spelling of a row — `class` on disk, `cls` in TypeScript. */
|
|
124
|
+
interface SerialisedRow {
|
|
125
|
+
rule: string;
|
|
126
|
+
class: string;
|
|
127
|
+
at: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function serialise(row: DeliveredRow): SerialisedRow {
|
|
131
|
+
return { rule: row.rule, class: row.cls, at: row.at };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read rows back out of whatever is on disk, discarding anything malformed.
|
|
136
|
+
*
|
|
137
|
+
* Deliberately lenient about the FILE and strict about the ROW. A truncated or
|
|
138
|
+
* hand-edited ledger must not cost the rows that are still intact, and a row
|
|
139
|
+
* whose class is outside the closed set must not enter the ledger wearing a
|
|
140
|
+
* value the vocabulary denies — `is_enforcement_class` is the gate, so a
|
|
141
|
+
* future widening of the schema cannot leak in through the state file.
|
|
142
|
+
*/
|
|
143
|
+
export function parseRows(loaded: unknown): DeliveredRow[] {
|
|
144
|
+
if (typeof loaded !== 'object' || loaded === null) return [];
|
|
145
|
+
const raw = (loaded as { delivered?: unknown }).delivered;
|
|
146
|
+
if (!Array.isArray(raw)) return [];
|
|
147
|
+
const out: DeliveredRow[] = [];
|
|
148
|
+
for (const entry of raw) {
|
|
149
|
+
if (typeof entry !== 'object' || entry === null) continue;
|
|
150
|
+
const e = entry as Record<string, unknown>;
|
|
151
|
+
const rule = e['rule'];
|
|
152
|
+
const cls = e['class'];
|
|
153
|
+
const at = e['at'];
|
|
154
|
+
if (typeof rule !== 'string' || rule === '') continue;
|
|
155
|
+
if (typeof at !== 'string' || at === '') continue;
|
|
156
|
+
if (!is_enforcement_class(cls)) continue;
|
|
157
|
+
out.push({ rule, cls, at });
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** ISO-8601 to second precision — the ledger has no use for milliseconds. */
|
|
163
|
+
export function stamp(now: Date = new Date()): string {
|
|
164
|
+
return `${now.toISOString().slice(0, 19)}Z`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Append delivered rows for one session, idempotently per rule.
|
|
169
|
+
*
|
|
170
|
+
* Idempotent because the injector's own contract is once-per-session-per-rule
|
|
171
|
+
* re-armed on compaction: after a compaction a rule is legitimately delivered
|
|
172
|
+
* again, and a second row would say the same thing twice while a reader
|
|
173
|
+
* counting distinct obligations wants it once. First delivery wins, so the
|
|
174
|
+
* timestamp is the one that answers "when did this session first come under
|
|
175
|
+
* this rule".
|
|
176
|
+
*
|
|
177
|
+
* Returns the number of rows actually added — 0 is a real answer (every rule
|
|
178
|
+
* already recorded) and is not a failure.
|
|
179
|
+
*
|
|
180
|
+
* NEVER THROWS, and never fails a turn. A ledger that cannot be written is an
|
|
181
|
+
* instrument that missed a reading; a turn refused because an instrument
|
|
182
|
+
* missed a reading is a worse outcome than the missing reading, and this
|
|
183
|
+
* module has no refusal to offer in the first place.
|
|
184
|
+
*/
|
|
185
|
+
export function appendDelivered(
|
|
186
|
+
root: string,
|
|
187
|
+
session_id: string,
|
|
188
|
+
rows: readonly DeliveredRow[],
|
|
189
|
+
): number {
|
|
190
|
+
if (rows.length === 0) return 0;
|
|
191
|
+
if (is_replay_mode()) return 0;
|
|
192
|
+
if (session_id.trim() === '') return 0;
|
|
193
|
+
|
|
194
|
+
const target = path.join(root, statePathFor(session_id));
|
|
195
|
+
let added = 0;
|
|
196
|
+
try {
|
|
197
|
+
update_json_under_lock<LedgerFile>(target, (loaded) => {
|
|
198
|
+
const existing = parseRows(loaded);
|
|
199
|
+
const seen = new Set(existing.map((r) => r.rule));
|
|
200
|
+
const merged = existing.map(serialise);
|
|
201
|
+
for (const row of rows) {
|
|
202
|
+
if (seen.has(row.rule)) continue;
|
|
203
|
+
seen.add(row.rule);
|
|
204
|
+
merged.push(serialise(row));
|
|
205
|
+
added += 1;
|
|
206
|
+
}
|
|
207
|
+
if (added === 0) return null; // deliberate no-write
|
|
208
|
+
return { ...carryOver(loaded), session_id, delivered: merged };
|
|
209
|
+
});
|
|
210
|
+
} catch {
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
return added;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The on-disk file, as the lock's mutator sees it. */
|
|
217
|
+
interface LedgerFile {
|
|
218
|
+
session_id: string;
|
|
219
|
+
delivered: SerialisedRow[];
|
|
220
|
+
discharged: DischargeRow[];
|
|
221
|
+
shadow: ShadowRow[];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The arrays this write is not touching, preserved verbatim.
|
|
226
|
+
*
|
|
227
|
+
* Three writers share one file and each owns one array. Without this, the
|
|
228
|
+
* last writer of a turn silently truncates the other two — the kind of loss
|
|
229
|
+
* that shows up as a detector reporting a clean turn because the discharges
|
|
230
|
+
* it was meant to read were dropped by the delivery write that followed them.
|
|
231
|
+
*/
|
|
232
|
+
function carryOver(loaded: Partial<LedgerFile>): Pick<LedgerFile, 'discharged' | 'shadow'> {
|
|
233
|
+
return {
|
|
234
|
+
discharged: Array.isArray(loaded.discharged) ? loaded.discharged : [],
|
|
235
|
+
shadow: Array.isArray(loaded.shadow) ? loaded.shadow : [],
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Record that one concern observed an obligation discharged.
|
|
241
|
+
*
|
|
242
|
+
* Idempotent per (rule, by): the design-pass concern may fire many times in a
|
|
243
|
+
* turn and the ledger wants the fact, not the frequency.
|
|
244
|
+
*/
|
|
245
|
+
export function appendDischarge(
|
|
246
|
+
root: string,
|
|
247
|
+
session_id: string,
|
|
248
|
+
rows: readonly DischargeRow[],
|
|
249
|
+
): number {
|
|
250
|
+
if (rows.length === 0) return 0;
|
|
251
|
+
if (is_replay_mode()) return 0;
|
|
252
|
+
if (session_id.trim() === '') return 0;
|
|
253
|
+
|
|
254
|
+
const target = path.join(root, statePathFor(session_id));
|
|
255
|
+
let added = 0;
|
|
256
|
+
try {
|
|
257
|
+
update_json_under_lock<LedgerFile>(target, (loaded) => {
|
|
258
|
+
const existing = Array.isArray(loaded.discharged) ? loaded.discharged : [];
|
|
259
|
+
const key = (r: DischargeRow): string => `${r.rule}::${r.by}`;
|
|
260
|
+
const seen = new Set(existing.map(key));
|
|
261
|
+
const merged = [...existing];
|
|
262
|
+
for (const row of rows) {
|
|
263
|
+
if (seen.has(key(row))) continue;
|
|
264
|
+
seen.add(key(row));
|
|
265
|
+
merged.push(row);
|
|
266
|
+
added += 1;
|
|
267
|
+
}
|
|
268
|
+
if (added === 0) return null;
|
|
269
|
+
return {
|
|
270
|
+
session_id,
|
|
271
|
+
delivered: parseRows(loaded).map(serialise),
|
|
272
|
+
discharged: merged,
|
|
273
|
+
shadow: Array.isArray(loaded.shadow) ? loaded.shadow : [],
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
} catch {
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
return added;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Rules discharged this session, by rule id. */
|
|
283
|
+
export function readDischarged(root: string, session_id: string): DischargeRow[] {
|
|
284
|
+
const file = readLedgerFile(root, session_id);
|
|
285
|
+
if (file === null) return [];
|
|
286
|
+
return Array.isArray(file.discharged) ? file.discharged : [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Shadow readings recorded this session. */
|
|
290
|
+
export function readShadow(root: string, session_id: string): ShadowRow[] {
|
|
291
|
+
const file = readLedgerFile(root, session_id);
|
|
292
|
+
if (file === null) return [];
|
|
293
|
+
return Array.isArray(file.shadow) ? file.shadow : [];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Record one would-refuse reading — ONE ROW PER MISSING SET, never per obligation.
|
|
298
|
+
*
|
|
299
|
+
* The aggregation is the contract, not an optimisation. Five missing
|
|
300
|
+
* obligations are one reading of one turn, and emitting five rows would let a
|
|
301
|
+
* later count of "refusals" read five times the truth while every one of them
|
|
302
|
+
* described the same moment.
|
|
303
|
+
*
|
|
304
|
+
* `attempt` increments when the SAME missing set is seen again. An unchanged
|
|
305
|
+
* set on a second reading is the signal that nothing moved — the caller uses
|
|
306
|
+
* it to stop forcing continuation, and it is recorded here so that decision is
|
|
307
|
+
* auditable rather than re-derived.
|
|
308
|
+
*
|
|
309
|
+
* Returns the attempt number written, or 0 when nothing was written.
|
|
310
|
+
*/
|
|
311
|
+
export function appendShadow(
|
|
312
|
+
root: string,
|
|
313
|
+
session_id: string,
|
|
314
|
+
missing: readonly string[],
|
|
315
|
+
now: string = stamp(),
|
|
316
|
+
): number {
|
|
317
|
+
if (missing.length === 0) return 0;
|
|
318
|
+
if (is_replay_mode()) return 0;
|
|
319
|
+
if (session_id.trim() === '') return 0;
|
|
320
|
+
|
|
321
|
+
const fingerprint = [...missing].sort().join('::');
|
|
322
|
+
const target = path.join(root, statePathFor(session_id));
|
|
323
|
+
let attempt = 0;
|
|
324
|
+
try {
|
|
325
|
+
update_json_under_lock<LedgerFile>(target, (loaded) => {
|
|
326
|
+
const existing = Array.isArray(loaded.shadow) ? loaded.shadow : [];
|
|
327
|
+
const prior = existing.filter(
|
|
328
|
+
(r) => [...(r.missing ?? [])].sort().join('::') === fingerprint,
|
|
329
|
+
);
|
|
330
|
+
attempt = prior.length + 1;
|
|
331
|
+
return {
|
|
332
|
+
session_id,
|
|
333
|
+
delivered: parseRows(loaded).map(serialise),
|
|
334
|
+
discharged: Array.isArray(loaded.discharged) ? loaded.discharged : [],
|
|
335
|
+
shadow: [
|
|
336
|
+
...existing,
|
|
337
|
+
{ at: now, missing: [...missing], attempt, would_refuse: true },
|
|
338
|
+
],
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
} catch {
|
|
342
|
+
return 0;
|
|
343
|
+
}
|
|
344
|
+
return attempt;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The whole file for one session, or `null` when it is absent or not ours. */
|
|
348
|
+
function readLedgerFile(root: string, session_id: string): Partial<LedgerFile> | null {
|
|
349
|
+
if (session_id.trim() === '') return null;
|
|
350
|
+
const target = path.join(root, statePathFor(session_id));
|
|
351
|
+
try {
|
|
352
|
+
const decoded: unknown = JSON.parse(fs.readFileSync(target, 'utf-8'));
|
|
353
|
+
if (typeof decoded !== 'object' || decoded === null) return null;
|
|
354
|
+
if ((decoded as { session_id?: unknown }).session_id !== session_id) return null;
|
|
355
|
+
return decoded as Partial<LedgerFile>;
|
|
356
|
+
} catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Rows recorded for one session, or `[]` when there are none to read.
|
|
363
|
+
*
|
|
364
|
+
* A file carrying somebody else's `session_id` reads as empty rather than as
|
|
365
|
+
* that session's rows — a copy, a restore or a hand-edit must not be able to
|
|
366
|
+
* attribute one session's deliveries to another. The producer side repairs
|
|
367
|
+
* the same case by rewriting the id; the consumer side refuses it. Both halves
|
|
368
|
+
* are needed, as `hooks/state_io.ts` § Per-session concern state states for
|
|
369
|
+
* the two concerns that got here first.
|
|
370
|
+
*/
|
|
371
|
+
export function readDelivered(root: string, session_id: string): DeliveredRow[] {
|
|
372
|
+
if (session_id.trim() === '') return [];
|
|
373
|
+
const target = path.join(root, statePathFor(session_id));
|
|
374
|
+
try {
|
|
375
|
+
const decoded: unknown = JSON.parse(fs.readFileSync(target, 'utf-8'));
|
|
376
|
+
if (typeof decoded !== 'object' || decoded === null) return [];
|
|
377
|
+
if ((decoded as { session_id?: unknown }).session_id !== session_id) return [];
|
|
378
|
+
return parseRows(decoded);
|
|
379
|
+
} catch {
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Whether the ledger directory can be written — the doctor's readiness line.
|
|
386
|
+
*
|
|
387
|
+
* Probes by writing and removing a sentinel rather than by checking mode bits,
|
|
388
|
+
* because the modes that matter here (a read-only mount, a directory owned by
|
|
389
|
+
* another user, a full disk) are not all visible in a `stat`. Returns `false`
|
|
390
|
+
* rather than throwing: the doctor reports, it does not fail.
|
|
391
|
+
*/
|
|
392
|
+
export function ledgerWritable(root: string): boolean {
|
|
393
|
+
if (is_replay_mode()) return false;
|
|
394
|
+
const probe = path.join(root, OBLIGATION_STATE_DIR, '.writable-probe.json');
|
|
395
|
+
try {
|
|
396
|
+
atomic_write_json(probe, { probe: true });
|
|
397
|
+
fs.rmSync(probe, { force: true });
|
|
398
|
+
return true;
|
|
399
|
+
} catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
}
|