@event4u/agent-config 9.2.0 → 9.4.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +111 -0
  3. package/CONTRIBUTING.md +14 -0
  4. package/README.md +12 -4
  5. package/dist/agent-src/commands/contribution-precheck.md +100 -0
  6. package/dist/agent-src/commands/fix/route.md +49 -0
  7. package/dist/agent-src/commands/fix.md +4 -2
  8. package/dist/agent-src/commands/tdd/green.md +32 -0
  9. package/dist/agent-src/commands/tdd/red.md +33 -0
  10. package/dist/agent-src/commands/tdd/refactor.md +31 -0
  11. package/dist/agent-src/commands/tdd.md +50 -0
  12. package/dist/agent-src/commands/team.md +4 -0
  13. package/dist/agent-src/contexts/communication/rules-auto/guidelines-mechanics.md +1 -0
  14. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +81 -2
  15. package/dist/agent-src/contexts/model-recommendations.md +6 -0
  16. package/dist/agent-src/rules/architecture.md +7 -0
  17. package/dist/agent-src/rules/token-efficiency.md +7 -0
  18. package/dist/agent-src/skills/adversarial-review/SKILL.md +5 -0
  19. package/dist/agent-src/skills/ai-council/SKILL.md +3 -0
  20. package/dist/agent-src/skills/build-buy-partner/SKILL.md +1 -0
  21. package/dist/agent-src/skills/decision-record/SKILL.md +35 -1
  22. package/dist/agent-src/skills/decision-record/evals/triggers.json +82 -12
  23. package/dist/agent-src/skills/decision-record/references/weighted-matrix.md +119 -0
  24. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +142 -0
  25. package/dist/agent-src/skills/fe-design/SKILL.md +1 -0
  26. package/dist/agent-src/skills/judge-code-quality/SKILL.md +1 -0
  27. package/dist/agent-src/skills/judge-synthesis/SKILL.md +1 -0
  28. package/dist/agent-src/skills/learning-tutor/SKILL.md +215 -0
  29. package/dist/agent-src/skills/persona-improvement/SKILL.md +81 -0
  30. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +10 -0
  31. package/dist/agent-src/skills/rice-prioritization/SKILL.md +1 -0
  32. package/dist/agent-src/skills/stakeholder-tradeoff/SKILL.md +3 -1
  33. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +14 -24
  34. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  35. package/dist/discovery/deprecation-report.md +1 -1
  36. package/dist/discovery/discovery-manifest.json +253 -30
  37. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  38. package/dist/discovery/discovery-manifest.summary.md +4 -4
  39. package/dist/discovery/orphan-report.md +1 -1
  40. package/dist/discovery/packs.json +19 -10
  41. package/dist/discovery/trust-report.md +3 -3
  42. package/dist/discovery/workspaces.json +15 -6
  43. package/dist/mcp/registry-manifest.json +3 -3
  44. package/docs/CLAIMS.md +16 -2
  45. package/docs/announcements/2026-07-honest-cost-sweep-launch.md +84 -0
  46. package/docs/anti-reskin-gate.md +60 -0
  47. package/docs/architecture.md +3 -3
  48. package/docs/catalog.md +303 -278
  49. package/docs/command-flows.md +10 -3
  50. package/docs/comparison.yaml +10 -0
  51. package/docs/contracts/adoption-signal-floor.md +17 -0
  52. package/docs/contracts/ai-council-config.md +25 -0
  53. package/docs/contracts/command-clusters.md +2 -1
  54. package/docs/contracts/mcp-tool-inventory.md +25 -22
  55. package/docs/contracts/tier-3-contrib-plugin.md +12 -4
  56. package/docs/decisions/ADR-122-adversarial-verification-council.md +1 -1
  57. package/docs/development.md +10 -1
  58. package/docs/featured-skills.md +2 -2
  59. package/docs/getting-started-by-role.md +1 -1
  60. package/docs/getting-started.md +1 -1
  61. package/docs/governance-advantage.md +1 -1
  62. package/docs/guidelines/component-oriented-and-oop-development.md +181 -0
  63. package/docs/proof.md +10 -6
  64. package/docs/self-review-gate.md +14 -0
  65. package/docs/setup/per-ide/antigravity.md +8 -3
  66. package/docs/us-vs-the-category.md +38 -0
  67. package/docs/wedge/production-validator/README.md +24 -0
  68. package/docs/wedge/production-validator/first-run-check.sh +42 -0
  69. package/package.json +3 -2
  70. package/src/scripts/_lib/catalog_score.ts +64 -0
  71. package/src/scripts/_lib/shingle_similarity.ts +146 -0
  72. package/src/scripts/ai_council/clients.ts +50 -1
  73. package/src/scripts/ai_council/config.ts +28 -3
  74. package/src/scripts/ai_council/low_impact.ts +16 -0
  75. package/src/scripts/ai_council/necessity.ts +11 -0
  76. package/src/scripts/ai_council/orchestrator.ts +78 -26
  77. package/src/scripts/ai_council/pricing.ts +59 -0
  78. package/src/scripts/ai_council/prompts.ts +7 -0
  79. package/src/scripts/ai_council/stance_tally.ts +42 -5
  80. package/src/scripts/build_catalog_index.ts +188 -0
  81. package/src/scripts/council_cli.ts +56 -1
  82. package/src/scripts/generate_index.ts +6 -3
  83. package/src/scripts/hook_manifest.yaml +17 -6
  84. package/src/scripts/install-hooks.sh +32 -17
  85. package/src/scripts/lint_originality.ts +464 -0
  86. package/src/scripts/mcp_server/catalog_tools.ts +114 -0
  87. package/src/scripts/mcp_server/consumer_tool_catalog.json +46 -0
  88. package/src/scripts/pr_url_reminder_hook.ts +177 -0
  89. package/src/scripts/schemas/flow.schema.json +17 -0
  90. package/src/scripts/schemas/skill.schema.json +166 -32
  91. package/src/scripts/self_review_gate.test.ts +48 -0
  92. package/src/scripts/self_review_gate.ts +73 -5
  93. package/src/scripts/validate_flow_teams.ts +127 -0
