@event4u/agent-config 14.7.0 → 14.8.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 +245 -388
- package/README.md +1 -1
- package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
- package/dist/agent-src/commands/evals/roadmap.json +17 -1
- package/dist/agent-src/commands/pr/create.md +14 -2
- package/dist/agent-src/commands/pr/merge.md +379 -0
- package/dist/agent-src/commands/roadmap/process-full.md +157 -3
- package/dist/agent-src/commands/roadmap.md +1 -1
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +64 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
- package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
- package/dist/agent-src/rules/recurring-criticism.md +1 -1
- package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
- package/dist/agent-src/rules/source-confidentiality.md +1 -1
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
- package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
- package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
- package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
- package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
- package/dist/agent-src/skills/security/SKILL.md +8 -0
- package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +20 -2
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
- package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-EEJUMCDZ.js} +14 -14
- package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
- package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
- package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
- package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
- package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
- package/dist/cli-delegate/cmd_conformance.js +14 -14
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +7 -7
- package/dist/cli-delegate/cmd_export.js +6 -6
- package/dist/cli-delegate/cmd_handoff.js +7 -6
- package/dist/cli-delegate/cmd_memory_get.js +2 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_preflight.js +5 -5
- package/dist/cli-delegate/cmd_prune.js +6 -6
- package/dist/cli-delegate/cmd_route_audit.js +6 -5
- package/dist/cli-delegate/cmd_route_explain.js +3 -3
- package/dist/cli-delegate/cmd_session_recycle.js +3 -3
- package/dist/cli-delegate/cmd_settings_get.js +3 -3
- package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
- package/dist/cli-delegate/cmd_sync.js +6 -6
- package/dist/cli-delegate/cmd_uninstall.js +6 -6
- package/dist/cli-delegate/cmd_update.js +3 -3
- package/dist/cli-delegate/cmd_upgrade.js +3 -3
- package/dist/cli-delegate/cmd_validate.js +6 -6
- package/dist/cli-delegate/cmd_versions.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +53 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -6
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -5
- package/dist/hooks/dispatch.js +806 -681
- package/dist/install/globalRuleLayers.js +169 -0
- package/dist/install/globalRuleLayers.js.map +1 -0
- package/dist/install/partitionEligibility.js +156 -0
- package/dist/install/partitionEligibility.js.map +1 -1
- package/dist/install/ruleLayerPartition.js +157 -0
- package/dist/install/ruleLayerPartition.js.map +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +654 -578
- package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
- package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
- package/dist/scripts/condense.js +52 -89
- package/dist/scripts/condense.js.map +1 -1
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
- package/docs/guidelines/agent-infra/false-green.md +47 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
- package/src/agent-src/scripts/dashboard_mode.ts +212 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
- package/src/config/ci-local-parity.yml +21 -0
- package/src/config/estate-count-budget.json +275 -2
- package/src/config/evaluator-budgets.json +3 -3
- package/src/config/gate-coverage.yml +220 -12
- package/src/config/gate-violation-baselines.json +73 -63
- package/src/config/gitignore-block.txt +15 -2
- package/src/config/hook-latency-budget.json +5 -0
- package/src/config/rule-stub-ceilings.json +17 -3
- package/src/install/globalRuleLayers.ts +197 -0
- package/src/install/partitionEligibility.ts +177 -0
- package/src/install/ruleLayerPartition.ts +212 -0
- package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
- package/src/scripts/_dispatch.bash +11 -0
- package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
- package/src/scripts/_lib/adr_frontmatter.ts +459 -0
- package/src/scripts/_lib/envelope_grounding.ts +11 -1
- package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
- package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
- package/src/scripts/adr/evidence_census.ts +899 -0
- package/src/scripts/adr/regenerate_index.ts +12 -32
- package/src/scripts/adr_cite_check.ts +540 -39
- package/src/scripts/audit_adr_coverage.ts +103 -17
- package/src/scripts/bench_hook_latency.ts +204 -0
- package/src/scripts/build_archive_index.ts +34 -0
- package/src/scripts/check_adr_frontmatter.ts +574 -39
- package/src/scripts/check_hook_bundle_content.ts +219 -0
- package/src/scripts/check_new_adr_evidence.ts +529 -0
- package/src/scripts/check_references.ts +18 -0
- package/src/scripts/check_rule_layer_partition.ts +531 -0
- package/src/scripts/check_single_delivery.ts +137 -4
- package/src/scripts/check_standing_rule_delivery.ts +8 -2
- package/src/scripts/condense.ts +61 -91
- package/src/scripts/external_sources_denylist.json +4 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +22 -2
- package/src/scripts/hooks/dispatch_hook.ts +154 -187
- package/src/scripts/hooks/dispatch_issues.ts +38 -19
- package/src/scripts/hooks/fallback_yaml.ts +66 -0
- package/src/scripts/hooks/host_semantics.ts +21 -0
- package/src/scripts/hooks/injection_budget.ts +13 -3
- package/src/scripts/hooks/py_json_dumps.ts +103 -0
- package/src/scripts/hooks/state_io.ts +88 -1
- package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
- package/src/scripts/injection_scan_hook.ts +137 -5
- package/src/scripts/lint_hook_manifest.ts +49 -0
- package/src/scripts/lint_provenance_vocabulary.ts +472 -4
- package/src/scripts/routing_doctor.ts +5 -1
- package/src/scripts/sync_pr_branch.ts +163 -20
- package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
- package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
- package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
- package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
|
@@ -26,18 +26,42 @@
|
|
|
26
26
|
* 3. Number cross-check — every N/D figure inside a scope box MUST also
|
|
27
27
|
* appear, verbatim, somewhere in `docs/CLAIMS.md` — a box number that
|
|
28
28
|
* drifts from the ledger is a build failure, not a doc nit.
|
|
29
|
+
* 4. Permanence language — an ADR record MUST NOT assert that its decision
|
|
30
|
+
* is permanent in a load-bearing position (its `# ` title, its
|
|
31
|
+
* `decision:` slug, its `## Decision` section, an `Addendum`). ADR-239
|
|
32
|
+
* § 6 is the doctrine and ADR-208 the standing demonstration: its title
|
|
33
|
+
* says the tree is kept forever, its Decision says KEEP permanently, and
|
|
34
|
+
* its own frontmatter carries the conditions under which it is reopened.
|
|
35
|
+
* A record cannot be both. Two escapes, both enforced here: permanence
|
|
36
|
+
* scoped to an EXTERNAL invariant with the condition under which that
|
|
37
|
+
* invariant stops applying stated in the same section, or an owner
|
|
38
|
+
* purpose statement recorded as `authority_basis: owner_intent`.
|
|
39
|
+
* Describing positions — `## Alternatives considered`, `## Consequences`,
|
|
40
|
+
* `## Context`, an `## Amendment`, a quoted open question, a table
|
|
41
|
+
* rationale cell — are NOT violations.
|
|
29
42
|
*
|
|
30
43
|
* Scan surfaces: README.md + every `docs/**\/*.md` file (recursive). No
|
|
31
44
|
* marketing/install-output text file carries this vocabulary today — this is
|
|
32
|
-
* the single place to widen SCAN_ROOTS when one does.
|
|
45
|
+
* the single place to widen SCAN_ROOTS when one does. Rule 4 adds no root: it
|
|
46
|
+
* filters the already-walked `docs/**` set down to ADR records
|
|
47
|
+
* ({@link isAdrRecord}) rather than widening the walk, which is why it lives
|
|
48
|
+
* here instead of in a new gate script (a new gate costs seven wiring
|
|
49
|
+
* surfaces for the same coverage).
|
|
33
50
|
*
|
|
34
|
-
*
|
|
51
|
+
* Two verdicts. Rules 1-3 hard-fail. Rule 4 is a RATCHET against
|
|
52
|
+
* `lint_provenance_vocabulary:permanence-language` in
|
|
53
|
+
* `src/config/gate-violation-baselines.json`, because the corpus carries real
|
|
54
|
+
* violations today and a gate that lands as five instant blockers is a shape
|
|
55
|
+
* this repository has already refused. A new record still hard-fails.
|
|
56
|
+
*
|
|
57
|
+
* Exit codes: 0 clean · 1 violations found (either verdict).
|
|
35
58
|
*/
|
|
36
59
|
|
|
37
60
|
import * as fs from 'node:fs';
|
|
38
61
|
import * as path from 'node:path';
|
|
39
62
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
40
63
|
|
|
64
|
+
import { checkRatchet } from './_lib/gate_baseline.js';
|
|
41
65
|
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
42
66
|
|
|
43
67
|
const _HERE = fileURLToPath(import.meta.url);
|
|
@@ -362,6 +386,387 @@ export function lintFile(text: string, file: string, claimsText: string): Violat
|
|
|
362
386
|
];
|
|
363
387
|
}
|
|
364
388
|
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// 4. Permanence language in ADR records
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* ADR record roots.
|
|
395
|
+
*
|
|
396
|
+
* Both already sit under `docs`, which {@link SCAN_ROOTS} walks — these roots
|
|
397
|
+
* do NOT widen the walk, they IDENTIFY which of the already-read files is an
|
|
398
|
+
* ADR record. Every other `docs/**` file is untouched by this rule.
|
|
399
|
+
*/
|
|
400
|
+
export const ADR_RECORD_ROOTS: readonly string[] = ['docs/decisions', 'docs/adrs'];
|
|
401
|
+
|
|
402
|
+
/** Ratchet key in `src/config/gate-violation-baselines.json`. */
|
|
403
|
+
export const PERMANENCE_GATE = 'lint_provenance_vocabulary:permanence-language';
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* True for a flat `docs/decisions/ADR-*.md` record or a per-area
|
|
407
|
+
* `docs/adrs/<area>/<n>-*.md` record. A per-area `README.md` is an index, not
|
|
408
|
+
* a record, and is excluded on both surfaces — so are the non-ADR documents
|
|
409
|
+
* that share `docs/decisions/` (sweep artifacts, reclassification logs).
|
|
410
|
+
*/
|
|
411
|
+
export function isAdrRecord(rel: string): boolean {
|
|
412
|
+
const posix = rel.split(path.sep).join('/');
|
|
413
|
+
const base = posix.slice(posix.lastIndexOf('/') + 1);
|
|
414
|
+
if (!base.endsWith('.md') || base === 'README.md') return false;
|
|
415
|
+
if (posix.startsWith('docs/decisions/')) return /^ADR-/.test(base);
|
|
416
|
+
if (posix.startsWith('docs/adrs/')) return /^\d/.test(base);
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The four positions where a permanence claim is LOAD-BEARING — the record is
|
|
422
|
+
* asserting permanence rather than describing, historicising, or rejecting it.
|
|
423
|
+
*
|
|
424
|
+
* - `title` — the `# ` heading; it is the record's name in every citation.
|
|
425
|
+
* - `slug` — the `decision:` frontmatter value; machine-visible, so a
|
|
426
|
+
* permanence claim there is carried by every tool that reads the field.
|
|
427
|
+
* - `decision` — the `## Decision` section's own prose.
|
|
428
|
+
* - `addendum` — an `Addendum` heading at any level. Both corpus instances
|
|
429
|
+
* (ADR-122, ADR-124) assert a permanently-off default, which is the same
|
|
430
|
+
* claim shape as ADR-208's Decision, so they are in scope. `Amendment` is
|
|
431
|
+
* deliberately NOT: an amendment revises or historicises an earlier claim.
|
|
432
|
+
*
|
|
433
|
+
* Everything else is out of scope by construction: `## Alternatives
|
|
434
|
+
* considered` rejects a permanence option, `## Consequences` reports one,
|
|
435
|
+
* `## Context` recounts one, and an `## Open question` quotes one. Scoping is
|
|
436
|
+
* the whole gate — the same word list applied to whole files matches 41 lines
|
|
437
|
+
* across 30 records, and the large majority of them are legitimate.
|
|
438
|
+
*/
|
|
439
|
+
export type PermanenceRegion = 'title' | 'slug' | 'decision' | 'addendum';
|
|
440
|
+
|
|
441
|
+
interface Region {
|
|
442
|
+
kind: PermanenceRegion;
|
|
443
|
+
/** First line index of the region (0-based, inclusive). */
|
|
444
|
+
start: number;
|
|
445
|
+
/** Last line index of the region (0-based, inclusive). */
|
|
446
|
+
end: number;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const FM_DELIM_RE = /^---[ \t]*$/;
|
|
450
|
+
const ANY_HEADING_RE = /^#{1,6}[ \t]+/;
|
|
451
|
+
const H1_RE = /^#[ \t]+/;
|
|
452
|
+
const DECISION_KEY_RE = /^decision:/;
|
|
453
|
+
// `## Decision`, `## Decisions`, `## Decision — GO`, `## Decision (Reading B)`,
|
|
454
|
+
// `### Decision`. Deliberately NOT `### Decision matrix` / `### Decision owner`
|
|
455
|
+
// / `### The owner's decision`: the word has to BE the heading, not open it.
|
|
456
|
+
const DECISION_HEADING_RE = /^#{2,6}[ \t]+Decisions?[ \t]*(?:[—–(:\-]|$)/i;
|
|
457
|
+
const ADDENDUM_HEADING_RE = /^#{2,6}[ \t]+Addendum\b/i;
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Escape 2 — an owner purpose statement, keyed on the SCHEMA field.
|
|
461
|
+
*
|
|
462
|
+
* This originally matched a bare `owner_intent: currently binding` key, which
|
|
463
|
+
* the roadmap's own draft prose named and which no schema declares anywhere in
|
|
464
|
+
* the tree (`grep -rn owner_intent docs/ src/` found it only in that prose). It
|
|
465
|
+
* was usable — nothing rejects an unknown frontmatter key — and that is exactly
|
|
466
|
+
* what made it wrong: a lint reading a field the validator does not know is a
|
|
467
|
+
* second vocabulary for one concept, and the tree would have taught two.
|
|
468
|
+
*
|
|
469
|
+
* `authority_basis: owner_intent` is the declared field
|
|
470
|
+
* (`adr-layout § Provenance and evidence`, validated by
|
|
471
|
+
* `check_adr_frontmatter.check_descriptive_axes`), and it already carries the
|
|
472
|
+
* temporal meaning the phrase was reaching for: the burden table reads it as
|
|
473
|
+
* "binding until the owner changes it". So one field, already validated, rather
|
|
474
|
+
* than a phrase that only this lint could see.
|
|
475
|
+
*/
|
|
476
|
+
const OWNER_INTENT_ESCAPE_RE = /^[ \t]*authority_basis:[ \t]*owner_intent[ \t]*$/m;
|
|
477
|
+
|
|
478
|
+
/** The literal an owner purpose statement writes instead of "forever". */
|
|
479
|
+
export const OWNER_INTENT_ESCAPE = 'authority_basis: owner_intent';
|
|
480
|
+
|
|
481
|
+
/** The YAML frontmatter block, or '' when the record carries none. */
|
|
482
|
+
function _frontmatterText(lines: readonly string[]): string {
|
|
483
|
+
if (lines.length === 0 || !FM_DELIM_RE.test(lines[0] ?? '')) return '';
|
|
484
|
+
for (let i = 1; i < lines.length; i++) {
|
|
485
|
+
if (FM_DELIM_RE.test(lines[i] ?? '')) return lines.slice(1, i).join('\n');
|
|
486
|
+
}
|
|
487
|
+
return '';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Single permanence words. `permanent` is matched only in a **predicative**
|
|
492
|
+
* position (see {@link PROSE_WORD_RES}) because attributively it names a
|
|
493
|
+
* thing, not the decision: "two permanent regression fixtures" (ADR-126) and
|
|
494
|
+
* "permanent migration stubs" (ADR-057) are objects that happen to persist.
|
|
495
|
+
*/
|
|
496
|
+
export const PERMANENCE_WORDS: readonly string[] = ['forever', 'permanently', 'permanent'];
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Multi-word equivalents. The first three are named by ADR-239 § 6;
|
|
500
|
+
* `now or in the future` is the corpus instance that made the list necessary
|
|
501
|
+
* (ADR-108's Decision statement says it instead of "forever"). `in perpetuity`
|
|
502
|
+
* and `for all time` have zero corpus hits today and are listed because they
|
|
503
|
+
* are the standard idioms for the same claim.
|
|
504
|
+
*/
|
|
505
|
+
export const PERMANENCE_PHRASES: readonly string[] = [
|
|
506
|
+
'never revisit',
|
|
507
|
+
'never reconsider',
|
|
508
|
+
'settled forever',
|
|
509
|
+
'now or in the future',
|
|
510
|
+
'in perpetuity',
|
|
511
|
+
'for all time',
|
|
512
|
+
];
|
|
513
|
+
|
|
514
|
+
/** Prose matchers. `forever` / `permanently` reject a trailing hyphen so a
|
|
515
|
+
* compound modifier ("forever-incomplete", "permanently-parked") describing
|
|
516
|
+
* something else is not read as a permanence claim about the decision. */
|
|
517
|
+
const PROSE_WORD_RES: readonly RegExp[] = [
|
|
518
|
+
/(?<![A-Za-z0-9])forever(?![A-Za-z0-9-])/gi,
|
|
519
|
+
/(?<![A-Za-z0-9])permanently(?![A-Za-z0-9-])/gi,
|
|
520
|
+
/(?<![A-Za-z0-9])permanent(?=[ \t]*(?:[.,;:!?)*\]]|$))/gi,
|
|
521
|
+
];
|
|
522
|
+
|
|
523
|
+
const PHRASE_RES: readonly RegExp[] = PERMANENCE_PHRASES.map(
|
|
524
|
+
(p) =>
|
|
525
|
+
new RegExp(
|
|
526
|
+
`(?<![A-Za-z0-9])${p.split(' ').map(_escapeRegExp).join('[ \\t]+')}(?![A-Za-z0-9])`,
|
|
527
|
+
'gi',
|
|
528
|
+
),
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
/** A hyphen-joined slug carries its words without spaces, so the slug region
|
|
532
|
+
* matches on hyphen boundaries too (`dist-agent-src-keep-forever`). */
|
|
533
|
+
const SLUG_RE = /(?<![A-Za-z0-9])(?:forever|permanent(?:ly)?)(?![A-Za-z0-9])/gi;
|
|
534
|
+
|
|
535
|
+
const TABLE_ROW_RE = /^[ \t]*\|/;
|
|
536
|
+
const LIST_ITEM_RE = /^[ \t]*(?:[-*+]|\d+[.)])[ \t]/;
|
|
537
|
+
// A sentence break, tolerating a bold/paren/backtick close after the stop.
|
|
538
|
+
const SENTENCE_SPLIT_RE = /(?<=[.!?][)*"'\]`]{0,3})[ \t]+(?=[A-Z*`[("'])/g;
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Negation and conditional markers that make a permanence word DESCRIPTIVE.
|
|
542
|
+
*
|
|
543
|
+
* Deliberately short. A wider list was tried and rejected against the corpus:
|
|
544
|
+
* `would` kills ADR-107's live claim ("commercial ship **would** require …"
|
|
545
|
+
* sits two sentences from "the suite is open-source forever"), and a bare `no`
|
|
546
|
+
* kills ADR-108's ("There is **no** commercial tier … now or in the future").
|
|
547
|
+
* Both are real violations, so a marker that silences them is worse than the
|
|
548
|
+
* noise it removes.
|
|
549
|
+
*/
|
|
550
|
+
const NEGATION_RE =
|
|
551
|
+
/\b(?:cannot|can't|could not|couldn't|does not|doesn't|do not|don't|did not|is not|isn't|are not|aren't|was not|were not|no longer|rather than|instead of|not a|not an|not the|neither)\b/i;
|
|
552
|
+
const CONDITIONAL_RE = /(?:^|[\s(*_"'`])(?:if|unless)\b/i;
|
|
553
|
+
|
|
554
|
+
/** Escape 1, half one: the permanence is scoped to an EXTERNAL invariant. */
|
|
555
|
+
const EXTERNAL_INVARIANT_RE =
|
|
556
|
+
/\b(?:protocol|api compatibility|wire format|statutory|regulatory|legal obligation|licen[sc]e|licensing|contractual|upstream|external constraint|standards? body|rfc[ \t]*\d+)\b/i;
|
|
557
|
+
/** Escape 1, half two: the condition under which that invariant stops. */
|
|
558
|
+
const STOP_CONDITION_RE =
|
|
559
|
+
/\b(?:stops? applying|no longer applies|ceases? to apply|until|reopen(?:s|ed)?[ \t]+when|revisit(?:ed)?[ \t]+(?:if|when)|is[ \t]+retired|is[ \t]+withdrawn|expires?)\b/i;
|
|
560
|
+
|
|
561
|
+
const PERMANENCE_CONSEQUENCE_BOUND =
|
|
562
|
+
'a record cannot both assert permanence and carry the conditions under which it ' +
|
|
563
|
+
'is reopened (ADR-239 § 6; ADR-208 is the standing demonstration). Two escapes: ' +
|
|
564
|
+
'scope the permanence to an external invariant AND state in the same section when ' +
|
|
565
|
+
`that invariant stops applying, or record an owner purpose statement as \`${OWNER_INTENT_ESCAPE}\``;
|
|
566
|
+
|
|
567
|
+
/** Split a record into its load-bearing regions. */
|
|
568
|
+
export function permanenceRegions(lines: readonly string[]): Region[] {
|
|
569
|
+
const regions: Region[] = [];
|
|
570
|
+
let bodyStart = 0;
|
|
571
|
+
|
|
572
|
+
if (lines.length > 0 && FM_DELIM_RE.test(lines[0] ?? '')) {
|
|
573
|
+
let close = -1;
|
|
574
|
+
for (let i = 1; i < lines.length; i++) {
|
|
575
|
+
if (FM_DELIM_RE.test(lines[i] ?? '')) {
|
|
576
|
+
close = i;
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (close > 0) {
|
|
581
|
+
bodyStart = close + 1;
|
|
582
|
+
for (let i = 1; i < close; i++) {
|
|
583
|
+
if (!DECISION_KEY_RE.test(lines[i] ?? '')) continue;
|
|
584
|
+
// A folded value (`decision: >-`) continues on indented lines.
|
|
585
|
+
let end = i;
|
|
586
|
+
for (let j = i + 1; j < close; j++) {
|
|
587
|
+
if (!/^[ \t]+\S/.test(lines[j] ?? '')) break;
|
|
588
|
+
end = j;
|
|
589
|
+
}
|
|
590
|
+
regions.push({ kind: 'slug', start: i, end });
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
let open: Region | null = null;
|
|
597
|
+
for (let i = bodyStart; i < lines.length; i++) {
|
|
598
|
+
const line = lines[i] ?? '';
|
|
599
|
+
if (!ANY_HEADING_RE.test(line)) continue;
|
|
600
|
+
if (open !== null) {
|
|
601
|
+
open.end = i - 1;
|
|
602
|
+
regions.push(open);
|
|
603
|
+
open = null;
|
|
604
|
+
}
|
|
605
|
+
if (H1_RE.test(line)) {
|
|
606
|
+
regions.push({ kind: 'title', start: i, end: i });
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (DECISION_HEADING_RE.test(line)) {
|
|
610
|
+
open = { kind: 'decision', start: i, end: lines.length - 1 };
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (ADDENDUM_HEADING_RE.test(line)) {
|
|
614
|
+
open = { kind: 'addendum', start: i, end: lines.length - 1 };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (open !== null) regions.push(open);
|
|
618
|
+
return regions;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The sentence containing the hit.
|
|
623
|
+
*
|
|
624
|
+
* Hard-wrapped prose puts a sentence's start on a previous line (ADR-055's
|
|
625
|
+
* "If Step 8 slips," is one line above its "permanent"), so the paragraph is
|
|
626
|
+
* rejoined first. The walk stops at a list-item start in both directions: the
|
|
627
|
+
* corpus writes numbered Decision clauses one item per line, and merging them
|
|
628
|
+
* imports a neighbour's markers into this item's verdict.
|
|
629
|
+
*/
|
|
630
|
+
function _sentenceAround(
|
|
631
|
+
lines: readonly string[],
|
|
632
|
+
region: Region,
|
|
633
|
+
lineIdx: number,
|
|
634
|
+
col: number,
|
|
635
|
+
): string {
|
|
636
|
+
let top = lineIdx;
|
|
637
|
+
while (
|
|
638
|
+
top > region.start &&
|
|
639
|
+
!LIST_ITEM_RE.test(lines[top] ?? '') &&
|
|
640
|
+
(lines[top - 1] ?? '').trim() !== '' &&
|
|
641
|
+
!ANY_HEADING_RE.test(lines[top - 1] ?? '') &&
|
|
642
|
+
!TABLE_ROW_RE.test(lines[top - 1] ?? '')
|
|
643
|
+
) {
|
|
644
|
+
top -= 1;
|
|
645
|
+
}
|
|
646
|
+
let bottom = lineIdx;
|
|
647
|
+
while (
|
|
648
|
+
bottom < region.end &&
|
|
649
|
+
(lines[bottom + 1] ?? '').trim() !== '' &&
|
|
650
|
+
!ANY_HEADING_RE.test(lines[bottom + 1] ?? '') &&
|
|
651
|
+
!LIST_ITEM_RE.test(lines[bottom + 1] ?? '') &&
|
|
652
|
+
!TABLE_ROW_RE.test(lines[bottom + 1] ?? '')
|
|
653
|
+
) {
|
|
654
|
+
bottom += 1;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
let joined = '';
|
|
658
|
+
let hitOffset = 0;
|
|
659
|
+
for (let i = top; i <= bottom; i++) {
|
|
660
|
+
const raw = lines[i] ?? '';
|
|
661
|
+
const sep = joined === '' ? '' : ' ';
|
|
662
|
+
if (i === lineIdx) {
|
|
663
|
+
const lead = raw.length - raw.trimStart().length;
|
|
664
|
+
hitOffset = joined.length + sep.length + Math.max(0, col - lead);
|
|
665
|
+
}
|
|
666
|
+
joined += sep + raw.trim();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const parts: Array<{ text: string; start: number }> = [];
|
|
670
|
+
let cursor = 0;
|
|
671
|
+
let m: RegExpExecArray | null;
|
|
672
|
+
SENTENCE_SPLIT_RE.lastIndex = 0;
|
|
673
|
+
while ((m = SENTENCE_SPLIT_RE.exec(joined)) !== null) {
|
|
674
|
+
parts.push({ text: joined.slice(cursor, m.index), start: cursor });
|
|
675
|
+
cursor = m.index + m[0].length;
|
|
676
|
+
}
|
|
677
|
+
parts.push({ text: joined.slice(cursor), start: cursor });
|
|
678
|
+
for (const p of parts) {
|
|
679
|
+
if (hitOffset >= p.start && hitOffset < p.start + p.text.length) return p.text;
|
|
680
|
+
}
|
|
681
|
+
return joined;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
interface RawHit {
|
|
685
|
+
term: string;
|
|
686
|
+
index: number;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function _hits(line: string, res: readonly RegExp[]): RawHit[] {
|
|
690
|
+
const out: RawHit[] = [];
|
|
691
|
+
for (const re of res) {
|
|
692
|
+
re.lastIndex = 0;
|
|
693
|
+
let m: RegExpExecArray | null;
|
|
694
|
+
while ((m = re.exec(line)) !== null) {
|
|
695
|
+
out.push({ term: m[0], index: m.index });
|
|
696
|
+
if (m.index === re.lastIndex) re.lastIndex += 1;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Rule 4: permanence language in an ADR record's load-bearing positions.
|
|
704
|
+
*
|
|
705
|
+
* Returns [] for a file {@link isAdrRecord} rejects, so a caller may hand it
|
|
706
|
+
* any prose file.
|
|
707
|
+
*/
|
|
708
|
+
export function permanenceViolations(text: string, file: string): Violation[] {
|
|
709
|
+
if (!isAdrRecord(file)) return [];
|
|
710
|
+
|
|
711
|
+
const rawLines = text.split('\n');
|
|
712
|
+
// Fence-stripped, index-preserving: a `## Decision` inside a code fence is
|
|
713
|
+
// an example, not a section, and must not open a region.
|
|
714
|
+
const prose = _stripFences(rawLines);
|
|
715
|
+
const regions = permanenceRegions(prose);
|
|
716
|
+
const ownerIntentEscape = OWNER_INTENT_ESCAPE_RE.test(_frontmatterText(rawLines));
|
|
717
|
+
|
|
718
|
+
const violations: Violation[] = [];
|
|
719
|
+
for (const region of regions) {
|
|
720
|
+
// The slug is machine-visible in every citation and hyphen-joined, so
|
|
721
|
+
// it takes the unconditional match and neither escape: a field cannot
|
|
722
|
+
// qualify a name that tools print without reading the field.
|
|
723
|
+
if (region.kind === 'slug') {
|
|
724
|
+
for (let i = region.start; i <= region.end; i++) {
|
|
725
|
+
for (const hit of _hits(prose[i] ?? '', [SLUG_RE])) {
|
|
726
|
+
violations.push({
|
|
727
|
+
file,
|
|
728
|
+
rule: 'permanence-language',
|
|
729
|
+
msg:
|
|
730
|
+
`line ${i + 1}: permanence language "${hit.term}" in the \`decision:\` slug — ` +
|
|
731
|
+
`${PERMANENCE_CONSEQUENCE_BOUND} (the slug takes neither escape)`,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const regionText = prose.slice(region.start, region.end + 1).join('\n');
|
|
739
|
+
const hasStopCondition = STOP_CONDITION_RE.test(regionText);
|
|
740
|
+
|
|
741
|
+
for (let i = region.start; i <= region.end; i++) {
|
|
742
|
+
const line = prose[i] ?? '';
|
|
743
|
+
const isProseRegion = region.kind === 'decision' || region.kind === 'addendum';
|
|
744
|
+
// A table cell is a per-row detail with its own rationale column,
|
|
745
|
+
// not the decision statement (ADR-074's "probing forever" is a
|
|
746
|
+
// failure mode being bounded, in the rationale column).
|
|
747
|
+
if (isProseRegion && TABLE_ROW_RE.test(line)) continue;
|
|
748
|
+
|
|
749
|
+
for (const hit of _hits(line, [...PROSE_WORD_RES, ...PHRASE_RES])) {
|
|
750
|
+
if (_isQuotedSpan(line, hit.index)) continue;
|
|
751
|
+
if (isProseRegion) {
|
|
752
|
+
if (ownerIntentEscape) continue;
|
|
753
|
+
const sentence = _sentenceAround(prose, region, i, hit.index);
|
|
754
|
+
if (NEGATION_RE.test(sentence) || CONDITIONAL_RE.test(sentence)) continue;
|
|
755
|
+
if (hasStopCondition && EXTERNAL_INVARIANT_RE.test(sentence)) continue;
|
|
756
|
+
}
|
|
757
|
+
violations.push({
|
|
758
|
+
file,
|
|
759
|
+
rule: 'permanence-language',
|
|
760
|
+
msg:
|
|
761
|
+
`line ${i + 1}: permanence language "${hit.term}" in the record's ${region.kind} — ` +
|
|
762
|
+
`${PERMANENCE_CONSEQUENCE_BOUND} (context: ${line.trim().slice(0, 90)})`,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return violations;
|
|
768
|
+
}
|
|
769
|
+
|
|
365
770
|
// ---------------------------------------------------------------------------
|
|
366
771
|
// Filesystem walk + CLI
|
|
367
772
|
// ---------------------------------------------------------------------------
|
|
@@ -404,6 +809,42 @@ export function lintProvenanceVocabulary(repoRoot: string = REPO_ROOT): Violatio
|
|
|
404
809
|
return violations;
|
|
405
810
|
}
|
|
406
811
|
|
|
812
|
+
/**
|
|
813
|
+
* Rule 4 over the ADR corpus.
|
|
814
|
+
*
|
|
815
|
+
* Separate from {@link lintProvenanceVocabulary} because the two carry
|
|
816
|
+
* different verdicts: rules 1-3 hard-fail, rule 4 is ratcheted against
|
|
817
|
+
* `${PERMANENCE_GATE}` in `src/config/gate-violation-baselines.json` because
|
|
818
|
+
* the corpus contains real violations today (ADR-107, ADR-108, ADR-122,
|
|
819
|
+
* ADR-124, ADR-208) and a gate that lands as five instant blockers is a shape
|
|
820
|
+
* this repository has refused before. A NEW record still hard-fails: the
|
|
821
|
+
* ratchet only turns one way.
|
|
822
|
+
*/
|
|
823
|
+
export function lintAdrPermanence(repoRoot: string = REPO_ROOT): Violation[] {
|
|
824
|
+
const records: string[] = [];
|
|
825
|
+
for (const root of ADR_RECORD_ROOTS) {
|
|
826
|
+
for (const abs of _collectMd(path.join(repoRoot, root))) {
|
|
827
|
+
const rel = path.relative(repoRoot, abs);
|
|
828
|
+
if (isAdrRecord(rel)) records.push(abs);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// A `## Decision`-scoped rule that read zero ADR records would print a
|
|
832
|
+
// green line while enforcing nothing — the exact failure the shared
|
|
833
|
+
// scope-assert exists for.
|
|
834
|
+
assertScanned({
|
|
835
|
+
gate: PERMANENCE_GATE,
|
|
836
|
+
scanned: records.length,
|
|
837
|
+
units: 'ADR record(s)',
|
|
838
|
+
roots: ADR_RECORD_ROOTS,
|
|
839
|
+
});
|
|
840
|
+
const violations: Violation[] = [];
|
|
841
|
+
for (const abs of records.sort()) {
|
|
842
|
+
const rel = path.relative(repoRoot, abs);
|
|
843
|
+
violations.push(...permanenceViolations(fs.readFileSync(abs, 'utf-8'), rel));
|
|
844
|
+
}
|
|
845
|
+
return violations;
|
|
846
|
+
}
|
|
847
|
+
|
|
407
848
|
function _print(quiet: boolean, msg: string): void {
|
|
408
849
|
if (!quiet) process.stdout.write(`${msg}\n`);
|
|
409
850
|
}
|
|
@@ -411,8 +852,10 @@ function _print(quiet: boolean, msg: string): void {
|
|
|
411
852
|
export function main(): number {
|
|
412
853
|
const quiet = process.argv.slice(2).includes('--quiet');
|
|
413
854
|
let violations: Violation[];
|
|
855
|
+
let permanence: Violation[];
|
|
414
856
|
try {
|
|
415
857
|
violations = lintProvenanceVocabulary();
|
|
858
|
+
permanence = lintAdrPermanence();
|
|
416
859
|
} catch (e) {
|
|
417
860
|
// 1 is the only failure code this gate defines, so a dead scan root and
|
|
418
861
|
// a real violation share it — the message distinguishes them.
|
|
@@ -422,14 +865,39 @@ export function main(): number {
|
|
|
422
865
|
}
|
|
423
866
|
throw e;
|
|
424
867
|
}
|
|
868
|
+
|
|
869
|
+
let failed = false;
|
|
425
870
|
if (violations.length > 0) {
|
|
426
871
|
for (const v of violations) {
|
|
427
872
|
process.stderr.write(`❌ [${v.rule}] ${v.file}: ${v.msg}\n`);
|
|
428
873
|
}
|
|
429
874
|
process.stderr.write(`\n❌ provenance-vocabulary — ${violations.length} violation(s)\n`);
|
|
430
|
-
|
|
875
|
+
failed = true;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Rule 4 reports every finding either way: at baseline they are the known
|
|
879
|
+
// debt this ratchet is walking down, and printing them is what makes the
|
|
880
|
+
// next lowering possible.
|
|
881
|
+
const verdict = checkRatchet({
|
|
882
|
+
gate: PERMANENCE_GATE,
|
|
883
|
+
actual: permanence.length,
|
|
884
|
+
repoRoot: REPO_ROOT,
|
|
885
|
+
});
|
|
886
|
+
if (!verdict.ok) {
|
|
887
|
+
for (const v of permanence) {
|
|
888
|
+
process.stderr.write(`❌ [${v.rule}] ${v.file}: ${v.msg}\n`);
|
|
889
|
+
}
|
|
890
|
+
process.stderr.write(`\n❌ ${verdict.message}\n`);
|
|
891
|
+
failed = true;
|
|
892
|
+
} else if (!quiet) {
|
|
893
|
+
for (const v of permanence) {
|
|
894
|
+
process.stdout.write(` [${v.rule}] ${v.file}: ${v.msg}\n`);
|
|
895
|
+
}
|
|
896
|
+
_print(quiet, `✅ ${verdict.message}`);
|
|
431
897
|
}
|
|
432
|
-
|
|
898
|
+
|
|
899
|
+
if (failed) return 1;
|
|
900
|
+
_print(quiet, '✅ provenance-vocabulary — no banned phrases, every approved-term use is co-located with a valid scope box, every box figure matches docs/CLAIMS.md, no new permanence language in an ADR record');
|
|
433
901
|
return 0;
|
|
434
902
|
}
|
|
435
903
|
|
|
@@ -276,7 +276,11 @@ export function render_standing_delivery(m: StandingDeliveryMeasure | null): str
|
|
|
276
276
|
lines.push(
|
|
277
277
|
`⚠️ Standing delivery: both rule layers active — ${m.overlap_rules} rule(s) delivered twice ` +
|
|
278
278
|
`(~${m.overlap_tokens} tok). This session's host receives ${m.received_tokens} rule tokens; ` +
|
|
279
|
-
`
|
|
279
|
+
`single-layer delivery would be ~${scoped} — run \`agent-config install\` to arm the ` +
|
|
280
|
+
`ADR-236 partition on this machine (it fingerprints the host layer; the next ` +
|
|
281
|
+
`\`task generate-tools\` then withholds the globally-owned artefacts). The former advice ` +
|
|
282
|
+
`here, \`install --layer=<global|project>\`, was layer suppression — declined by ADR-226 ` +
|
|
283
|
+
`and superseded by the partition.`,
|
|
280
284
|
);
|
|
281
285
|
if (m.divergent_rules > 0) {
|
|
282
286
|
// Suppressing a layer whose copies differ drops whichever obligations only
|