@event4u/agent-config 14.15.0 → 14.17.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 (58) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +84 -153
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/analyze/inbox.md +172 -6
  7. package/dist/agent-src/commands/fix/commit-messages.md +336 -0
  8. package/dist/agent-src/commands/fix.md +5 -3
  9. package/dist/agent-src/rules/commit-conventions.md +1 -1
  10. package/dist/agent-src/rules/security-sensitive-stop.md +7 -5
  11. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +20 -2
  12. package/dist/agent-src/skills/aws-infrastructure/SKILL.md +41 -0
  13. package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +234 -4
  14. package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +99 -99
  15. package/dist/agent-src/skills/fe-design/SKILL.md +6 -3
  16. package/dist/agent-src/skills/fe-design/references/design-patterns.md +28 -0
  17. package/dist/agent-src/skills/iconography/SKILL.md +37 -6
  18. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  19. package/dist/agent-src/skills/terraform/SKILL.md +29 -0
  20. package/dist/agent-src/skills/threat-modeling/data/reasoning.csv +1 -0
  21. package/dist/agent-src/skills/threat-modeling/data/surfaces.csv +1 -0
  22. package/dist/agent-src/skills/threat-modeling/data/threats.csv +5 -0
  23. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  24. package/dist/cli-delegate/cmd_conformance.js +158 -33
  25. package/dist/cli-delegate/cmd_explain.js +24 -112
  26. package/dist/discovery/deprecation-report.md +1 -1
  27. package/dist/discovery/discovery-manifest.json +47 -21
  28. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  29. package/dist/discovery/discovery-manifest.summary.md +3 -3
  30. package/dist/discovery/dormancy-report.md +1 -1
  31. package/dist/discovery/orphan-report.md +1 -1
  32. package/dist/discovery/packs.json +6 -5
  33. package/dist/discovery/trust-report.md +2 -2
  34. package/dist/discovery/workspaces.json +5 -4
  35. package/dist/hooks/dispatch.js +169 -41
  36. package/dist/mcp/registry-manifest.json +3 -3
  37. package/dist/mcp/server.json +2 -2
  38. package/dist/mcp/server.mjs +160 -35
  39. package/dist/router.json +1 -1
  40. package/docs/guidelines/design-antipatterns.md +10 -2
  41. package/package.json +1 -1
  42. package/src/config/gate-violation-baselines.json +2 -2
  43. package/src/config/web-launch-readiness.json +1 -1
  44. package/src/scripts/_cli/explain_last/index.ts +1 -1
  45. package/src/scripts/_cli/explain_last/memory.ts +8 -104
  46. package/src/scripts/check_artefact_count_messaging.ts +9 -0
  47. package/src/scripts/check_release_highlights.ts +77 -4
  48. package/src/scripts/check_web_launch_readiness.ts +295 -15
  49. package/src/scripts/design_slop_rules.ts +19 -9
  50. package/src/scripts/git_authorization_hook.ts +257 -35
  51. package/src/scripts/hooks/block_unauthorized_git.ts +16 -4
  52. package/src/scripts/hooks/merge_impact.ts +68 -0
  53. package/src/scripts/measure_release_mix.ts +384 -0
  54. package/src/scripts/release.ts +4 -17
  55. package/src/scripts/release_drill.ts +154 -1
  56. package/src/scripts/release_mix_taxonomy.json +172 -0
  57. package/src/scripts/release_publication.ts +91 -0
  58. package/src/scripts/attest_artifact.ts +0 -193