@@ -145,6 +145,17 @@ concerns:
145
145
  script: src/scripts/team_review_gate_hook.ts
146
146
  args: []
147
147
  fail_closed: false
148
+ # Reply-shape backstop for the direct-answers rule (reply-close): a PR
149
+ # created THIS turn must end the reply as the literal last raw URL. This
150
+ # PostToolUse hook fires right after a PR *create* (gh pr create, or a
151
+ # github create-pull-request tool) that produced a `…/pull/<n>` URL, and
152
+ # WARNS in context (exit 2) so the reminder lands before the reply closes.
153
+ # Never blocks; unconditional (reinforces an always-on rule, fires only on
154
+ # the rare create event, so no settings gate). fail_closed: false.
155
+ pr-url-reminder:
156
+ script: src/scripts/pr_url_reminder_hook.ts
157
+ args: []
158
+ fail_closed: false
148
159
 
149
160
  platforms:
150
161
  augment:
@@ -152,7 +163,7 @@ platforms:
152
163
  session_end: [chat-history]
153
164
  stop: [chat-history, hot-context, verify-before-complete]
154
165
  pre_tool_use: [block-no-verify, rtk-wrap, design-slop]
155
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
166
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
156
167
 
157
168
  claude:
158
169
  session_start: [chat-history, hot-context, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe]
@@ -160,7 +171,7 @@ platforms:
160
171
  stop: [chat-history, hot-context, verify-before-complete, team-review-gate]
161
172
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
162
173
  pre_tool_use: [block-no-verify, rtk-wrap, design-slop]
163
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
174
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
164
175
 
165
176
  # Cowork — the Claude desktop app's local-agent-mode runtime, built
166
177
  # on top of the Claude Code CLI. Same lifecycle vocabulary, same
@@ -182,7 +193,7 @@ platforms:
182
193
  stop: [chat-history, hot-context, verify-before-complete]
183
194
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
184
195
  pre_tool_use: [block-no-verify, rtk-wrap, design-slop]
185
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
196
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
186
197
 
187
198
  # Phase 7.5 — Cursor. `.cursor/hooks.json` (project) is read by the
188
199
  # IDE and CLI; `~/.cursor/hooks.json` (user) is opt-in via
@@ -196,7 +207,7 @@ platforms:
196
207
  session_end: [chat-history]
197
208
  stop: [chat-history, hot-context, verify-before-complete]
198
209
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
199
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
210
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
200
211
 
201
212
  # Phase 7.6 — Cline. Hooks live under `.clinerules/hooks/<HookName>`
202
213
  # (project, no file extension, must be executable per Cline docs) or
@@ -211,7 +222,7 @@ platforms:
211
222
  session_end: [chat-history]
212
223
  stop: [chat-history, hot-context, verify-before-complete]
213
224
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
214
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
225
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
215
226
 
216
227
  # Phase 7.7 — Windsurf (Cascade). Hooks live at `.windsurf/hooks.json`
217
228
  # (project) or `~/.codeium/windsurf/hooks.json` (user). Cascade has
@@ -245,7 +256,7 @@ platforms:
245
256
  session_end: [chat-history]
246
257
  stop: [chat-history, hot-context, verify-before-complete]
247
258
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
248
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
259
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder]
249
260
 
250
261
  # Phase 7.9 — Copilot has no hook surface. Concerns route through
251
262
  # rule-only fallback; the dispatcher silently no-ops on --platform copilot.
@@ -8,32 +8,47 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
8
  # This installer lives at src/scripts/install-hooks.sh, so the repo root
9
9
  # (which owns .git/) is two levels up.
