@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +111 -0
- package/CONTRIBUTING.md +14 -0
- package/README.md +12 -4
- package/dist/agent-src/commands/contribution-precheck.md +100 -0
- package/dist/agent-src/commands/fix/route.md +49 -0
- package/dist/agent-src/commands/fix.md +4 -2
- package/dist/agent-src/commands/tdd/green.md +32 -0
- package/dist/agent-src/commands/tdd/red.md +33 -0
- package/dist/agent-src/commands/tdd/refactor.md +31 -0
- package/dist/agent-src/commands/tdd.md +50 -0
- package/dist/agent-src/commands/team.md +4 -0
- package/dist/agent-src/contexts/communication/rules-auto/guidelines-mechanics.md +1 -0
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +81 -2
- package/dist/agent-src/contexts/model-recommendations.md +6 -0
- package/dist/agent-src/rules/architecture.md +7 -0
- package/dist/agent-src/rules/token-efficiency.md +7 -0
- package/dist/agent-src/skills/adversarial-review/SKILL.md +5 -0
- package/dist/agent-src/skills/ai-council/SKILL.md +3 -0
- package/dist/agent-src/skills/build-buy-partner/SKILL.md +1 -0
- package/dist/agent-src/skills/decision-record/SKILL.md +35 -1
- package/dist/agent-src/skills/decision-record/evals/triggers.json +82 -12
- package/dist/agent-src/skills/decision-record/references/weighted-matrix.md +119 -0
- package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +142 -0
- package/dist/agent-src/skills/fe-design/SKILL.md +1 -0
- package/dist/agent-src/skills/judge-code-quality/SKILL.md +1 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +1 -0
- package/dist/agent-src/skills/learning-tutor/SKILL.md +215 -0
- package/dist/agent-src/skills/persona-improvement/SKILL.md +81 -0
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +10 -0
- package/dist/agent-src/skills/rice-prioritization/SKILL.md +1 -0
- package/dist/agent-src/skills/stakeholder-tradeoff/SKILL.md +3 -1
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +14 -24
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +253 -30
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +19 -10
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +15 -6
- package/dist/mcp/registry-manifest.json +3 -3
- package/docs/CLAIMS.md +16 -2
- package/docs/announcements/2026-07-honest-cost-sweep-launch.md +84 -0
- package/docs/anti-reskin-gate.md +60 -0
- package/docs/architecture.md +3 -3
- package/docs/catalog.md +303 -278
- package/docs/command-flows.md +10 -3
- package/docs/comparison.yaml +10 -0
- package/docs/contracts/adoption-signal-floor.md +17 -0
- package/docs/contracts/ai-council-config.md +25 -0
- package/docs/contracts/command-clusters.md +2 -1
- package/docs/contracts/mcp-tool-inventory.md +25 -22
- package/docs/contracts/tier-3-contrib-plugin.md +12 -4
- package/docs/decisions/ADR-122-adversarial-verification-council.md +1 -1
- package/docs/development.md +10 -1
- package/docs/featured-skills.md +2 -2
- package/docs/getting-started-by-role.md +1 -1
- package/docs/getting-started.md +1 -1
- package/docs/governance-advantage.md +1 -1
- package/docs/guidelines/component-oriented-and-oop-development.md +181 -0
- package/docs/proof.md +10 -6
- package/docs/self-review-gate.md +14 -0
- package/docs/setup/per-ide/antigravity.md +8 -3
- package/docs/us-vs-the-category.md +38 -0
- package/docs/wedge/production-validator/README.md +24 -0
- package/docs/wedge/production-validator/first-run-check.sh +42 -0
- package/package.json +3 -2
- package/src/scripts/_lib/catalog_score.ts +64 -0
- package/src/scripts/_lib/shingle_similarity.ts +146 -0
- package/src/scripts/ai_council/clients.ts +50 -1
- package/src/scripts/ai_council/config.ts +28 -3
- package/src/scripts/ai_council/low_impact.ts +16 -0
- package/src/scripts/ai_council/necessity.ts +11 -0
- package/src/scripts/ai_council/orchestrator.ts +78 -26
- package/src/scripts/ai_council/pricing.ts +59 -0
- package/src/scripts/ai_council/prompts.ts +7 -0
- package/src/scripts/ai_council/stance_tally.ts +42 -5
- package/src/scripts/build_catalog_index.ts +188 -0
- package/src/scripts/council_cli.ts +56 -1
- package/src/scripts/generate_index.ts +6 -3
- package/src/scripts/hook_manifest.yaml +17 -6
- package/src/scripts/install-hooks.sh +32 -17
- package/src/scripts/lint_originality.ts +464 -0
- package/src/scripts/mcp_server/catalog_tools.ts +114 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +46 -0
- package/src/scripts/pr_url_reminder_hook.ts +177 -0
- package/src/scripts/schemas/flow.schema.json +17 -0
- package/src/scripts/schemas/skill.schema.json +166 -32
- package/src/scripts/self_review_gate.test.ts +48 -0
- package/src/scripts/self_review_gate.ts +73 -5
- 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
|
-
|
|
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:
|
|
18
|
-
#
|
|
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
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
echo "
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
}
|