@@ -0,0 +1,172 @@
1
+ {
2
+ "taxonomy_version": "1.0.0",
3
+ "updated": "2026-09-04",
4
+ "contract": "docs/decisions/ADR-253-per-pr-user-artifact-gate-declined.md",
5
+ "reader": "src/scripts/measure_release_mix.ts",
6
+ "note": [
7
+ "Path -> category mapping for the release governance-vs-product measurement.",
8
+ "Versioned so a later reading is comparable to an earlier one: a reading records",
9
+ "the taxonomy_version it was computed under, and a bump means prior readings are",
10
+ "recomputed rather than silently compared across two different rulers.",
11
+ "Matching is LONGEST-PREFIX-WINS over the repository-relative path. A path that",
12
+ "matches no rule is reported as `unclassified` -- a first-class state, never a",
13
+ "silent default. `unclassified` alone does not make a commit `mixed`.",
14
+ "The `consumer` label names a SHIPPED SURFACE, not delivered consumer value.",
15
+ "openai/codex-default asked for the label `shipped-product` on exactly that",
16
+ "ground and anthropic/claude-sonnet-4-5 preferred keeping `consumer` for",
17
+ "consistency with the roadmap's own wording; the roadmap's wording is kept and",
18
+ "the objection is recorded here rather than dropped."
19
+ ],
20
+ "categories": {
21
+ "consumer": "Shipped surface an installing consumer receives or invokes: skills, rules, commands, guidelines, packs, the agent-config CLI runtime, and user-facing documentation.",
22
+ "governance": "This repository's own steering layer: roadmaps, evidence ledgers, verdicts, decision records, archive and promotion records, and governance-only contracts.",
23
+ "maintenance": "Repository infrastructure: CI gates, tests, build and release automation, packaging, dependencies, tooling configuration.",
24
+ "unclassified": "No rule matches, or the path is genuinely shared between categories and is left visible rather than assigned."
25
+ },
26
+ "excluded_generated": [
27
+ "dist/",
28
+ ".augment/",
29
+ ".claude/",
30
+ ".cursor/",
31
+ ".clinerules/",
32
+ ".windsurfrules",
33
+ "GEMINI.md",
34
+ ".claude-plugin/",
35
+ ".augment-plugin/",
36
+ "package-lock.json",
37
+ "docs/decisions/INDEX.md",
38
+ "agents/roadmaps/archive/INDEX.md",
39
+ "agents/roadmaps/archive/index.json"
40
+ ],
41
+ "excluded_note": [
42
+ "Generated projections and lockfiles are removed BEFORE a commit is classified,",
43
+ "and are excluded from both the commit view and the diff view. A commit left with",
44
+ "no path after the exclusion is counted in the `generated_only` diagnostic rather",
45
+ "than as maintenance -- otherwise a release's ratio would depend on whether the",
46
+ "regeneration was committed separately from the source edit that caused it."
47
+ ],
48
+ "rules": [
49
+ { "prefix": "src/skills/", "category": "consumer" },
50
+ { "prefix": "src/rules/", "category": "consumer" },
51
+ { "prefix": "src/agent-src/", "category": "consumer" },
52
+ { "prefix": "src/domains/", "category": "consumer" },
53
+ { "prefix": "src/packs/", "category": "consumer" },
54
+ { "prefix": "src/profiles/", "category": "consumer" },
55
+ { "prefix": "src/templates/", "category": "consumer" },
56
+ { "prefix": "src/patterns/", "category": "consumer" },
57
+ { "prefix": "src/subagents/", "category": "consumer" },
58
+ { "prefix": "src/missions/", "category": "consumer" },
59
+ { "prefix": "src/flows/", "category": "consumer" },
60
+ { "prefix": "src/config/", "category": "consumer" },
61
+ { "prefix": "src/cli/", "category": "consumer" },
62
+ { "prefix": "src/install/", "category": "consumer" },
63
+ { "prefix": "src/server/", "category": "consumer" },
64
+ { "prefix": "src/ui/", "category": "consumer" },
65
+ { "prefix": "src/shared/", "category": "unclassified" },
66
+
67
+ { "prefix": "src/scripts/", "category": "maintenance" },
68
+ { "prefix": "src/scripts/ai_council/", "category": "consumer" },
69
+ { "prefix": "src/scripts/ai_team/", "category": "consumer" },
70
+ { "prefix": "src/scripts/ai-video/", "category": "consumer" },
71
+ { "prefix": "src/scripts/ai-image/", "category": "consumer" },
72
+ { "prefix": "src/scripts/media/", "category": "consumer" },
73
+ { "prefix": "src/scripts/hooks/", "category": "consumer" },
74
+ { "prefix": "src/scripts/mcp_server/", "category": "consumer" },
75
+ { "prefix": "src/scripts/skill_tools/", "category": "consumer" },
76
+ { "prefix": "src/scripts/command_suggester/", "category": "consumer" },
77
+ { "prefix": "src/scripts/code_graph/", "category": "consumer" },
78
+ { "prefix": "src/scripts/prediction-pool/", "category": "consumer" },
79
+ { "prefix": "src/scripts/cost/", "category": "consumer" },
80
+
81
+ { "prefix": "agents/", "category": "governance" },
82
+ { "prefix": "internal/", "category": "governance" },
83
+ { "prefix": "provenance/", "category": "governance" },
84
+
85
+ { "prefix": "docs/", "category": "consumer" },
86
+ { "prefix": "docs/decisions/", "category": "governance" },
87
+ { "prefix": "docs/adrs/", "category": "governance" },
88
+ { "prefix": "docs/archive/", "category": "governance" },
89
+ { "prefix": "docs/maintainers/", "category": "governance" },
90
+ { "prefix": "docs/parity/", "category": "governance" },
91
+ { "prefix": "docs/wedge/", "category": "governance" },
92
+ { "prefix": "docs/experiences/", "category": "governance" },
93
+ { "prefix": "docs/case-studies/", "category": "governance" },
94
+ { "prefix": "docs/announcements/", "category": "governance" },
95
+
96
+ { "prefix": "tests/", "category": "maintenance" },
97
+ { "prefix": ".github/", "category": "maintenance" },
98
+ { "prefix": "taskfiles/", "category": "maintenance" },
99
+ { "prefix": "deploy/", "category": "maintenance" },
100
+ { "prefix": "site/", "category": "maintenance" },
101
+ { "prefix": "hooks/", "category": "consumer" },
102
+ { "prefix": "user-types/", "category": "consumer" },
103
+ { "prefix": "tokens/", "category": "consumer" },
104
+ { "prefix": "LICENSES/", "category": "consumer" },
105
+
106
+ { "prefix": "README.md", "category": "consumer" },
107
+ { "prefix": "ONBOARDING.md", "category": "consumer" },
108
+ { "prefix": "MIGRATION.md", "category": "consumer" },
109
+ { "prefix": "BREAKING_CHANGES.md", "category": "consumer" },
110
+ { "prefix": "SECURITY.md", "category": "consumer" },
111
+ { "prefix": "LEGAL_NOTICE.md", "category": "consumer" },
112
+ { "prefix": "CHANGELOG.md", "category": "consumer" },
113
+ { "prefix": "CAPABILITIES.yaml", "category": "consumer" },
114
+ { "prefix": "CREDITS.md", "category": "consumer" },
115
+ { "prefix": "REUSE.toml", "category": "consumer" },
116
+ { "prefix": "LICENSE", "category": "consumer" },
117
+ { "prefix": "NOTICE", "category": "consumer" },
118
+ { "prefix": "llms.txt", "category": "consumer" },
119
+
120
+ { "prefix": "AGENTS.md", "category": "governance" },
121
+ { "prefix": "CLAUDE.md", "category": "governance" },
122
+ { "prefix": "CONTRIBUTING.md", "category": "governance" },
123
+
124
+ { "prefix": "Taskfile.yml", "category": "maintenance" },
125
+ { "prefix": "agent-config", "category": "maintenance" },
126
+ { "prefix": "scripts-run", "category": "maintenance" },
127
+ { "prefix": "setup.sh", "category": "maintenance" },
128
+ { "prefix": "package.json", "category": "maintenance" },
129
+ { "prefix": "eslint.config.js", "category": "maintenance" },
130
+ { "prefix": "tsconfig", "category": "maintenance" },
131
+ { "prefix": "vite.config.ts", "category": "maintenance" },
132
+ { "prefix": "vitest.config.ts", "category": "maintenance" },
133
+ { "prefix": "playwright.config.ts", "category": "maintenance" },
134
+ { "prefix": "glama.json", "category": "maintenance" },
135
+ { "prefix": "mcp.json", "category": "maintenance" },
136
+ { "prefix": "allowlist_backup.json", "category": "maintenance" },
137
+ { "prefix": ".augmentignore", "category": "maintenance" },
138
+ { "prefix": ".dockerignore", "category": "maintenance" },
139
+ { "prefix": ".gitattributes", "category": "maintenance" },
140
+ { "prefix": ".gitignore", "category": "maintenance" },
141
+ { "prefix": ".mailmap", "category": "maintenance" },
142
+ { "prefix": ".npmignore", "category": "maintenance" },
143
+ { "prefix": ".path-leak-allow", "category": "maintenance" },
144
+ { "prefix": ".secret-allow", "category": "maintenance" }
145
+ ],
146
+ "governance_contracts": [
147
+ "docs/contracts/adr-layout.md",
148
+ "docs/contracts/adr-architectural-consensus-mechanism.md",
149
+ "docs/contracts/ai-council-config.md",
150
+ "docs/contracts/adversarial-review-protocol.md",
151
+ "docs/contracts/branch-protection-policy.md",
152
+ "docs/contracts/ci-green-floor.md",
153
+ "docs/contracts/ci-cost-budget.md",
154
+ "docs/contracts/release-pr-gating.md",
155
+ "docs/contracts/release-sizing.md",
156
+ "docs/contracts/release-trunk-sync.md",
157
+ "docs/contracts/CHANGELOG-conventions.md",
158
+ "docs/contracts/evidence-artifact-types.md",
159
+ "docs/contracts/kernel-membership.md",
160
+ "docs/contracts/package-self-orientation.md"
161
+ ],
162
+ "governance_contracts_note": [
163
+ "`docs/contracts/` defaults to `consumer` because most contracts on that page",
164
+ "describe behavior the package promises an installing consumer -- classifying the",
165
+ "whole directory as governance would systematically undercount shipped work.",
166
+ "The files listed above are the exceptions: contracts that govern this",
167
+ "repository's own process (decision recording, council, release cutting, CI",
168
+ "floors, evidence discipline) rather than promised behavior. They resolve to",
169
+ "`governance`. A contract absent from this list and absent from the tree is a",
170
+ "stale entry and is reported by `--audit`."
171
+ ]
172
+ }
@@ -515,6 +515,97 @@ export function guard_release_branch_push(branch: string): void {
515
515
  * and every line above 1500 is charged by the growth ratchet, while this module
516
516
  * is far below it.
517
517
  */
518
+ /**
519
+ * Own the whole step-1 branch decision: reuse an existing release branch, and
520
+ * make sure whatever we end up on carries current `main`.
521
+ *
522
+ * Three defects it closes, all measured on 14.15.0 (2026-09-03) in one sitting,
523
+ * and all the same underlying mistake — a release branch that is not on current
524
+ * `main`, discovered later than it could have been.
525
+ *
526
+ * 1. **Reuse was gated on `--resume`.** The two "branch exists" arms read
527
+ * `resume && _branch_exists_local(branch)`, so a PLAIN `task release` over an
528
+ * existing branch fell through to `git checkout -b` and died with exit 128.
529
+ * That is exactly the state `guard_release_curation` leaves behind — it stops
530
+ * before the commit, and the branch it stops on already exists — while its
531
+ * own message says to re-run `task release`. The message was right about
532
+ * what to do and the code refused to do it. Reuse no longer consults
533
+ * `resume`: an existing release branch is a fact about the repository, not
534
+ * about the flag.
535
+ * 2. **A branch cut from an older `main` failed at the PUSH.** The pre-push
536
+ * preflight reported `branch is BEHIND origin/main` after steps 2 and 3 had
537
+ * already bumped and committed — the cheapest moment to integrate `main` is
538
+ * the moment we check the branch out, not eight steps later.
539
+ * 3. **A fresh branch was cut from whatever local `main` happened to be.** The
540
+ * create arm never pulled, so a stale local `main` produced a stale release
541
+ * branch and defect 2 followed on the next run. Fixed on the same terms
542
+ * rather than left as the half that still bites: leaving it would reproduce
543
+ * the "fix that does not fix it" pattern the other two came from.
544
+ *
545
+ * `say` is a callback rather than a direct write because the `[N/total]` step
546
+ * prefix belongs to the orchestrator, which knows the total; this module does
547
+ * not. Keeping the printing there and the decision here is also what lets
548
+ * `release.ts` shed twenty lines — it sits ~500 lines past the 1500-line cap,
549
+ * where every line is charged by the growth ratchet, and this module is under
550
+ * it.
551
+ *
552
+ * The merge is `git merge`, never a rebase and never a force: a release branch
553
+ * can already carry a pushed commit and somebody else's merge, and neither is
554
+ * ours to rewrite.
555
+ */
556
+ export function checkout_release_branch(
557
+ branch: string,
558
+ prMerged: boolean,
559
+ say: (msg: string) => void,
560
+ ): void {
561
+ if (prMerged) {
562
+ say(`PR for ${branch} already merged — staying on ${MAIN_BRANCH}`);
563
+ if (git(['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true }) !== MAIN_BRANCH) {
564
+ run(['git', 'checkout', MAIN_BRANCH]);
565
+ }
566
+ run(['git', 'pull', '--ff-only', REMOTE, MAIN_BRANCH]);
567
+ return;
568
+ }
569
+ if (_branch_exists_local(branch)) {
570
+ say(`Branch ${branch} exists locally — checkout`);
571
+ run(['git', 'checkout', branch]);
572
+ } else if (_branch_exists_remote(branch)) {
573
+ say(`Branch ${branch} exists on ${REMOTE} — fetch + checkout`);
574
+ run(['git', 'fetch', REMOTE, branch]);
575
+ run(['git', 'checkout', '-b', branch, `${REMOTE}/${branch}`]);
576
+ } else {
577
+ // Cut from CURRENT main, not from whatever the local ref happens to be.
578
+ say(`Create branch ${branch} from current ${MAIN_BRANCH}`);
579
+ if (git(['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true }) === MAIN_BRANCH) {
580
+ run(['git', 'pull', '--ff-only', REMOTE, MAIN_BRANCH]);
581
+ }
582
+ run(['git', 'checkout', '-b', branch]);
583
+ return;
584
+ }
585
+ integrate_main_if_behind(branch, say);
586
+ }
587
+
588
+ /**
589
+ * Merge `origin/<default>` into the checked-out release branch when it is
590
+ * behind, so the pre-push preflight is never the first place that surfaces.
591
+ *
592
+ * Silent when the branch is already current — a release run should not print a
593
+ * merge it did not make. Counting with `rev-list --count` against the fetched
594
+ * remote ref rather than the local one: the local `main` is exactly what went
595
+ * stale in the case this exists for.
596
+ */
597
+ export function integrate_main_if_behind(branch: string, say: (msg: string) => void): void {
598
+ run(['git', 'fetch', REMOTE, MAIN_BRANCH], { check: false });
599
+ const behind = git(['rev-list', '--count', `HEAD..${REMOTE}/${MAIN_BRANCH}`], {
600
+ capture: true,
601
+ }).trim();
602
+ if (behind === '' || behind === '0') {
603
+ return;
604
+ }
605
+ say(`Branch ${branch} is ${behind} commit(s) behind ${REMOTE}/${MAIN_BRANCH} — merging it in`);
606
+ run(['git', 'merge', `${REMOTE}/${MAIN_BRANCH}`, '--no-edit']);
607
+ }
608
+
518
609
  export function guard_release_curation(version: string, prMerged = false): void {
519
610
  if (prMerged) {
520
611
  return;
@@ -1,193 +0,0 @@
1
- #!/usr/bin/env tsx
2
- /**
3
- * Content attestation for any artifact a hook would INJECT.
4
- *
5
- * `road-to-skill-ecosystem-runtime-enforcement` Phase 5, Steps 6-7, and the
6
- * ordering in that step is the argument: attestation is the **precondition** for
7
- * auto-injection, not a follow-up to it.
8
- *
9
- * ── The threat, concretely ──────────────────────────────────────────────────
10
- * A hook that reads a tracked file and injects its contents into the model's
11
- * context turns that file into a **standing injection amplifier**. Whoever can
12
- * write the file can write the agent's instructions, on every session, without
13
- * a review — which is `untrusted-input-defense`'s found-instructions case
14
- * arriving through a file the repository itself vouches for. The file being
15
- * tracked is not the control: a tracked file changes in a pull request, and a
16
- * pull request that changes an injected file is indistinguishable in the diff
17
- * from one that changes documentation.
18
- *
19
- * ── The contract ────────────────────────────────────────────────────────────
20
- * An injectable artifact carries a sidecar `<file>.attest.json` holding the
21
- * sha256 of its bytes and the date the attestation was recorded. Before
22
- * injecting, a caller asks {@link verifyArtifact}:
23
- *
24
- * - hash matches → `ok`, inject.
25
- * - hash differs → `mismatch`, REFUSE. The content changed since a human
26
- * vouched for it; re-attesting is a deliberate act with a diff.
27
- * - no sidecar → `unattested`, REFUSE. **Not "allow because nothing said
28
- * no"** — that default makes the whole mechanism opt-out, and the file an
29
- * attacker adds is exactly the one with no sidecar.
30
- * - file missing → `absent`, REFUSE.
31
- *
32
- * ── What this does NOT claim ────────────────────────────────────────────────
33
- * The sidecar is committed beside the artifact, so an attacker who can write
34
- * both can write a consistent pair. This is NOT a cryptographic signature and
35
- * does not pretend to be one. What it buys is that the change becomes VISIBLE:
36
- * a diff that touches an injected file must also touch its attestation, and a
37
- * hash line changing in a review is a question, where a prose change in the same
38
- * file is not. Making a change reviewable is the honest claim; making it
39
- * impossible would need a key this repository does not have.
40
- *
41
- * Exit codes: 0 verified · 1 refused (mismatch / unattested / absent) · 2 usage.
42
- *
43
- * Usage:
44
- * ./scripts-run src/scripts/attest_artifact --record <file>
45
- * ./scripts-run src/scripts/attest_artifact --verify <file>
46
- * ./scripts-run src/scripts/attest_artifact --verify <file> --json
47
- */
48
- import * as crypto from 'node:crypto';
49
- import * as fs from 'node:fs';
50
- import * as path from 'node:path';
51
- import process from 'node:process';
52
- import { fileURLToPath, pathToFileURL } from 'node:url';
53
-
54
- const _HERE = fileURLToPath(import.meta.url);
55
-
56
- /** Sidecar suffix. Beside the artifact, so a move takes the attestation with it. */
57
- export const ATTEST_SUFFIX = '.attest.json';
58
-
59
- export type AttestStatus = 'ok' | 'mismatch' | 'unattested' | 'absent';
60
-
61
- export interface Attestation {
62
- schema_version: 1;
63
- /** sha256 of the artifact's bytes, hex. */
64
- sha256: string;
65
- /** `YYYY-MM-DD` the attestation was recorded. */
66
- recorded_at: string;
67
- /** Bytes at record time — a cheap second signal that costs nothing to store. */
68
- bytes: number;
69
- }
70
-
71
- export interface Verdict {
72
- status: AttestStatus;
73
- /** True only for `ok`. Every other status refuses. */
74
- inject: boolean;
75
- detail: string;
76
- expected?: string;
77
- actual?: string;
78
- }
79
-
80
- export function sidecarPath(artifact: string): string {
81
- return `${artifact}${ATTEST_SUFFIX}`;
82
- }
83
-
84
- export function hashFile(artifact: string): string {
85
- return crypto.createHash('sha256').update(fs.readFileSync(artifact)).digest('hex');
86
- }
87
-
88
- /**
89
- * Verify an artifact against its sidecar.
90
- *
91
- * Every non-`ok` status sets `inject: false`. That is the whole safety property
92
- * and it is expressed as data rather than left to each caller's `if`: a caller
93
- * that reads `verdict.inject` cannot accidentally treat `unattested` as
94
- * permissive, which is the mistake an `status === 'mismatch'` check invites.
95
- */
96
- export function verifyArtifact(artifact: string, today?: string): Verdict {
97
- void today;
98
- if (!fs.existsSync(artifact)) {
99
- return { status: 'absent', inject: false, detail: `${artifact} does not exist` };
100
- }
101
- const side = sidecarPath(artifact);
102
- if (!fs.existsSync(side)) {
103
- return {
104
- status: 'unattested',
105
- inject: false,
106
- detail:
107
- `${artifact} has no ${ATTEST_SUFFIX} sidecar — REFUSED. An unattested artifact is ` +
108
- 'not "allowed because nothing said no": that default makes attestation opt-out, and ' +
109
- 'the file an attacker adds is precisely the one with no sidecar.',
110
- };
111
- }
112
- let att: Partial<Attestation>;
113
- try {
114
- att = JSON.parse(fs.readFileSync(side, 'utf8')) as Partial<Attestation>;
115
- } catch (e) {
116
- return {
117
- status: 'unattested',
118
- inject: false,
119
- detail: `${side} does not parse (${e instanceof Error ? e.message : String(e)}) — treated as absent`,
120
- };
121
- }
122
- const actual = hashFile(artifact);
123
- if (typeof att.sha256 !== 'string' || att.sha256 === '') {
124
- return { status: 'unattested', inject: false, detail: `${side} carries no sha256`, actual };
125
- }
126
- if (att.sha256 !== actual) {
127
- return {
128
- status: 'mismatch',
129
- inject: false,
130
- detail:
131
- `${artifact} changed since it was attested — REFUSED. Re-attest deliberately ` +
132
- `(--record) so the new hash lands in a diff a reviewer sees.`,
133
- expected: att.sha256,
134
- actual,
135
- };
136
- }
137
- return { status: 'ok', inject: true, detail: `${artifact} matches its attestation`, expected: att.sha256, actual };
138
- }
139
-
140
- /** Record (or re-record) an attestation. Deliberate, and it lands in a diff. */
141
- export function recordAttestation(artifact: string, today = new Date().toISOString().slice(0, 10)): Attestation {
142
- const att: Attestation = {
143
- schema_version: 1,
144
- sha256: hashFile(artifact),
145
- recorded_at: today,
146
- bytes: fs.statSync(artifact).size,
147
- };
148
- fs.writeFileSync(sidecarPath(artifact), `${JSON.stringify(att, null, 2)}\n`, 'utf8');
149
- return att;
150
- }
151
-
152
- export function main(argv: string[] = process.argv.slice(2)): number {
153
- const usage =
154
- 'usage: attest_artifact (--record | --verify) <file> [--json]\n' +
155
- ' --verify exits 1 on mismatch, unattested or absent. Refusal is the default.\n';
156
- if (argv.includes('-h') || argv.includes('--help')) {
157
- process.stdout.write(usage);
158
- return 0;
159
- }
160
- const asJson = argv.includes('--json');
161
- const record = argv.includes('--record');
162
- const verify = argv.includes('--verify');
163
- const file = argv.find((a) => !a.startsWith('--'));
164
- if (record === verify || file === undefined) {
165
- process.stderr.write(`attest_artifact: error: exactly one of --record / --verify plus a file\n${usage}`);
166
- return 2;
167
- }
168
- const target = path.resolve(file);
169
- if (record) {
170
- if (!fs.existsSync(target)) {
171
- process.stderr.write(`attest_artifact: ${file} does not exist\n`);
172
- return 2;
173
- }
174
- const att = recordAttestation(target);
175
- process.stdout.write(`✅ attested ${file} — sha256 ${att.sha256.slice(0, 16)}… (${String(att.bytes)} bytes)\n`);
176
- return 0;
177
- }
178
- const v = verifyArtifact(target);
179
- if (asJson) {
180
- process.stdout.write(`${JSON.stringify(v, null, 2)}\n`);
181
- } else if (v.inject) {
182
- process.stdout.write(`✅ ${v.detail}\n`);
183
- } else {
184
- process.stderr.write(`❌ [${v.status}] ${v.detail}\n`);
185
- }
186
- return v.inject ? 0 : 1;
187
- }
188
-
189
- /* c8 ignore start */
190
- if (process.argv[1] !== undefined && pathToFileURL(path.resolve(process.argv[1])).href === pathToFileURL(_HERE).href) {
191
- process.exit(main());
192
- }
193
- /* c8 ignore stop */