10
10
  PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
11
- HOOKS_DIR="$PROJECT_ROOT/.git/hooks"
11
+ # Resolve the SHARED hooks dir via git so this also works from a linked worktree
12
+ # (where $PROJECT_ROOT/.git is a file, not a dir, and a literal .git/hooks path
13
+ # would break under `set -e`). --git-common-dir points at the main .git even from
14
+ # a worktree; fall back to the literal path only outside a git repo.
15
+ if COMMON_GIT_DIR="$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null)"; then
16
+ case "$COMMON_GIT_DIR" in /*) : ;; *) COMMON_GIT_DIR="$PROJECT_ROOT/$COMMON_GIT_DIR" ;; esac
17
+ HOOKS_DIR="$COMMON_GIT_DIR/hooks"
18
+ else
19
+ HOOKS_DIR="$PROJECT_ROOT/.git/hooks"
20
+ fi
12
21
 
13
22
  mkdir -p "$HOOKS_DIR"
14
23
 
15
24
  cat > "$HOOKS_DIR/pre-push" << 'EOF'
16
25
  #!/usr/bin/env bash
17
- # Pre-push hook: verify dist/agent-src/ is in sync with .agent-src.uncondensed/
18
- # and that the canonical command count matches README + getting-started docs.
26
+ # Pre-push hook: mirror the CI "Sync + Generate Tools Consistency" gate LOCALLY
27
+ # so derived-output drift is caught BEFORE push, not in remote CI.
19
28
  #
20
- # The command-count gate exists because three consecutive PRs landed
21
- # post-CI count-drift fixes (e.g. f2fb0026 "bump command count
22
- # 101→103"). Catching the drift pre-push stops it from flooding remote
23
- # CI. Runtime ~0.1s.
29
+ # History: the remote "Consistency" check failed in a large share of PRs because
30
+ # the old hook only verified dist sync + the COMMAND count — it missed
31
+ # guideline/skill/rule count drift and the generate-tools / router / corpus
32
+ # outputs (e.g. a new guideline bumping the count in README + docs/architecture).
33
+ # `task consistency` is the EXACT CI mirror (sync-check + sync-check-hashes +
34
+ # sync + generate-tools + compile-router + compile-corpus + `git diff --quiet`),
35
+ # so any derived-output drift blocks the push here. Runtime ~15-40s (it
36
+ # regenerates the tool trees) — cheaper than a red CI run and a fixup re-push.
24
37
 
25
38
  fail=0
26
39
 
27
- echo "🔍 Checking dist/agent-src/ sync..."
28
- if ! ./scripts-run src/scripts/condense --check; then
29
- echo "❌ dist/agent-src/ is out of sync. Run 'task sync' and condense changed .md files, then commit."
30
- fail=1
31
- fi
32
-
33
- echo "🔍 Checking command count messaging..."
34
- if ! ./scripts-run src/scripts/check_command_count_messaging; then
35
- echo "❌ Command-count drift in README / AGENTS.md / getting-started. Run 'task counts-update', stage the changes, then re-commit."
36
- fail=1
40
+ if ! command -v task >/dev/null 2>&1; then
41
+ echo "⚠️ 'task' (go-task) not found — cannot run the consistency gate locally."
42
+ echo " Install it (https://taskfile.dev) so pre-push can mirror the CI check;"
43
+ echo " skipping the consistency check for this push."
44
+ else
45
+ echo "🔍 Consistency — mirroring the CI 'Sync + Generate Tools Consistency' gate..."
46
+ if ! task consistency; then
47
+ echo "❌ Derived outputs drifted (counts / dist / generated tool trees / router / corpus)."
48
+ echo " 'task consistency' just regenerated them into your working tree —"
49
+ echo " review + 'git add' the changes (or run 'task consistency-fix'), commit, then re-push."
50
+ fail=1
51
+ fi
37
52
  fi
38
53
 
39
54
  echo "🔍 Checking for leftover conflict markers / unmerged paths..."
@@ -0,0 +1,464 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Anti-reskin originality linter.
4
+ *
5
+ * Entity-neutralized shingle-overlap gate over the authored corpus — skills,
6
+ * personas, and domain commands. Catches the failure mode that keyword-cosine
7
+ * (`audit_skill_overlap.ts`) and token-Jaccard (`_lib/text_similarity.ts`)
8
+ * miss: a find-replace re-skin ("Laravel" → "Symfony", "Vietnam" → "Korea")
9
+ * submitted as a "new" artifact. The engine (`_lib/shingle_similarity.ts`)
10
+ * neutralizes framework/vendor/region proper nouns before comparing 8-word
11
+ * shingles, so the swap does not move the score.
12
+ *
13
+ * Corpus is class-scoped: a skill compares only against skills, a persona only
14
+ * against personas, a command only against commands — a persona legitimately
15
+ * shares scaffold phrasing with the persona template, never with a skill.
16
+ * Shared-scaffold shingles are subtracted per class (the class template's
17
+ * shingles are removed from every candidate) so boilerplate never scores.
18
+ * Rules and contexts are OUT of scope by design (their intentional
19
+ * cross-referencing makes shingle overlap structurally noisy).
20
+ *
21
+ * Modes:
22
+ * (no args) full pairwise audit → agents/reports/originality.{json,md}
23
+ * --changed <f...> CI path: each changed file vs the whole same-class corpus
24
+ * AND vs the other changed files of its class
25
+ *
26
+ * Thresholds (env-overridable): ORIGINALITY_FAIL (60), ORIGINALITY_WARN (40).
27
+ * Calibrated 2026-07-19 over 495 artifacts / 56 151 comparisons: after the
28
+ * template + document-frequency boilerplate subtraction the worst legitimate
29
+ * pair is 40 % (`domains/engineering-base/tests` ~ `domains/meta/override` —
30
+ * two cluster-orchestrator commands sharing the family template's
31
+ * "Top-level orchestrator … / Non-interactive & auto-detection" scaffold;
32
+ * triaged as legitimate, not a merge candidate), p95 and median 0 %. A
33
+ * find-replace re-skin scores ~100 % (entities neutralize away), so FAIL 60
34
+ * blocks every real re-skin with a 20-point margin above the legitimate floor
35
+ * and 40-point margin below a re-skin. Distribution in agents/reports/originality.md.
36
+ *
37
+ * TWO PROPERTIES OF THE DF PASS A LATER READER MUST NOT MISTAKE FOR CONSTANTS:
38
+ * 1. Scores are CORPUS-RELATIVE. The DF floor subtracts what is common across
39
+ * the class, so every overlap number is measured against the corpus as it
40
+ * stands. The 40 % floor is a 2026-07-19 SNAPSHOT, not a fixed constant —
41
+ * it drifts as commands/skills are added or removed. Re-run the full audit
42
+ * after any large corpus change; do not treat 40 % as invariant.
43
+ * 2. Adversarial batch masking. In `--changed` mode the boilerplate set is
44
+ * computed from the corpus MINUS the change set, so a batch of ≥ _dfFloor
45
+ * near-identical re-skins submitted together cannot classify its own shared
46
+ * shingles as boilerplate (see `_changed`). The full-audit sweep (no
47
+ * "changed" notion) does NOT have this guard — a batch of ≥ floor identical
48
+ * NEW files committed at once would mutually mask there; the `--changed` PR
49
+ * gate, not the sweep, is the adversarial defense. That gate is wired as
50
+ * the `originality-gate` job in `.github/workflows/skill-lint.yml`, which
51
+ * computes the PR's changed corpus files and runs `--changed` on them
52
+ * (empty changed set reports INCONCLUSIVE, never a pass).
53
+ *
54
+ * Exit 0 clean/warn · 1 fail · 2 usage.
55
+ */
56
+ import * as fs from 'node:fs';
57
+ import * as path from 'node:path';
58
+ import { fileURLToPath, pathToFileURL } from 'node:url';
59
+
60
+ import { shingles } from './_lib/shingle_similarity.js';
61
+
62
+ const _HERE = fileURLToPath(import.meta.url);
63
+ export const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
64
+ const REPORT_DIR = path.join(ROOT, 'agents', 'reports');
65
+ const OUT_JSON = path.join(REPORT_DIR, 'originality.json');
66
+ const OUT_MD = path.join(REPORT_DIR, 'originality.md');
67
+
68
+ const K = 8;
69
+
70
+ export type ArtifactClass = 'skill' | 'persona' | 'command';
71
+
72
+ interface ClassSpec {
73
+ readonly name: ArtifactClass;
74
+ /** Absolute paths of every corpus file in this class (sorted). */
75
+ files(): string[];
76
+ /** Class template file whose shingles are subtracted as shared scaffold. */
77
+ readonly templatePath: string;
78
+ /** Classify an absolute path into this class, or null. */
79
+ matches(abs: string): boolean;
80
+ }
81
+
82
+ function _isDir(p: string): boolean {
83
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
84
+ }
85
+ function _isFile(p: string): boolean {
86
+ try { return fs.statSync(p).isFile(); } catch { return false; }
87
+ }
88
+ function _rel(abs: string): string {
89
+ return path.relative(ROOT, abs).split(path.sep).join('/');
90
+ }
91
+
92
+ /** Recursive walk collecting files whose basename === `leaf`. */
93
+ function _walkLeaf(dir: string, leaf: string, out: string[]): void {
94
+ let entries: fs.Dirent[];
95
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
96
+ for (const ent of entries) {
97
+ const full = path.join(dir, ent.name);
98
+ if (ent.isDirectory() || (ent.isSymbolicLink() && _isDir(full))) {
99
+ _walkLeaf(full, leaf, out);
100
+ } else if (ent.name === leaf) {
101
+ out.push(full);
102
+ }
103
+ }
104
+ }
105
+
106
+ const SKILLS_DIR = path.join(ROOT, 'src', 'skills');
107
+ const PERSONAS_DIR = path.join(ROOT, 'src', 'agent-src', 'personas');
108
+ const DOMAINS_DIR = path.join(ROOT, 'src', 'domains');
109
+
110
+ const CLASSES: readonly ClassSpec[] = [
111
+ {
112
+ name: 'skill',
113
+ templatePath: path.join(ROOT, 'src', 'agent-src', 'templates', 'skill.md'),
114
+ files() {
115
+ if (!_isDir(SKILLS_DIR)) return [];
116
+ return fs.readdirSync(SKILLS_DIR)
117
+ .map((n) => path.join(SKILLS_DIR, n, 'SKILL.md'))
118
+ .filter(_isFile)
119
+ .sort();
120
+ },
121
+ matches: (abs) => abs.startsWith(SKILLS_DIR + path.sep) && path.basename(abs) === 'SKILL.md',
122
+ },
123
+ {
124
+ name: 'persona',
125
+ templatePath: path.join(PERSONAS_DIR, '_template-specialist', 'persona.md'),
126
+ files() {
127
+ if (!_isDir(PERSONAS_DIR)) return [];
128
+ // Top-level *.md only (subdirs like _template-specialist/, advisors/
129
+ // are not first-class personas — the corpus is the flat set).
130
+ return fs.readdirSync(PERSONAS_DIR)
131
+ .filter((n) => n.endsWith('.md') && !n.startsWith('_'))
132
+ .map((n) => path.join(PERSONAS_DIR, n))
133
+ .filter(_isFile)
134
+ .sort();
135
+ },
136
+ matches: (abs) =>
137
+ path.dirname(abs) === PERSONAS_DIR &&
138
+ abs.endsWith('.md') &&
139
+ !path.basename(abs).startsWith('_'),
140
+ },
141
+ {
142
+ name: 'command',
143
+ templatePath: path.join(ROOT, 'src', 'agent-src', 'templates', 'command.md'),
144
+ files() {
145
+ const out: string[] = [];
146
+ _walkLeaf(DOMAINS_DIR, 'command.md', out);
147
+ return out.sort();
148
+ },
149
+ matches: (abs) => abs.startsWith(DOMAINS_DIR + path.sep) && path.basename(abs) === 'command.md',
150
+ },
151
+ ];
152
+
153
+ interface Artifact {
154
+ cls: ArtifactClass;
155
+ relpath: string;
156
+ /** Human name — frontmatter name/id, else parent dir. */
157
+ label: string;
158
+ /** Scaffold-stripped shingle set. */
159
+ shingles: Set<string>;
160
+ }
161
+
162
+ /** Cheap frontmatter name/id read for a readable label (no yaml dep needed). */
163
+ function _label(text: string, abs: string): string {
164
+ const fm = /^---\n([\s\S]*?)\n---/.exec(text);
165
+ if (fm) {
166
+ const m = /^(?:name|id):\s*["']?([^"'\n]+)["']?\s*$/m.exec(fm[1] as string);
167
+ if (m) return (m[1] as string).trim();
168
+ }
169
+ return path.basename(path.dirname(abs)) || path.basename(abs);
170
+ }
171
+
172
+ function _templateShingles(spec: ClassSpec): Set<string> {
173
+ if (!_isFile(spec.templatePath)) return new Set();
174
+ return shingles(fs.readFileSync(spec.templatePath, 'utf-8'), K);
175
+ }
176
+
177
+ function _load(abs: string, cls: ArtifactClass, tmpl: Set<string>): Artifact {
178
+ const text = fs.readFileSync(abs, 'utf-8');
179
+ const raw = shingles(text, K);
180
+ // Subtract shared scaffold — a shingle also present in the class template
181
+ // is boilerplate, not authored content.
182
+ const kept = new Set<string>();
183
+ for (const s of raw) if (!tmpl.has(s)) kept.add(s);
184
+ return { cls, relpath: _rel(abs), label: _label(text, abs), shingles: kept };
185
+ }
186
+
187
+ export function overlap(a: Artifact, b: Artifact): number {
188
+ const min = Math.min(a.shingles.size, b.shingles.size);
189
+ if (min === 0) return 0;
190
+ const [small, large] = a.shingles.size <= b.shingles.size ? [a.shingles, b.shingles] : [b.shingles, a.shingles];
191
+ let shared = 0;
192
+ for (const s of small) if (large.has(s)) shared++;
193
+ return (100 * shared) / min;
194
+ }
195
+
196
+ interface Pair { cls: ArtifactClass; a: string; b: string; a_path: string; b_path: string; overlap: number; }
197
+
198
+ function _round1(x: number): number { return Math.round(x * 10) / 10; }
199
+
200
+ // --- thresholds --------------------------------------------------------------
201
+
202
+ function _threshold(env: string, dflt: number): number {
203
+ const v = process.env[env];
204
+ if (v === undefined || v === '') return dflt;
205
+ const n = Number(v);
206
+ return Number.isFinite(n) ? n : dflt;
207
+ }
208
+ export const FAIL = _threshold('ORIGINALITY_FAIL', 60);
209
+ export const WARN = _threshold('ORIGINALITY_WARN', 40);
210
+
211
+ // --- corpus ------------------------------------------------------------------
212
+
213
+ /**
214
+ * Document-frequency boilerplate floor. A shingle that recurs across many
215
+ * artifacts of a class is shared scaffold the single template file does not
216
+ * capture (command orchestrators especially share structural prose). A
217
+ * find-replace re-skin, by contrast, is a PAIR: its distinctive shingles live
218
+ * in ≤ 2 files, so DF-filtering keeps exactly the content a re-skin copies while
219
+ * dropping the class boilerplate — a per-class IDF stopword pass at shingle
220
+ * granularity. Floor = max(4, 3% of the class size).
221
+ */
222
+ export function _dfFloor(n: number): number {
223
+ return Math.max(4, Math.round(0.03 * n));
224
+ }
225
+
226
+ /** The high-DF shingle set — class boilerplate to subtract from every artifact. */
227
+ export function _boilerplateSet(arts: Artifact[]): Set<string> {
228
+ const floor = _dfFloor(arts.length);
229
+ const df = new Map<string, number>();
230
+ for (const a of arts) for (const s of a.shingles) df.set(s, (df.get(s) ?? 0) + 1);
231
+ const boiler = new Set<string>();
232
+ for (const [s, c] of df) if (c >= floor) boiler.add(s);
233
+ return boiler;
234
+ }
235
+
236
+ function _subtract(a: Artifact, boiler: Set<string>): void {
237
+ if (boiler.size === 0) return;
238
+ const kept = new Set<string>();
239
+ for (const s of a.shingles) if (!boiler.has(s)) kept.add(s);
240
+ a.shingles = kept;
241
+ }
242
+
243
+ /** Per-class artifacts with the class template + DF-boilerplate subtracted. */
244
+ function _corpus(): Map<ArtifactClass, Artifact[]> {
245
+ const byClass = new Map<ArtifactClass, Artifact[]>();
246
+ for (const spec of CLASSES) {
247
+ const tmpl = _templateShingles(spec);
248
+ const arts = spec.files().map((f) => _load(f, spec.name, tmpl));
249
+ const boiler = _boilerplateSet(arts);
250
+ for (const a of arts) _subtract(a, boiler);
251
+ byClass.set(spec.name, arts);
252
+ }
253
+ return byClass;
254
+ }
255
+
256
+ function _classify(abs: string): ClassSpec | null {
257
+ for (const spec of CLASSES) if (spec.matches(abs)) return spec;
258
+ return null;
259
+ }
260
+
261
+ // --- full audit --------------------------------------------------------------
262
+
263
+ function _fullPairs(byClass: Map<ArtifactClass, Artifact[]>): Pair[] {
264
+ const pairs: Pair[] = [];
265
+ for (const arts of byClass.values()) {
266
+ for (let i = 0; i < arts.length; i++) {
267
+ for (let j = i + 1; j < arts.length; j++) {
268
+ const a = arts[i] as Artifact;
269
+ const b = arts[j] as Artifact;
270
+ const o = overlap(a, b);
271
+ if (o >= WARN) {
272
+ pairs.push({ cls: a.cls, a: a.label, b: b.label, a_path: a.relpath, b_path: b.relpath, overlap: _round1(o) });
273
+ }
274
+ }
275
+ }
276
+ }
277
+ return pairs.sort((x, y) => y.overlap - x.overlap || (x.a_path < y.a_path ? -1 : 1));
278
+ }
279
+
280
+ /** worst / p95 / median over EVERY pair (not just the >= WARN ones). */
281
+ function _distribution(byClass: Map<ArtifactClass, Artifact[]>): { worst: number; p95: number; median: number; comparisons: number } {
282
+ const scores: number[] = [];
283
+ for (const arts of byClass.values()) {
284
+ for (let i = 0; i < arts.length; i++) {
285
+ for (let j = i + 1; j < arts.length; j++) {
286
+ scores.push(overlap(arts[i] as Artifact, arts[j] as Artifact));
287
+ }
288
+ }
289
+ }
290
+ if (scores.length === 0) return { worst: 0, p95: 0, median: 0, comparisons: 0 };
291
+ scores.sort((a, b) => a - b);
292
+ const at = (q: number): number => _round1(scores[Math.min(scores.length - 1, Math.floor(q * scores.length))] as number);
293
+ return { worst: _round1(scores[scores.length - 1] as number), p95: at(0.95), median: at(0.5), comparisons: scores.length };
294
+ }
295
+
296
+ function _writeReport(byClass: Map<ArtifactClass, Artifact[]>, pairs: Pair[], dist: ReturnType<typeof _distribution>): void {
297
+ fs.mkdirSync(REPORT_DIR, { recursive: true });
298
+ const scanned = [...byClass.values()].reduce((n, a) => n + a.length, 0);
299
+ const json = {
300
+ fail_threshold: FAIL,
301
+ warn_threshold: WARN,
302
+ artifacts_scanned: scanned,
303
+ distribution: dist,
304
+ pairs,
305
+ };
306
+ fs.writeFileSync(OUT_JSON, JSON.stringify(json, null, 2) + '\n', 'utf-8');
307
+
308
+ const L: string[] = [
309
+ '# Originality audit — entity-neutralized shingle overlap\n',
310
+ `> Anti-reskin gate over ${scanned} authored artifacts (skills · personas · commands), `,
311
+ `class-scoped, scaffold-subtracted, k=${K}. Thresholds: FAIL ${FAIL} / WARN ${WARN}. `,
312
+ 'This report blocks NOTHING on its own — `lint_originality --changed` is the CI gate.\n',
313
+ `\n- Artifacts scanned: **${scanned}**`,
314
+ `\n- Pairwise comparisons: **${dist.comparisons}**`,
315
+ `\n- Overlap distribution: worst **${dist.worst}%** · p95 **${dist.p95}%** · median **${dist.median}%**`,
316
+ `\n- Pairs ≥ WARN (${WARN}%): **${pairs.length}** (${pairs.filter((p) => p.overlap >= FAIL).length} ≥ FAIL)\n`,
317
+ '\n## Pairs at or above the warn threshold\n',
318
+ ];
319
+ if (pairs.length === 0) {
320
+ L.push('None — no same-class pair exceeds the warn threshold.\n');
321
+ } else {
322
+ L.push('| Class | A | B | overlap |');
323
+ L.push('|---|---|---|--:|');
324
+ for (const p of pairs) {
325
+ const flag = p.overlap >= FAIL ? ' ❌' : '';
326
+ L.push(`| ${p.cls} | \`${p.a}\` | \`${p.b}\` | ${p.overlap}%${flag} |`);
327
+ }
328
+ }
329
+ fs.writeFileSync(OUT_MD, L.join('\n') + '\n', 'utf-8');
330
+ }
331
+
332
+ // --- changed mode ------------------------------------------------------------
333
+
334
+ function _changed(files: string[]): { pairs: Pair[]; fails: number } {
335
+ // The boilerplate DF set is derived from the ESTABLISHED corpus only — the
336
+ // change set under review is excluded. Without this, a batch of ≥ _dfFloor
337
+ // near-identical re-skins submitted together would lift its OWN shared
338
+ // shingles over the floor, be reclassified as boilerplate, subtracted from
339
+ // every copy, and score 0 against each other — the gate blinds itself on
340
+ // exactly the attack it exists to catch. "What is scaffold" must be defined
341
+ // by the corpus as it stands, never by the diff proposing to change it.
342
+ const changedRel = new Set(files.map((f) => _rel(path.resolve(ROOT, f))));
343
+
344
+ // Build the corpus in RAW (template-subtracted, pre-DF) form per class so we
345
+ // can derive the same boilerplate set the candidate is filtered against.
346
+ const byClass = new Map<ArtifactClass, Artifact[]>();
347
+ const boilerByClass = new Map<ArtifactClass, Set<string>>();
348
+ const templateCache = new Map<ArtifactClass, Set<string>>();
349
+ for (const spec of CLASSES) {
350
+ const tmpl = _templateShingles(spec);
351
+ templateCache.set(spec.name, tmpl);
352
+ const arts = spec.files().map((f) => _load(f, spec.name, tmpl));
353
+ // Established corpus = on-disk arts minus the change set.
354
+ const boiler = _boilerplateSet(arts.filter((a) => !changedRel.has(a.relpath)));
355
+ for (const a of arts) _subtract(a, boiler);
356
+ byClass.set(spec.name, arts);
357
+ boilerByClass.set(spec.name, boiler);
358
+ }
359
+
360
+ // Load each changed file as an artifact (skip un-classifiable ones) and
361
+ // subtract the same class boilerplate so the comparison is apples-to-apples.
362
+ const changedArts: Artifact[] = [];
363
+ for (const f of files) {
364
+ const abs = path.resolve(ROOT, f);
365
+ const spec = _classify(abs);
366
+ if (!spec || !_isFile(abs)) continue;
367
+ const a = _load(abs, spec.name, templateCache.get(spec.name) as Set<string>);
368
+ _subtract(a, boilerByClass.get(spec.name) as Set<string>);
369
+ changedArts.push(a);
370
+ }
371
+
372
+ const pairs: Pair[] = [];
373
+ const seen = new Set<string>();
374
+ const record = (a: Artifact, b: Artifact): void => {
375
+ if (a.relpath === b.relpath) return;
376
+ const key = [a.relpath, b.relpath].sort().join('');
377
+ if (seen.has(key)) return;
378
+ const o = overlap(a, b);
379
+ if (o >= WARN) {
380
+ seen.add(key);
381
+ const [x, y] = a.relpath <= b.relpath ? [a, b] : [b, a];
382
+ pairs.push({ cls: a.cls, a: x.label, b: y.label, a_path: x.relpath, b_path: y.relpath, overlap: _round1(o) });
383
+ }
384
+ };
385
+
386
+ for (const cand of changedArts) {
387
+ // vs the whole same-class corpus
388
+ for (const other of byClass.get(cand.cls) ?? []) record(cand, other);
389
+ // vs the other changed files of the same class
390
+ for (const other of changedArts) if (other.cls === cand.cls) record(cand, other);
391
+ }
392
+ pairs.sort((x, y) => y.overlap - x.overlap || (x.a_path < y.a_path ? -1 : 1));
393
+ return { pairs, fails: pairs.filter((p) => p.overlap >= FAIL).length };
394
+ }
395
+
396
+ // --- main --------------------------------------------------------------------
397
+
398
+ export function main(argv?: string[]): number {
399
+ const args = argv ?? process.argv.slice(2);
400
+ if (args.includes('-h') || args.includes('--help')) {
401
+ process.stdout.write(_usage());
402
+ return 0;
403
+ }
404
+ let quiet = false;
405
+ let changedFiles: string[] | null = null;
406
+ for (let i = 0; i < args.length; i++) {
407
+ const a = args[i] as string;
408
+ if (a === '--quiet') { quiet = true; }
409
+ else if (a === '--changed') { changedFiles = args.slice(i + 1).filter((x) => !x.startsWith('-')); break; }
410
+ else if (a.startsWith('-')) { process.stderr.write(`unrecognized argument: ${a}\n${_usage()}`); return 2; }
411
+ else { process.stderr.write(`unexpected argument: ${a} (did you mean --changed ${a}?)\n${_usage()}`); return 2; }
412
+ }
413
+
414
+ if (changedFiles !== null) {
415
+ const { pairs, fails } = _changed(changedFiles);
416
+ if (pairs.length > 0) {
417
+ const stream = fails > 0 ? process.stderr : process.stdout;
418
+ stream.write(`lint_originality: ${pairs.length} overlap(s) ≥ WARN ${WARN}% (${fails} ≥ FAIL ${FAIL}%):\n`);
419
+ for (const p of pairs) {
420
+ stream.write(` [${p.cls}] ${p.a_path} ~ ${p.b_path} ${p.overlap}%${p.overlap >= FAIL ? ' ❌' : ''}\n`);
421
+ }
422
+ } else if (!quiet) {
423
+ process.stdout.write(`lint_originality: OK — ${changedFiles.length} changed file(s), no overlap ≥ WARN ${WARN}%.\n`);
424
+ }
425
+ return fails > 0 ? 1 : 0;
426
+ }
427
+
428
+ // Full audit
429
+ const byClass = _corpus();
430
+ const scanned = [...byClass.values()].reduce((n, a) => n + a.length, 0);
431
+ if (scanned === 0) {
432
+ process.stderr.write('❌ No artifacts found under the corpus roots.\n');
433
+ return 3;
434
+ }
435
+ const dist = _distribution(byClass);
436
+ const pairs = _fullPairs(byClass);
437
+ _writeReport(byClass, pairs, dist);
438
+ const fails = pairs.filter((p) => p.overlap >= FAIL).length;
439
+ if (!quiet) {
440
+ process.stdout.write(
441
+ `lint_originality: ${scanned} artifacts, worst ${dist.worst}% · p95 ${dist.p95}% · median ${dist.median}%; ` +
442
+ `${pairs.length} pair(s) ≥ WARN ${WARN}% (${fails} ≥ FAIL ${FAIL}%).\n`,
443
+ );
444
+ process.stdout.write(` JSON: ${_rel(OUT_JSON)}\n`);
445
+ process.stdout.write(` MD: ${_rel(OUT_MD)}\n`);
446
+ }
447
+ return fails > 0 ? 1 : 0;
448
+ }
449
+
450
+ function _usage(): string {
451
+ return 'usage: lint_originality [--quiet] [--changed <file...>]\n';
452
+ }
453
+
454
+ function _isCliEntry(): boolean {
455
+ if (process.argv[1] === undefined) return false;
456
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
457
+ try {
458
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
459
+ } catch { return false; }
460
+ }
461
+
462
+ if (_isCliEntry()) {
463
+ process.exit(main());
464
+ }