@event4u/agent-config 12.0.0 → 12.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +122 -0
- package/agents/templates/.ai-council.yml.example +46 -3
- package/dist/agent-src/contexts/model-recommendations.md +6 -4
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
- package/dist/agent-src/scripts/update_roadmap_progress.ts +32 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +7 -7
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +41 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-K34QTSD5.js +3895 -0
- package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-LKZ5DOYW.js} +557 -297
- package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-O52B4F5S.js} +123 -2338
- package/dist/cli-delegate/cmd_conformance.js +4 -4
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +2574 -1080
- package/dist/install/install.mjs +744 -482
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +113 -10
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/model_tier.js +9 -0
- package/dist/scripts/_lib/model_tier.js.map +1 -1
- package/dist/scripts/condense.js +50 -0
- package/dist/scripts/condense.js.map +1 -1
- package/docs/MIGRATION.md +20 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +30 -2
- package/src/agent-src/scripts/update_roadmap_progress.ts +32 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +39 -9
- package/src/config/gate-violation-baselines.json +6 -0
- package/src/config/hook-token-budget.json +7 -1
- package/src/config/preamble-payload-budget.json +28 -0
- package/src/scripts/_dispatch.bash +10 -0
- package/src/scripts/_lib/git_common_dir.ts +173 -0
- package/src/scripts/_lib/model_tier.ts +9 -0
- package/src/scripts/_lib/release_highlights.ts +108 -7
- package/src/scripts/_lib/skill_catalogue.ts +862 -0
- package/src/scripts/ai_council/clients.ts +182 -3
- package/src/scripts/ai_council/config.ts +16 -1
- package/src/scripts/ai_council/low_impact.ts +14 -1
- package/src/scripts/ai_council/orchestrator.ts +14 -1
- package/src/scripts/ai_council/transport_resolver.ts +32 -2
- package/src/scripts/capture_skill_catalogue.ts +185 -328
- package/src/scripts/check_branch_freshness.ts +7 -1
- package/src/scripts/check_release_published.ts +6 -2
- package/src/scripts/check_release_trunk_sync.ts +18 -2
- package/src/scripts/condense.ts +50 -1
- package/src/scripts/dispatch_r2_reviewer.ts +15 -5
- package/src/scripts/evidence_report.ts +15 -4
- package/src/scripts/hook_manifest.yaml +47 -13
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
- package/src/scripts/hot_context_hook.ts +13 -1
- package/src/scripts/install.ts +129 -0
- package/src/scripts/janitor.ts +77 -2
- package/src/scripts/lint_plan_risk_register.ts +22 -9
- package/src/scripts/lint_roadmap_blockers.ts +109 -6
- package/src/scripts/lint_scheduled_deprecations.ts +738 -0
- package/src/scripts/migration_status.ts +17 -1
- package/src/scripts/probe_review_binding_drift.ts +731 -0
- package/src/scripts/release.ts +95 -1
- package/src/scripts/schemas/command.schema.json +2 -2
- package/src/scripts/schemas/skill.schema.json +2 -1
- package/src/scripts/schemas/subagent.schema.json +2 -2
- package/src/scripts/session_eol_report.ts +81 -0
- package/src/scripts/workspace_doctor.ts +464 -0
- package/src/scripts/worktree_cleanup_check.ts +2 -1
- package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
package/src/scripts/install.ts
CHANGED
|
@@ -81,6 +81,12 @@ import * as rule_layer_overlap from './_lib/rule_layer_overlap.js';
|
|
|
81
81
|
import * as installed_tools from './_lib/installed_tools.js';
|
|
82
82
|
import { collect_drift, format_drift_report } from './_lib/install_drift.js';
|
|
83
83
|
import * as user_global_paths from './_lib/user_global_paths.js';
|
|
84
|
+
// The LIBRARY half, deliberately not `capture_skill_catalogue.js`: that module
|
|
85
|
+
// is a CLI entry with a top-level `process.exit()`, and esbuild would bundle
|
|
86
|
+
// that exit into the installer a consumer loads (`check_installer_import_purity`
|
|
87
|
+
// catches exactly this). The lib has no entry and no exit.
|
|
88
|
+
import * as catalogue from './_lib/skill_catalogue.js';
|
|
89
|
+
import { isInteractiveSession, type SessionProbe } from '../shared/interactiveContext.js';
|
|
84
90
|
import * as claude_desktop_bundler from './_lib/claude_desktop_bundler.js';
|
|
85
91
|
import * as claude_settings_hooks from './_lib/claude_settings_hooks.js';
|
|
86
92
|
import { find_project_root_with_anchor, load_agent_settings } from './_lib/agent_settings.js';
|
|
@@ -3899,6 +3905,32 @@ function install_global(
|
|
|
3899
3905
|
if (!state.QUIET) warn(`settings-surface snapshot failed — ${String(e)}`);
|
|
3900
3906
|
}
|
|
3901
3907
|
|
|
3908
|
+
// Catalogue-budget warning (road-to-skill-catalogue-budget Phase 1).
|
|
3909
|
+
// Some hosts silently truncate the skill catalogue they were handed: the
|
|
3910
|
+
// deploy reports success, and the model never sees most of what was
|
|
3911
|
+
// deployed. A host that publishes its own dropped count has a MEASURED
|
|
3912
|
+
// ceiling; one that does not gets no warning and no invented number.
|
|
3913
|
+
// Warn only — over-shipping is the safe direction, and a consumer who
|
|
3914
|
+
// accepts truncation is making a legitimate choice.
|
|
3915
|
+
if (!state.QUIET) {
|
|
3916
|
+
try {
|
|
3917
|
+
for (const line of _catalogue_truncation_warnings(deploy_results, project_root)) {
|
|
3918
|
+
warn(line);
|
|
3919
|
+
}
|
|
3920
|
+
const notice = _scoped_migration_notice(deploy_results, project_root, package_root, {
|
|
3921
|
+
env: process.env,
|
|
3922
|
+
stdinTty: process.stdin.isTTY === true,
|
|
3923
|
+
stdoutTty: process.stdout.isTTY === true,
|
|
3924
|
+
});
|
|
3925
|
+
if (notice.length > 0) {
|
|
3926
|
+
process.stdout.write('\n');
|
|
3927
|
+
for (const line of notice) info(line);
|
|
3928
|
+
}
|
|
3929
|
+
} catch {
|
|
3930
|
+
// A measurement must never sink an install that otherwise worked.
|
|
3931
|
+
}
|
|
3932
|
+
}
|
|
3933
|
+
|
|
3902
3934
|
if (!state.QUIET) {
|
|
3903
3935
|
process.stdout.write('\n');
|
|
3904
3936
|
success(`Global install completed (v${installed_version}).`);
|
|
@@ -3907,6 +3939,103 @@ function install_global(
|
|
|
3907
3939
|
return 0;
|
|
3908
3940
|
}
|
|
3909
3941
|
|
|
3942
|
+
/**
|
|
3943
|
+
* One line per deployed host whose catalogue exceeds a ceiling this machine has
|
|
3944
|
+
* actually measured. Empty when nothing has been measured — the observation log
|
|
3945
|
+
* lives where captures accumulate, and an install on a machine that never ran
|
|
3946
|
+
* one has no basis to claim a limit.
|
|
3947
|
+
*
|
|
3948
|
+
* Returns lines rather than printing them so the decision is testable without
|
|
3949
|
+
* capturing stdout.
|
|
3950
|
+
*/
|
|
3951
|
+
export function _catalogue_truncation_warnings(
|
|
3952
|
+
deploy_results: Record<string, DeployResult>,
|
|
3953
|
+
project_root: string | null,
|
|
3954
|
+
): string[] {
|
|
3955
|
+
const log_candidates = [
|
|
3956
|
+
path.join(user_global_paths.event4u_root(), 'state', 'skill-catalogue.jsonl'),
|
|
3957
|
+
...(project_root ? [path.join(project_root, catalogue.OBSERVATION_LOG)] : []),
|
|
3958
|
+
];
|
|
3959
|
+
const records = log_candidates.flatMap((p) => catalogue.readObservationLog(p));
|
|
3960
|
+
if (records.length === 0) return [];
|
|
3961
|
+
|
|
3962
|
+
const limits = catalogue.knownHostLimits(records);
|
|
3963
|
+
if (limits.size === 0) return [];
|
|
3964
|
+
|
|
3965
|
+
const lines: string[] = [];
|
|
3966
|
+
for (const tool_id of Object.keys(deploy_results).sort()) {
|
|
3967
|
+
const [, , status] = deploy_results[tool_id] as DeployResult;
|
|
3968
|
+
if (status !== 'deployed') continue;
|
|
3969
|
+
const limit = limits.get(tool_id);
|
|
3970
|
+
if (limit === undefined) continue;
|
|
3971
|
+
const anchor_raw = USER_SCOPE_PATHS[tool_id];
|
|
3972
|
+
if (!anchor_raw) continue;
|
|
3973
|
+
|
|
3974
|
+
const volume = catalogue.measureCatalogueVolume(tool_id, expanduser(anchor_raw));
|
|
3975
|
+
const warning = catalogue.catalogueLimitWarning(volume, limit);
|
|
3976
|
+
if (warning !== null) lines.push(warning);
|
|
3977
|
+
}
|
|
3978
|
+
return lines;
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3981
|
+
/**
|
|
3982
|
+
* The scoped-projection migration notice for an EXISTING install.
|
|
3983
|
+
*
|
|
3984
|
+
* Shape decided by AI council 2026-08-15 (2/2 converged, both members present):
|
|
3985
|
+
* option B — the existing warning PLUS an interactive notice that persists
|
|
3986
|
+
* NOTHING and names the manual edit and the GUI route. `projection.mode` is
|
|
3987
|
+
* settings class C, so no agent may write it; a notice that offered a CLI
|
|
3988
|
+
* write would send the reader to a command guaranteed to refuse them.
|
|
3989
|
+
*
|
|
3990
|
+
* Eligibility and delivery are separate on purpose. A non-interactive run can
|
|
3991
|
+
* be fully eligible; it simply gets the warning and no notice, and the two
|
|
3992
|
+
* outcomes stay distinguishable instead of collapsing into "nothing printed".
|
|
3993
|
+
*
|
|
3994
|
+
* Known and accepted: with nothing persisted, a qualifying install sees this
|
|
3995
|
+
* on every deploy. The council named that as the strongest argument for doing
|
|
3996
|
+
* nothing at all, and it does not outweigh making a measured, silent
|
|
3997
|
+
* capability loss an explicit human decision.
|
|
3998
|
+
*/
|
|
3999
|
+
export function _scoped_migration_notice(
|
|
4000
|
+
deploy_results: Record<string, DeployResult>,
|
|
4001
|
+
project_root: string | null,
|
|
4002
|
+
package_root: string,
|
|
4003
|
+
probe: SessionProbe,
|
|
4004
|
+
): string[] {
|
|
4005
|
+
if (!isInteractiveSession(probe)) return [];
|
|
4006
|
+
|
|
4007
|
+
const log_candidates = [
|
|
4008
|
+
path.join(user_global_paths.event4u_root(), 'state', 'skill-catalogue.jsonl'),
|
|
4009
|
+
...(project_root ? [path.join(project_root, catalogue.OBSERVATION_LOG)] : []),
|
|
4010
|
+
];
|
|
4011
|
+
const limits = catalogue.knownHostLimits(log_candidates.flatMap((p) => catalogue.readObservationLog(p)));
|
|
4012
|
+
if (limits.size === 0) return [];
|
|
4013
|
+
|
|
4014
|
+
const resolved = _resolve_scoped_projection(package_root);
|
|
4015
|
+
const settings_path =
|
|
4016
|
+
_resolve_global_settings_path() ??
|
|
4017
|
+
path.join(user_global_paths.event4u_root(), 'settings', SETTINGS_FILE);
|
|
4018
|
+
|
|
4019
|
+
for (const tool_id of Object.keys(deploy_results).sort()) {
|
|
4020
|
+
const [, , status] = deploy_results[tool_id] as DeployResult;
|
|
4021
|
+
if (status !== 'deployed') continue;
|
|
4022
|
+
const anchor_raw = USER_SCOPE_PATHS[tool_id];
|
|
4023
|
+
if (!anchor_raw) continue;
|
|
4024
|
+
|
|
4025
|
+
const volume = catalogue.measureCatalogueVolume(tool_id, expanduser(anchor_raw));
|
|
4026
|
+
const eligibility = catalogue.migrationEligibility(
|
|
4027
|
+
tool_id,
|
|
4028
|
+
resolved.mode,
|
|
4029
|
+
volume.skillEntries,
|
|
4030
|
+
limits,
|
|
4031
|
+
);
|
|
4032
|
+
if (eligibility.eligible) {
|
|
4033
|
+
return catalogue.migrationPromptLines(tool_id, eligibility, settings_path);
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
return [];
|
|
4037
|
+
}
|
|
4038
|
+
|
|
3910
4039
|
const SETTINGS_SURFACE_REL = path.join('state', 'settings-surface.json');
|
|
3911
4040
|
const SETTINGS_DELTA_REL = path.join('state', 'settings-delta.json');
|
|
3912
4041
|
|
package/src/scripts/janitor.ts
CHANGED
|
@@ -24,22 +24,82 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
24
24
|
const _HERE = fileURLToPath(import.meta.url);
|
|
25
25
|
const REPO_ROOT = path.join(path.dirname(_HERE), '..', '..');
|
|
26
26
|
|
|
27
|
-
// TTL configuration per directory (days)
|
|
28
|
-
|
|
27
|
+
// TTL configuration per directory (days).
|
|
28
|
+
//
|
|
29
|
+
// `owner` / `reviewBy` (road-to-inbox-harvest-2026-08-d-context-ledger Step
|
|
30
|
+
// 4.1) put every TTL under the same discipline the two budget config files
|
|
31
|
+
// carry: a retention number nobody owns and nobody revisits is a guess that
|
|
32
|
+
// hardens into a fact. The falsifier is printed per run — see the reclaim
|
|
33
|
+
// total in `main` — so a TTL that reclaims nothing over a review window is
|
|
34
|
+
// removed rather than defended.
|
|
35
|
+
const TTL_CONFIG: ReadonlyArray<{
|
|
36
|
+
dir: string;
|
|
37
|
+
ttlDays: number;
|
|
38
|
+
description: string;
|
|
39
|
+
owner: string;
|
|
40
|
+
reviewBy: string;
|
|
41
|
+
}> = [
|
|
29
42
|
{
|
|
30
43
|
dir: 'agents/tmp.old',
|
|
31
44
|
ttlDays: 30,
|
|
32
45
|
description: 'Processed inbox archive (consumed agents/tmp/ files)',
|
|
46
|
+
owner: 'maintainer',
|
|
47
|
+
reviewBy: '2026-11-10',
|
|
33
48
|
},
|
|
34
49
|
{
|
|
35
50
|
dir: 'agents/runtime/tmp',
|
|
36
51
|
ttlDays: 7,
|
|
37
52
|
description: 'Agent scratch / runtime tmp',
|
|
53
|
+
owner: 'maintainer',
|
|
54
|
+
reviewBy: '2026-11-10',
|
|
38
55
|
},
|
|
39
56
|
{
|
|
40
57
|
dir: 'agents/runtime/council/responses',
|
|
41
58
|
ttlDays: 7,
|
|
42
59
|
description: 'Council response cache',
|
|
60
|
+
owner: 'maintainer',
|
|
61
|
+
reviewBy: '2026-11-10',
|
|
62
|
+
},
|
|
63
|
+
// ── Session-scoped runtime state (Step 4.1) ────────────────────────────
|
|
64
|
+
//
|
|
65
|
+
// These four directories accrete ONE entry per session behind a
|
|
66
|
+
// once-per-session latch, and nothing in the tree removed them: writers
|
|
67
|
+
// and readers existed, no reaper did. They are named INDIVIDUALLY rather
|
|
68
|
+
// than sweeping `agents/runtime/state` as a whole, and that is the
|
|
69
|
+
// load-bearing decision in this step.
|
|
70
|
+
//
|
|
71
|
+
// `agents/runtime/state` also holds the CUMULATIVE measurement streams —
|
|
72
|
+
// `audit/`, `injection-census.jsonl`, `tool-result-census.jsonl`,
|
|
73
|
+
// `ai-video-telemetry.jsonl`, the memory index — and a directory-level
|
|
74
|
+
// TTL would delete exactly the corpora this roadmap exists to build. A
|
|
75
|
+
// sweeper that eats the instrument's own data is worse than no sweeper.
|
|
76
|
+
{
|
|
77
|
+
dir: 'agents/runtime/state/session-eol',
|
|
78
|
+
ttlDays: 7,
|
|
79
|
+
description: 'Per-session end-of-life counters (one file per session)',
|
|
80
|
+
owner: 'maintainer',
|
|
81
|
+
reviewBy: '2026-11-10',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
dir: 'agents/runtime/state/reread-guard',
|
|
85
|
+
ttlDays: 7,
|
|
86
|
+
description: 'Per-session re-read latch',
|
|
87
|
+
owner: 'maintainer',
|
|
88
|
+
reviewBy: '2026-11-10',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
dir: 'agents/runtime/state/end-review-nudge',
|
|
92
|
+
ttlDays: 7,
|
|
93
|
+
description: 'Per-session end-review latch',
|
|
94
|
+
owner: 'maintainer',
|
|
95
|
+
reviewBy: '2026-11-10',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
dir: 'agents/runtime/state/probe-throttle',
|
|
99
|
+
ttlDays: 7,
|
|
100
|
+
description: 'Per-session probe throttle latch',
|
|
101
|
+
owner: 'maintainer',
|
|
102
|
+
reviewBy: '2026-11-10',
|
|
43
103
|
},
|
|
44
104
|
];
|
|
45
105
|
|
|
@@ -227,6 +287,21 @@ function report(apply: boolean): void {
|
|
|
227
287
|
);
|
|
228
288
|
} else if (apply && totalExpired > 0) {
|
|
229
289
|
process.stdout.write(`Total deleted: ${totalExpired} file(s), ${_humanSize(totalSize)} freed.\n`);
|
|
290
|
+
} else {
|
|
291
|
+
// road-to-inbox-harvest-2026-08-d-context-ledger Step 4.2 — the TTL's
|
|
292
|
+
// own falsifier, and it only works if a ZERO is printed.
|
|
293
|
+
//
|
|
294
|
+
// Until now a run that reclaimed nothing printed no total at all, so
|
|
295
|
+
// "the sweeper ran and found nothing expired" and "the sweeper did not
|
|
296
|
+
// run" produced byte-identical output. That is the difference between
|
|
297
|
+
// evidence that a retention policy is unnecessary and no evidence at
|
|
298
|
+
// all — and the first is what removes a TTL entry rather than letting
|
|
299
|
+
// it harden into an unexamined default.
|
|
300
|
+
process.stdout.write(
|
|
301
|
+
`Total ${apply ? 'deleted' : 'reclaimable'}: 0 file(s), 0 B — nothing was past its TTL.\n` +
|
|
302
|
+
`A reclaim that stays at zero across a review window is the signal to drop the ` +
|
|
303
|
+
`TTL entry, not to keep it: see the owner / reviewBy fields on each entry.\n`,
|
|
304
|
+
);
|
|
230
305
|
}
|
|
231
306
|
}
|
|
232
307
|
|
|
@@ -39,6 +39,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
39
39
|
|
|
40
40
|
import { parse as parseYaml } from 'yaml';
|
|
41
41
|
|
|
42
|
+
import { workspaceIdentity } from './_lib/git_common_dir.js';
|
|
42
43
|
import { gitEnv } from './_lib/git_env.js';
|
|
43
44
|
import { splitMarkdownRow } from './_lib/md_table.js';
|
|
44
45
|
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
@@ -505,12 +506,26 @@ interface HistoryEntry {
|
|
|
505
506
|
date: string; // %cs — YYYY-MM-DD
|
|
506
507
|
}
|
|
507
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Repo root containing `dir`, or `null` outside a repository.
|
|
511
|
+
*
|
|
512
|
+
* Census rows R5–R7. The three call sites below each spawned
|
|
513
|
+
* `rev-parse --show-toplevel` through `_git` — already `GIT_DIR`-safe via
|
|
514
|
+
* `gitEnv()`, so this migration is behaviour-identical by construction and
|
|
515
|
+
* exists to remove the third, fourth and fifth re-derivation of one question,
|
|
516
|
+
* not to fix a defect. The resolver reads files instead of spawning, so the
|
|
517
|
+
* three checks also stop costing three subprocesses.
|
|
518
|
+
*/
|
|
519
|
+
function _repoRootOf(dir: string): string | null {
|
|
520
|
+
const root = workspaceIdentity(dir).repoRoot;
|
|
521
|
+
return root.resolved ? root.value : null;
|
|
522
|
+
}
|
|
523
|
+
|
|
508
524
|
/** Newest-first commit history of a file; empty for untracked / non-repo paths. */
|
|
509
525
|
export function fileHistory(absPath: string): HistoryEntry[] {
|
|
510
526
|
const dir = path.dirname(absPath);
|
|
511
|
-
const
|
|
512
|
-
if (
|
|
513
|
-
const root = top.trim();
|
|
527
|
+
const root = _repoRootOf(dir);
|
|
528
|
+
if (root === null) return [];
|
|
514
529
|
// Normalize separators: git pathspecs use `/` on every platform, so a raw
|
|
515
530
|
// Windows `path.relative()` result matches nothing and would silently yield
|
|
516
531
|
// an empty history — which the grandfather clause reads as "no baseline".
|
|
@@ -540,9 +555,8 @@ export function fileHistory(absPath: string): HistoryEntry[] {
|
|
|
540
555
|
*/
|
|
541
556
|
export function hasUncommittedChanges(absPath: string): boolean {
|
|
542
557
|
const dir = path.dirname(absPath);
|
|
543
|
-
const
|
|
544
|
-
if (
|
|
545
|
-
const root = top.trim();
|
|
558
|
+
const root = _repoRootOf(dir);
|
|
559
|
+
if (root === null) return false;
|
|
546
560
|
const rel = path.relative(root, absPath).split(path.sep).join('/');
|
|
547
561
|
const out = _git(root, ['status', '--porcelain', '--', rel]);
|
|
548
562
|
if (out === null) return false;
|
|
@@ -551,9 +565,8 @@ export function hasUncommittedChanges(absPath: string): boolean {
|
|
|
551
565
|
|
|
552
566
|
function _blobAt(absPath: string, sha: string): string | null {
|
|
553
567
|
const dir = path.dirname(absPath);
|
|
554
|
-
const
|
|
555
|
-
if (
|
|
556
|
-
const root = top.trim();
|
|
568
|
+
const root = _repoRootOf(dir);
|
|
569
|
+
if (root === null) return null;
|
|
557
570
|
const rel = path.relative(root, absPath).split(path.sep).join('/');
|
|
558
571
|
return _git(root, ['show', `${sha}:${rel}`]);
|
|
559
572
|
}
|
|
@@ -20,6 +20,7 @@ import * as fs from 'node:fs';
|
|
|
20
20
|
import * as path from 'node:path';
|
|
21
21
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
22
22
|
|
|
23
|
+
import { checkRatchet } from './_lib/gate_baseline.js';
|
|
23
24
|
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
24
25
|
|
|
25
26
|
const _HERE = fileURLToPath(import.meta.url);
|
|
@@ -46,6 +47,41 @@ const REQUIRED_FIELDS: ReadonlyArray<readonly [string, RegExp]> = [
|
|
|
46
47
|
['Resolved when', /^-[ \t]*\*\*Resolved when:\*\*/im],
|
|
47
48
|
];
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The fields that make a blocker DECIDABLE rather than merely described.
|
|
52
|
+
*
|
|
53
|
+
* Separated from `REQUIRED_FIELDS` on purpose: the five above are a settled
|
|
54
|
+
* contract every entry in the tree already satisfies, so a missing one is a
|
|
55
|
+
* hard failure. These three are new, and 36 open entries predate them — held
|
|
56
|
+
* to the same bar on day one they would red the whole backlog, which is the
|
|
57
|
+
* "strict gate fires on ~283 files" failure this repo has already recorded
|
|
58
|
+
* once. They run through the ratchet instead: the backlog is legal at its
|
|
59
|
+
* measured size, and every entry added or edited after this must clear them.
|
|
60
|
+
*/
|
|
61
|
+
const DECIDABILITY_FIELDS: ReadonlyArray<readonly [string, RegExp]> = [
|
|
62
|
+
['Recommendation', /^-[ \t]*\*\*Recommendation:\*\*[ \t]*\S/im],
|
|
63
|
+
['If you do nothing', /^-[ \t]*\*\*If you do nothing:\*\*[ \t]*\S/im],
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** `What to do:` body — everything up to the next field marker. */
|
|
67
|
+
const WHAT_TO_DO_SLICE_RE =
|
|
68
|
+
/^-[ \t]*\*\*What to do:\*\*([\s\S]*?)(?=^-[ \t]*\*\*[A-Z]|^###[ \t]|$(?![\s\S]))/im;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Does `What to do` carry anything the owner can actually execute?
|
|
72
|
+
*
|
|
73
|
+
* A backticked span (a command, a path, a flag) or an enumerated option set
|
|
74
|
+
* (`(a)` / `1.`) both count. Bare prose does not: "pick exactly one — accept
|
|
75
|
+
* the reading, or re-cut the criterion" names no file, no command and no
|
|
76
|
+
* consequence, and hands the analysis back to the person least equipped to
|
|
77
|
+
* redo it. Measured 2026-08-15: 14 of 46 entries in this tree are that shape.
|
|
78
|
+
*/
|
|
79
|
+
function _hasExecutableSubstance(body: string): boolean {
|
|
80
|
+
const m = WHAT_TO_DO_SLICE_RE.exec(body);
|
|
81
|
+
const slice = m ? (m[1] as string) : '';
|
|
82
|
+
return /`[^`\n]+`/.test(slice) || /(^|\s)\((?:[a-z]|[0-9]+)\)\s/i.test(slice);
|
|
83
|
+
}
|
|
84
|
+
|
|
49
85
|
interface Violation {
|
|
50
86
|
line: number;
|
|
51
87
|
message: string;
|
|
@@ -68,7 +104,19 @@ function _stripFencedCode(text: string): string {
|
|
|
68
104
|
}
|
|
69
105
|
|
|
70
106
|
function _scan(rawText: string): Violation[] {
|
|
107
|
+
return _scanBoth(rawText).hard;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ScanResult {
|
|
111
|
+
/** Contract violations — always fatal. */
|
|
112
|
+
hard: Violation[];
|
|
113
|
+
/** Open entries that are not yet decidable — counted against the ratchet. */
|
|
114
|
+
decidability: Violation[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _scanBoth(rawText: string): ScanResult {
|
|
71
118
|
const violations: Violation[] = [];
|
|
119
|
+
const decidability: Violation[] = [];
|
|
72
120
|
const text = _stripFencedCode(rawText);
|
|
73
121
|
const declaredIds = new Set<string>();
|
|
74
122
|
|
|
@@ -105,6 +153,23 @@ function _scan(rawText: string): Violation[] {
|
|
|
105
153
|
message: `blocker '${cur.id}' missing required field(s): ${missing.join(', ')}`,
|
|
106
154
|
});
|
|
107
155
|
}
|
|
156
|
+
// Resolved entries are history — re-litigating a decision already
|
|
157
|
+
// made would be churn, and the ratchet would never reach zero.
|
|
158
|
+
const isResolved = /^-[ \t]*\*\*Status:\*\*[ \t]*resolved/im.test(body);
|
|
159
|
+
if (!isResolved) {
|
|
160
|
+
const gaps = DECIDABILITY_FIELDS.filter(([, re]) => !re.test(body)).map(
|
|
161
|
+
([name]) => name,
|
|
162
|
+
);
|
|
163
|
+
if (!_hasExecutableSubstance(body)) {
|
|
164
|
+
gaps.push('What to do (no command, path or option set)');
|
|
165
|
+
}
|
|
166
|
+
if (gaps.length) {
|
|
167
|
+
decidability.push({
|
|
168
|
+
line: _lineAt(text, sectionStart + cur.start),
|
|
169
|
+
message: `blocker '${cur.id}' is not decidable yet: ${gaps.join(', ')}`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
108
173
|
}
|
|
109
174
|
}
|
|
110
175
|
|
|
@@ -131,7 +196,8 @@ function _scan(rawText: string): Violation[] {
|
|
|
131
196
|
}
|
|
132
197
|
}
|
|
133
198
|
violations.sort((a, b) => a.line - b.line);
|
|
134
|
-
|
|
199
|
+
decidability.sort((a, b) => a.line - b.line);
|
|
200
|
+
return { hard: violations, decidability };
|
|
135
201
|
}
|
|
136
202
|
|
|
137
203
|
/** Sorted `agents/roadmaps/*.md` (non-recursive — active roadmaps only). */
|
|
@@ -206,14 +272,18 @@ function main(): number {
|
|
|
206
272
|
return 0;
|
|
207
273
|
}
|
|
208
274
|
let failed = 0;
|
|
275
|
+
const undecidable: Array<{ rel: string; v: Violation }> = [];
|
|
209
276
|
for (const roadmap of roadmaps) {
|
|
210
277
|
const rel = _relPosix(roadmap, REPO_ROOT);
|
|
211
278
|
const text = fs.readFileSync(roadmap, 'utf-8');
|
|
212
|
-
const
|
|
213
|
-
|
|
279
|
+
const { hard, decidability } = _scanBoth(text);
|
|
280
|
+
for (const v of decidability) {
|
|
281
|
+
undecidable.push({ rel, v });
|
|
282
|
+
}
|
|
283
|
+
if (hard.length) {
|
|
214
284
|
failed += 1;
|
|
215
285
|
process.stderr.write(`❌ ${rel}\n`);
|
|
216
|
-
for (const v of
|
|
286
|
+
for (const v of hard) {
|
|
217
287
|
process.stderr.write(` line ${v.line}: ${v.message}\n`);
|
|
218
288
|
}
|
|
219
289
|
} else if (!QUIET) {
|
|
@@ -228,8 +298,31 @@ function main(): number {
|
|
|
228
298
|
);
|
|
229
299
|
return 1;
|
|
230
300
|
}
|
|
301
|
+
|
|
302
|
+
// The decidability ratchet. A blocker that names no option, no command and
|
|
303
|
+
// no recommendation is a research task handed to the person with the least
|
|
304
|
+
// context — the defect this half of the gate exists to stop growing.
|
|
305
|
+
const verdict = checkRatchet({
|
|
306
|
+
gate: 'lint_roadmap_blockers:decidability',
|
|
307
|
+
actual: undecidable.length,
|
|
308
|
+
repoRoot: REPO_ROOT,
|
|
309
|
+
});
|
|
310
|
+
if (!verdict.ok) {
|
|
311
|
+
process.stderr.write(`❌ ${verdict.message}\n`);
|
|
312
|
+
for (const { rel, v } of undecidable) {
|
|
313
|
+
process.stderr.write(` ${rel}:${v.line}: ${v.message}\n`);
|
|
314
|
+
}
|
|
315
|
+
process.stderr.write(
|
|
316
|
+
'\n Every open blocker needs **Recommendation:** (which option, and why),\n' +
|
|
317
|
+
' **If you do nothing:** (the cost of the non-decision), and a\n' +
|
|
318
|
+
' **What to do:** carrying a command, a path or an enumerated option set.\n' +
|
|
319
|
+
' See templates/roadmaps.md rule 20.\n',
|
|
320
|
+
);
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
231
323
|
if (!QUIET) {
|
|
232
324
|
process.stdout.write(`\n✅ ${roadmaps.length} roadmap(s) blocker-contract-clean\n`);
|
|
325
|
+
process.stdout.write(`✅ ${verdict.message}\n`);
|
|
233
326
|
}
|
|
234
327
|
return 0;
|
|
235
328
|
}
|
|
@@ -260,5 +353,15 @@ if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
|
260
353
|
process.exit(main());
|
|
261
354
|
}
|
|
262
355
|
|
|
263
|
-
export {
|
|
264
|
-
|
|
356
|
+
export {
|
|
357
|
+
REPO_ROOT,
|
|
358
|
+
ROADMAP_GLOB,
|
|
359
|
+
REQUIRED_FIELDS,
|
|
360
|
+
DECIDABILITY_FIELDS,
|
|
361
|
+
_hasExecutableSubstance,
|
|
362
|
+
_scan,
|
|
363
|
+
_scanBoth,
|
|
364
|
+
_globRoadmaps,
|
|
365
|
+
main,
|
|
366
|
+
};
|
|
367
|
+
export type { Violation, ScanResult };
|