session-orchestrator 4.2.0 → 5.0.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/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +94 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/commands/ux-grill.md +51 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/codex-setup.md +8 -0
- package/docs/components.md +7 -7
- package/docs/events-schema.md +9 -5
- package/docs/github-mirror-protection.md +61 -20
- package/docs/migration-v5.md +62 -0
- package/docs/scope-collision-guard.md +14 -0
- package/hooks/_lib/hook-import-set.json +44 -2
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +190 -3
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +51 -4
- package/package.json +2 -1
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +114 -13
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget.mjs +336 -6
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +11 -1
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +14 -65
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-start-probes.mjs +419 -53
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +48 -20
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/mcp-server.sh +16 -1
- package/scripts/release.mjs +7 -2
- package/skills/bootstrap/SKILL.md +12 -209
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +7 -3
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +10 -0
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +4 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-plan/SKILL.md +2 -144
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-start/SKILL.md +24 -6
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +3 -128
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +18 -5
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/_shared/journey-manifest.md +0 -114
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* Responsibilities:
|
|
12
12
|
* - Acquire a per-write file lock (`.orchestrator/rules.lock`) to serialise
|
|
13
13
|
* concurrent writers — mirrors PSA-005 (withStateMdLock) pattern.
|
|
14
|
+
* - Before any write: project the instruction budget after the batch and
|
|
15
|
+
* refuse the WHOLE batch (nothing written, nothing stamped) when it would
|
|
16
|
+
* breach a ceiling — see `budgetPreflight`.
|
|
14
17
|
* - For each approved proposal: path-safety guard → STRUCTURAL content gate
|
|
15
18
|
* (#1015, see {@link frontmatterRefusalReason}) → mkdirSync → atomic
|
|
16
19
|
* tmp+rename write → stamp the idempotency sidecar terminal via
|
|
@@ -54,8 +57,20 @@
|
|
|
54
57
|
* @module reconcile/writer
|
|
55
58
|
*/
|
|
56
59
|
|
|
57
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
mkdirSync,
|
|
62
|
+
mkdtempSync,
|
|
63
|
+
writeFileSync,
|
|
64
|
+
readFileSync,
|
|
65
|
+
readdirSync,
|
|
66
|
+
renameSync,
|
|
67
|
+
appendFileSync,
|
|
68
|
+
realpathSync,
|
|
69
|
+
rmSync,
|
|
70
|
+
statSync,
|
|
71
|
+
} from 'node:fs';
|
|
58
72
|
import { randomBytes } from 'node:crypto';
|
|
73
|
+
import { tmpdir } from 'node:os';
|
|
59
74
|
import path from 'node:path';
|
|
60
75
|
|
|
61
76
|
import { withFileLock } from '../file-lock.mjs';
|
|
@@ -490,6 +505,228 @@ function isOperatorRejection(item) {
|
|
|
490
505
|
return typeof item.content === 'string' && item.content.length > 0;
|
|
491
506
|
}
|
|
492
507
|
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// Instruction-budget pre-flight (#1316 follow-up)
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
/** Refusal reason returned when the projected corpus would breach a ceiling. */
|
|
513
|
+
export const BUDGET_REFUSAL_REASON = 'instruction-budget-exceeded';
|
|
514
|
+
|
|
515
|
+
const BUDGET_HINT =
|
|
516
|
+
'absorb the approved rule(s) into an existing thematic file under .claude/rules/ ' +
|
|
517
|
+
'(N provenance pairs in ONE file, globs:/paths: mirrored — docs/rule-authoring.md § Consolidated rules) ' +
|
|
518
|
+
'and re-run; never raise the ceiling';
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* @typedef {Object} BudgetRefusal
|
|
522
|
+
* @property {string} reason - always {@link BUDGET_REFUSAL_REASON}.
|
|
523
|
+
* @property {'path-scoped-bytes'|'generated-bytes'|'always-on-bytes'|'directives'} axis
|
|
524
|
+
* @property {number} current - the axis value before the write.
|
|
525
|
+
* @property {number} projected - the axis value after the write.
|
|
526
|
+
* @property {number} ceiling - the ceiling the projection breaches.
|
|
527
|
+
* @property {string} hint
|
|
528
|
+
*/
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Project the instruction budget AFTER the pending repo-local writes and refuse
|
|
532
|
+
* the whole batch if the projection would breach a ceiling.
|
|
533
|
+
*
|
|
534
|
+
* WHY A PROJECTION AND NOT WRITE-THEN-ROLLBACK: a rollback has to undo rule
|
|
535
|
+
* files AND sidecar stamps, and a half-failed rollback is worse than the breach.
|
|
536
|
+
* Refusing before the first byte lands keeps the failure clean.
|
|
537
|
+
*
|
|
538
|
+
* HOW: the guard's own `computeInstructionBudget` runs twice — on the live rules
|
|
539
|
+
* directory, and on a throwaway overlay (a copy of that directory with the
|
|
540
|
+
* pending contents written over it). Re-running the guard's code rather than
|
|
541
|
+
* re-deriving its byte count means the projection cannot disagree with the
|
|
542
|
+
* verdict: same frontmatter stripping, same `globs:`/provenance predicates,
|
|
543
|
+
* all four axes, and an update of an existing file lands as its delta by
|
|
544
|
+
* construction. Ceilings follow `checkInstructionBudget`'s precedence
|
|
545
|
+
* (Session Config `instruction-budget.*` > module default), and
|
|
546
|
+
* `enabled: false` / `mode: off` skips the pre-flight — the operator opted out
|
|
547
|
+
* of the guard.
|
|
548
|
+
*
|
|
549
|
+
* REFUSE ONLY WHAT THIS WRITE WORSENS: an axis blocks the batch only when it is
|
|
550
|
+
* over in the projection AND the write grows it. A corpus that is already over
|
|
551
|
+
* therefore still accepts a shrinking consolidation — the remedy must never be
|
|
552
|
+
* blocked by the breach it repairs — and an unrelated pre-existing breach (say,
|
|
553
|
+
* the hand-authored always-on directive count) does not freeze reconcile.
|
|
554
|
+
*
|
|
555
|
+
* CEILING (BV-004): this is a projection of THIS batch against the corpus as it
|
|
556
|
+
* stands inside the rules lock. It does not cover rule edits made by hand or by
|
|
557
|
+
* any writer that does not take `.orchestrator/rules.lock`, and it projects
|
|
558
|
+
* with the Session Config ceilings while `tests/rules/receiving-review.test.mjs`
|
|
559
|
+
* asserts against the module defaults — so a repo that RAISES its ceiling in
|
|
560
|
+
* config can pass here and still go red there; that live test stays the second
|
|
561
|
+
* safety net. It also fails OPEN (one `errors[]` warning, then the write
|
|
562
|
+
* proceeds) when the guard cannot load or the overlay cannot be built,
|
|
563
|
+
* because blocking every reconcile write on a broken measurement is the larger
|
|
564
|
+
* harm. Revisit if a second writer of `.claude/rules/` appears, or if a repo
|
|
565
|
+
* ever carries a config ceiling above the module default.
|
|
566
|
+
*
|
|
567
|
+
* @param {Object} ctx
|
|
568
|
+
* @param {WriterApprovedItem[]} ctx.approvedItems
|
|
569
|
+
* @param {PreparedTarget|undefined} ctx.prep - the prepared `repo-local` target.
|
|
570
|
+
* @param {{repoRoot?: string, baselineRoot?: string}} ctx.roots
|
|
571
|
+
* @param {string} ctx.repoRoot
|
|
572
|
+
* @param {string[]} ctx.errors - receives fail-open warnings.
|
|
573
|
+
* @returns {Promise<BudgetRefusal|null>} null ⇒ proceed with the writes.
|
|
574
|
+
*/
|
|
575
|
+
async function budgetPreflight({ approvedItems, prep, roots, repoRoot, errors }) {
|
|
576
|
+
if (!prep || !prep.ok) return null;
|
|
577
|
+
|
|
578
|
+
// Only what the write loop below would actually put on disk counts: the same
|
|
579
|
+
// content-type check, structural gate and destination resolution. Errors from
|
|
580
|
+
// this dry resolution go to a throwaway sink — the write loop reports them.
|
|
581
|
+
/** @type {Array<{rel: string, content: string}>} */
|
|
582
|
+
const pending = [];
|
|
583
|
+
for (const item of approvedItems) {
|
|
584
|
+
if (!item || typeof item.content !== 'string') continue;
|
|
585
|
+
if (frontmatterRefusalReason(item.content) !== null) continue;
|
|
586
|
+
const dest = resolveDest(item, 'repo-local', prep, roots, []);
|
|
587
|
+
if (dest === null) continue;
|
|
588
|
+
pending.push({ rel: path.relative(prep.dir, dest), content: item.content });
|
|
589
|
+
}
|
|
590
|
+
if (pending.length === 0) return null;
|
|
591
|
+
|
|
592
|
+
let overlay = null;
|
|
593
|
+
try {
|
|
594
|
+
// Dynamic import keeps the guard out of this module's static graph.
|
|
595
|
+
const guard = await import('../instruction-budget-guard.mjs');
|
|
596
|
+
const cfg = guard.loadInstructionBudgetConfig(repoRoot);
|
|
597
|
+
if (!cfg.enabled || cfg.mode === 'off') return null;
|
|
598
|
+
// computeInstructionBudget falls back to its module default for any
|
|
599
|
+
// non-number, so an absent optional key needs no special case here.
|
|
600
|
+
const ceilings = {
|
|
601
|
+
ceiling: cfg.ceiling,
|
|
602
|
+
byteCeiling: cfg['byte-ceiling'],
|
|
603
|
+
generatedByteCeiling: cfg['generated-byte-ceiling'],
|
|
604
|
+
pathScopedByteCeiling: cfg['path-scoped-byte-ceiling'],
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const current = guard.computeInstructionBudget({ repoRoot, rulesDir: prep.dir, ...ceilings });
|
|
608
|
+
|
|
609
|
+
overlay = mkdtempSync(path.join(tmpdir(), 'reconcile-budget-'));
|
|
610
|
+
// The guard reads the rules directory flat (`readdirSync`, `.md` only).
|
|
611
|
+
for (const name of readdirSync(prep.dir)) {
|
|
612
|
+
if (!name.endsWith('.md')) continue;
|
|
613
|
+
try {
|
|
614
|
+
writeFileSync(path.join(overlay, name), readFileSync(path.join(prep.dir, name), 'utf8'), 'utf8');
|
|
615
|
+
} catch {
|
|
616
|
+
/* unreadable or a directory — the guard skips it too */
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
for (const { rel, content } of pending) {
|
|
620
|
+
const dest = path.join(overlay, rel);
|
|
621
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
622
|
+
writeFileSync(dest, content, 'utf8');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const projected = guard.computeInstructionBudget({ repoRoot, rulesDir: overlay, ...ceilings });
|
|
626
|
+
|
|
627
|
+
const axes = [
|
|
628
|
+
['path-scoped-bytes', current.bySurface.pathScoped.bytes, projected.bySurface.pathScoped.bytes, projected.pathScopedByteCeiling, projected.overPathScopedBudget],
|
|
629
|
+
['generated-bytes', current.bySurface.generated.bytes, projected.bySurface.generated.bytes, projected.generatedByteCeiling, projected.overGeneratedBudget],
|
|
630
|
+
['always-on-bytes', current.totalBytes, projected.totalBytes, projected.byteCeiling, projected.overByteBudget],
|
|
631
|
+
['directives', current.totalDirectives, projected.totalDirectives, projected.ceiling, projected.overDirectiveBudget],
|
|
632
|
+
];
|
|
633
|
+
for (const [axis, cur, proj, ceiling, over] of axes) {
|
|
634
|
+
if (over && proj > cur) {
|
|
635
|
+
return { reason: BUDGET_REFUSAL_REASON, axis, current: cur, projected: proj, ceiling, hint: BUDGET_HINT };
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return null;
|
|
639
|
+
} catch (err) {
|
|
640
|
+
const msg = err && err.message ? err.message : String(err);
|
|
641
|
+
errors.push(`instruction-budget pre-flight could not run (${msg}) — writes proceed unchecked (fail-open)`);
|
|
642
|
+
return null;
|
|
643
|
+
} finally {
|
|
644
|
+
if (overlay !== null) {
|
|
645
|
+
try {
|
|
646
|
+
rmSync(overlay, { recursive: true, force: true });
|
|
647
|
+
} catch {
|
|
648
|
+
/* a leftover tmp dir is harmless */
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
// Telemetry (#1307)
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Ledger name of the rule-write event (issue #1307).
|
|
660
|
+
*
|
|
661
|
+
* WHY A SECOND EVENT AND NOT A FIELD ON `orchestrator.reconcile.completed`:
|
|
662
|
+
* that event is emitted by the `runReconcile` wrapper — BEFORE the operator
|
|
663
|
+
* approval AUQ and before this module runs at all. A `dry_run: false` record
|
|
664
|
+
* therefore proves only that the engine ran and merged the candidate store; an
|
|
665
|
+
* operator who declined every proposal emits a byte-identical record to one who
|
|
666
|
+
* approved five. The emitter has to sit where the thing it proves happens, and
|
|
667
|
+
* that is here: after the write pass, in the one module that writes rule files.
|
|
668
|
+
*
|
|
669
|
+
* Emitting is NOT writing — the #693 FA2/FA3 brandmauer is untouched. This
|
|
670
|
+
* module remains the only writer of `.claude/rules/`, and every write it makes
|
|
671
|
+
* is still an operator-approved item handed in via `approved`.
|
|
672
|
+
*/
|
|
673
|
+
export const RULES_WRITTEN_EVENT = 'orchestrator.reconcile.rules_written';
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Record ONE rule-write pass in the repo's event ledger — best-effort.
|
|
677
|
+
*
|
|
678
|
+
* ZERO-WRITE IS EMITTED, DELIBERATELY. The alternative (emit only on a
|
|
679
|
+
* successful write) makes the maintenance loop unable to separate three
|
|
680
|
+
* materially different outcomes that would all produce NO record: the operator
|
|
681
|
+
* declined every proposal, every write was REFUSED by a guard (path-safety,
|
|
682
|
+
* the #1015 content gate, a missing baseline root), and the writer was never
|
|
683
|
+
* reached at all. `.claude/rules/host-resources.md` § HR-105 is explicit that a
|
|
684
|
+
* rule whose firing you cannot falsify is not a rule, and the sibling event's
|
|
685
|
+
* own docs row states the same convention in one line: *"`dry_run` is the
|
|
686
|
+
* discriminator, not the event's absence"*. So the discriminator here is a
|
|
687
|
+
* FIELD — `rules_written` against `approved_proposals` — never the absence of a
|
|
688
|
+
* record. The one case that emits nothing is the caller's true no-op (neither
|
|
689
|
+
* an approved nor a rejected item), which returns before the lock is taken.
|
|
690
|
+
*
|
|
691
|
+
* NEVER THROWS, and never propagates: `emitEvent` throws `EventValidationError`
|
|
692
|
+
* on a malformed record, and `writeApprovedRules`'s never-throws contract is
|
|
693
|
+
* older and more load-bearing than this telemetry. A failed emit degrades to a
|
|
694
|
+
* stderr WARN and the write result is returned unchanged.
|
|
695
|
+
*
|
|
696
|
+
* @param {WriteApprovedRulesResult} result
|
|
697
|
+
* @param {{repoRoot?: string, targets: string[], approvedCount: number}} ctx
|
|
698
|
+
* @returns {Promise<void>}
|
|
699
|
+
*/
|
|
700
|
+
async function emitRulesWritten(result, ctx) {
|
|
701
|
+
const { repoRoot } = ctx;
|
|
702
|
+
// Same refusal as `emitReconcileCompleted` (#1119): with no repoRoot the
|
|
703
|
+
// ambient `SO_PROJECT_DIR` would receive synthetic records on every test run.
|
|
704
|
+
if (typeof repoRoot !== 'string' || repoRoot.trim() === '') return;
|
|
705
|
+
|
|
706
|
+
/** @type {Record<string, unknown>} */
|
|
707
|
+
const payload = {
|
|
708
|
+
rules_written: result.written,
|
|
709
|
+
approved_proposals: ctx.approvedCount,
|
|
710
|
+
rejected_archived: result.archived,
|
|
711
|
+
write_errors: result.errors.length,
|
|
712
|
+
};
|
|
713
|
+
// Allowlisted to the CLOSED {@link TARGET_DIRS} key set before entering the
|
|
714
|
+
// ledger — `targets` originates in operator-authored Session Config and is
|
|
715
|
+
// unbounded there, so an unknown value is untrusted text, never a
|
|
716
|
+
// measurement. Same treatment as `buildReconcilePayload`'s `targets`.
|
|
717
|
+
const known = Object.keys(TARGET_DIRS);
|
|
718
|
+
const targets = ctx.targets.filter((t) => known.includes(t));
|
|
719
|
+
if (targets.length > 0) payload.targets = targets;
|
|
720
|
+
|
|
721
|
+
try {
|
|
722
|
+
const { emitEvent } = await import('../events.mjs');
|
|
723
|
+
await emitEvent(RULES_WRITTEN_EVENT, payload, { repoRoot });
|
|
724
|
+
} catch (err) {
|
|
725
|
+
const msg = err && err.message ? err.message : String(err);
|
|
726
|
+
process.stderr.write(`reconcile-writer: ${RULES_WRITTEN_EVENT} emit failed (non-fatal): ${msg}\n`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
493
730
|
// ---------------------------------------------------------------------------
|
|
494
731
|
// Public API
|
|
495
732
|
// ---------------------------------------------------------------------------
|
|
@@ -532,6 +769,12 @@ function isOperatorRejection(item) {
|
|
|
532
769
|
* @property {number} written - number of rule files successfully written.
|
|
533
770
|
* @property {number} archived - number of rejected records appended to the log.
|
|
534
771
|
* @property {string[]} errors - per-item error strings (never fatal).
|
|
772
|
+
*
|
|
773
|
+
* On an instruction-budget refusal (see `budgetPreflight`) the same object also
|
|
774
|
+
* carries `ok: false` plus every {@link BudgetRefusal} field, `written` and
|
|
775
|
+
* `archived` are 0, and `errors[]` holds one line naming the axis and the hint —
|
|
776
|
+
* so a caller that only surfaces `errors[]` still shows the refusal. The success
|
|
777
|
+
* shape carries no `ok` key.
|
|
535
778
|
*/
|
|
536
779
|
|
|
537
780
|
/**
|
|
@@ -618,6 +861,24 @@ export async function writeApprovedRules({
|
|
|
618
861
|
}
|
|
619
862
|
}
|
|
620
863
|
|
|
864
|
+
// Instruction-budget pre-flight — before the first write, inside the
|
|
865
|
+
// lock so no concurrent reconcile writer moves the corpus between the
|
|
866
|
+
// projection and the write. A refusal writes NOTHING: no rule file, no
|
|
867
|
+
// rejected-log line, no sidecar stamp (approved or rejected).
|
|
868
|
+
const budgetRefusal = await budgetPreflight({
|
|
869
|
+
approvedItems,
|
|
870
|
+
prep: prepared.get('repo-local'),
|
|
871
|
+
roots,
|
|
872
|
+
repoRoot,
|
|
873
|
+
errors,
|
|
874
|
+
});
|
|
875
|
+
if (budgetRefusal !== null) {
|
|
876
|
+
errors.push(
|
|
877
|
+
`${budgetRefusal.reason}: ${budgetRefusal.axis} would go ${budgetRefusal.current} → ${budgetRefusal.projected} (ceiling ${budgetRefusal.ceiling}) — nothing written, no candidate marked processed; ${budgetRefusal.hint}`,
|
|
878
|
+
);
|
|
879
|
+
return { written: 0, archived: 0, errors, ok: false, ...budgetRefusal };
|
|
880
|
+
}
|
|
881
|
+
|
|
621
882
|
// ── Step 1: write approved rule files ──────────────────────────────────
|
|
622
883
|
for (const item of approvedItems) {
|
|
623
884
|
// Item-level guards run ONCE, before any target loop: content is a
|
|
@@ -765,15 +1026,21 @@ export async function writeApprovedRules({
|
|
|
765
1026
|
{ timeoutMs: 10000 },
|
|
766
1027
|
);
|
|
767
1028
|
|
|
768
|
-
//
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1029
|
+
// One emit for BOTH return points (#1307). A lock-acquisition failure is a
|
|
1030
|
+
// zero-write pass like any other and is recorded as one — its `write_errors: 1`
|
|
1031
|
+
// plus a non-zero `approved_proposals` is exactly the shape an operator needs
|
|
1032
|
+
// to see, and an emit placed on the success path alone would drop it silently.
|
|
1033
|
+
const result =
|
|
1034
|
+
lockResult.ok === false
|
|
1035
|
+
? { written: 0, archived: 0, errors: [`lock-${lockResult.reason ?? 'unknown'}`] }
|
|
1036
|
+
: // Unwrap the result returned from inside the lock body.
|
|
1037
|
+
lockResult.value;
|
|
1038
|
+
|
|
1039
|
+
await emitRulesWritten(result, {
|
|
1040
|
+
repoRoot,
|
|
1041
|
+
targets: effectiveTargets,
|
|
1042
|
+
approvedCount: approvedItems.length,
|
|
1043
|
+
});
|
|
776
1044
|
|
|
777
|
-
|
|
778
|
-
return lockResult.value;
|
|
1045
|
+
return result;
|
|
779
1046
|
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scope-echo.mjs — the RECEIVE-side half of the FILE-SCOPE observability chain (#1092).
|
|
3
|
+
*
|
|
4
|
+
* `hooks/pre-task-scope-disjoint.mjs` observes the SEND side: it emits
|
|
5
|
+
* `orchestrator.wave_dispatch.scope_checked` describing what the guard saw in the
|
|
6
|
+
* prompt the coordinator handed to the dispatch tool. Nothing in this repo can
|
|
7
|
+
* observe the other half — whether the `FILE-SCOPE` block reached the agent's
|
|
8
|
+
* assembled context — because no platform boundary exposes the final prompt
|
|
9
|
+
* (`docs/scope-collision-guard.md` § 4.2).
|
|
10
|
+
*
|
|
11
|
+
* What IS feasible is a self-reported echo: the coordinator appends one
|
|
12
|
+
* instruction line naming the expected digest of the agent's own scope file, and
|
|
13
|
+
* the agent ends its report with `SCOPE-DIGEST: <8-hex>`. Post-wave, the
|
|
14
|
+
* coordinator compares the two and emits
|
|
15
|
+
* `orchestrator.wave_dispatch.scope_echo_checked`.
|
|
16
|
+
*
|
|
17
|
+
* CEILING (BV-004, named deliberately): this is a SOFT signal. The digest is
|
|
18
|
+
* handed to the agent in the prompt, so an agent that never read the scope block
|
|
19
|
+
* can still copy the line — it proves the report carried the digest the
|
|
20
|
+
* coordinator handed it, never that the model read or obeyed the scope. Revisit
|
|
21
|
+
* when the platform exposes a stable prompt-assembly boundary; at that point the
|
|
22
|
+
* digest can be computed against the real assembled prompt instead of echoed.
|
|
23
|
+
*
|
|
24
|
+
* Pure + stdlib only. Nothing here throws on malformed input — a broken echo
|
|
25
|
+
* check must never change a wave's outcome.
|
|
26
|
+
*
|
|
27
|
+
* Exports:
|
|
28
|
+
* scopeDigest(paths) → 8-hex string (deterministic, never throws)
|
|
29
|
+
* renderScopeEchoInstruction(paths) → the ONE prompt line to append
|
|
30
|
+
* extractScopeEcho(reportText) → { echoed, digest }
|
|
31
|
+
* checkScopeEcho({ scopeFilePath, reportText }) → { echoed, match, expected, actual, reason? }
|
|
32
|
+
*
|
|
33
|
+
* CLI:
|
|
34
|
+
* node scripts/lib/scope-echo.mjs --scope-file <path> --instruction
|
|
35
|
+
* node scripts/lib/scope-echo.mjs --scope-file <path> --report-file <path> \
|
|
36
|
+
* [--wave N --agent-id ID --emit]
|
|
37
|
+
* node scripts/lib/scope-echo.mjs --help
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { readFileSync } from 'node:fs';
|
|
41
|
+
import { resolve } from 'node:path';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
|
|
44
|
+
import { digestSha256Short } from './crypto-digest-utils.mjs';
|
|
45
|
+
|
|
46
|
+
/** Marker the agent must emit. Case-sensitive by design — a lowercase lookalike is not an echo. */
|
|
47
|
+
export const SCOPE_ECHO_MARKER = 'SCOPE-DIGEST:';
|
|
48
|
+
|
|
49
|
+
/** Event name for the post-wave verdict. */
|
|
50
|
+
export const SCOPE_ECHO_EVENT = 'orchestrator.wave_dispatch.scope_echo_checked';
|
|
51
|
+
|
|
52
|
+
/** Max characters retained for `agent_id` in the payload (same clamp as the send-side hook). */
|
|
53
|
+
const AGENT_ID_MAX = 120;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The LAST `SCOPE-DIGEST: <8 hex>` occurrence wins. Optional surrounding backticks
|
|
57
|
+
* (agents like to fence the line) and trailing whitespace are tolerated. The
|
|
58
|
+
* negative lookahead is load-bearing: without it `SCOPE-DIGEST: 123456789` would
|
|
59
|
+
* match its first eight characters and report a false echo.
|
|
60
|
+
*/
|
|
61
|
+
const ECHO_RE = /SCOPE-DIGEST:[ \t]*`{0,3}([0-9a-fA-F]{8})(?![0-9a-fA-F])/g;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalize a declared scope into the canonical digest input: trimmed, empties
|
|
65
|
+
* dropped, deduplicated, sorted, joined with `\n`. Order and incidental
|
|
66
|
+
* whitespace therefore never change the digest — two coordinators that wrote the
|
|
67
|
+
* same set of paths in a different order agree.
|
|
68
|
+
*
|
|
69
|
+
* @param {unknown} paths
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
function normalizeScopePaths(paths) {
|
|
73
|
+
if (!Array.isArray(paths)) return [];
|
|
74
|
+
const out = new Set();
|
|
75
|
+
for (const entry of paths) {
|
|
76
|
+
if (typeof entry !== 'string') continue;
|
|
77
|
+
const trimmed = entry.trim();
|
|
78
|
+
if (trimmed) out.add(trimmed);
|
|
79
|
+
}
|
|
80
|
+
return [...out].sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Digest of a declared file scope: sha256 over the normalized paths, first 8 hex.
|
|
85
|
+
* An empty (or unusable) scope digests the empty string — deterministic, and it
|
|
86
|
+
* never throws, so a Discovery wave's empty scope has a stable answer rather than
|
|
87
|
+
* an error path.
|
|
88
|
+
*
|
|
89
|
+
* @param {unknown} paths array of path strings (anything else → empty scope).
|
|
90
|
+
* @returns {string} 8-character lowercase hex digest.
|
|
91
|
+
*/
|
|
92
|
+
export function scopeDigest(paths) {
|
|
93
|
+
return digestSha256Short(normalizeScopePaths(paths).join('\n'));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The single line the coordinator appends to an agent prompt after the fenced
|
|
98
|
+
* `FILE-SCOPE` block. It names the expected digest outright, so the agent only
|
|
99
|
+
* has to echo it — the check is about the line surviving the round trip, not
|
|
100
|
+
* about making the agent compute a hash.
|
|
101
|
+
*
|
|
102
|
+
* @param {unknown} paths
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
export function renderScopeEchoInstruction(paths) {
|
|
106
|
+
return `End your final report with the line: ${SCOPE_ECHO_MARKER} ${scopeDigest(paths)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Find the LAST scope-echo marker in an agent's report.
|
|
111
|
+
*
|
|
112
|
+
* The MARKER stays case-sensitive by design (a lowercase `scope-digest:` is not
|
|
113
|
+
* an echo), but the HEX PAYLOAD is accepted case-insensitively and normalized to
|
|
114
|
+
* lowercase: `0123ABCD` and `0123abcd` are the same 32 bits, and an agent that
|
|
115
|
+
* upper-cases the digest has demonstrably carried the line through — which is
|
|
116
|
+
* the only thing this signal measures.
|
|
117
|
+
*
|
|
118
|
+
* @param {unknown} reportText
|
|
119
|
+
* @returns {{ echoed: boolean, digest: string|null }}
|
|
120
|
+
*/
|
|
121
|
+
export function extractScopeEcho(reportText) {
|
|
122
|
+
if (typeof reportText !== 'string' || reportText.length === 0) {
|
|
123
|
+
return { echoed: false, digest: null };
|
|
124
|
+
}
|
|
125
|
+
let last = null;
|
|
126
|
+
ECHO_RE.lastIndex = 0;
|
|
127
|
+
for (const match of reportText.matchAll(ECHO_RE)) last = match[1].toLowerCase();
|
|
128
|
+
return last === null ? { echoed: false, digest: null } : { echoed: true, digest: last };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read a per-agent scope file (`<state-dir>/filescopes/wave-<N>/<agent-id>.json`,
|
|
133
|
+
* a JSON array of path strings — shape (a) of the two scope shapes, see
|
|
134
|
+
* CLAUDE.md / AGENTS.md § allowedPaths) and compare its digest against the agent's echo.
|
|
135
|
+
*
|
|
136
|
+
* Never throws: an unreadable or wrongly-shaped scope file yields
|
|
137
|
+
* `{ match: false, reason: 'scope-file-unreadable' }` so the caller logs an
|
|
138
|
+
* informational line instead of failing a wave.
|
|
139
|
+
*
|
|
140
|
+
* An EMPTY declared scope (a Discovery wave, `[]`) is NOT a failed echo: no
|
|
141
|
+
* instruction line was ever injected, so the agent had nothing to echo. Those
|
|
142
|
+
* verdicts carry `applicable: false` + `reason: 'scope-empty'` so a consumer can
|
|
143
|
+
* exclude them from the echo-rate instead of counting every uninstructed agent
|
|
144
|
+
* as a miss. `applicable` is OMITTED (never `true`) on the instructed path — the
|
|
145
|
+
* same "absent is not zero" discipline `wave` follows.
|
|
146
|
+
*
|
|
147
|
+
* @param {{ scopeFilePath?: string, reportText?: string }} args
|
|
148
|
+
* @returns {{ echoed: boolean, match: boolean, expected: string|null, actual: string|null, applicable?: boolean, reason?: string }}
|
|
149
|
+
*/
|
|
150
|
+
export function checkScopeEcho({ scopeFilePath, reportText } = {}) {
|
|
151
|
+
const { echoed, digest } = extractScopeEcho(reportText);
|
|
152
|
+
/** @type {string|null} */
|
|
153
|
+
let expected;
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(readFileSync(String(scopeFilePath), 'utf8'));
|
|
156
|
+
if (!Array.isArray(parsed)) throw new TypeError('scope file is not a JSON array');
|
|
157
|
+
if (normalizeScopePaths(parsed).length === 0) {
|
|
158
|
+
return {
|
|
159
|
+
echoed,
|
|
160
|
+
match: false,
|
|
161
|
+
expected: null,
|
|
162
|
+
actual: digest,
|
|
163
|
+
applicable: false,
|
|
164
|
+
reason: 'scope-empty',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
expected = scopeDigest(parsed);
|
|
168
|
+
} catch {
|
|
169
|
+
return { echoed, match: false, expected: null, actual: digest, reason: 'scope-file-unreadable' };
|
|
170
|
+
}
|
|
171
|
+
if (!echoed) return { echoed, match: false, expected, actual: null, reason: 'echo-absent' };
|
|
172
|
+
const match = digest === expected;
|
|
173
|
+
return { echoed, match, expected, actual: digest, ...(match ? {} : { reason: 'digest-mismatch' }) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build the telemetry payload for a verdict. Counts, closed enums and digests
|
|
178
|
+
* only — NO path and NO prompt text (issue #1092 acceptance criterion 3): this
|
|
179
|
+
* record also travels over the optional Clank Event-Bus webhook with no
|
|
180
|
+
* redaction, and declared paths carry private project slugs.
|
|
181
|
+
*
|
|
182
|
+
* NO path here means no path in THIS function's own object literal, before the
|
|
183
|
+
* standard `sessionAttribution` spread, which adds the session ids like every
|
|
184
|
+
* event — that spread is applied by the caller and is out of this promise's
|
|
185
|
+
* scope.
|
|
186
|
+
*
|
|
187
|
+
* `wave` follows the "absent is not zero" rule (`docs/events-schema.md`): it is
|
|
188
|
+
* omitted rather than defaulted when the caller has no wave number. `applicable`
|
|
189
|
+
* follows the same rule in the other direction — it is emitted ONLY as `false`,
|
|
190
|
+
* for a verdict whose agent was never instructed (empty declared scope), so a
|
|
191
|
+
* consumer computing an echo-rate can filter those rows out. Its absence means
|
|
192
|
+
* "instructed", never "unknown". (Events-schema row owned elsewhere: the
|
|
193
|
+
* `orchestrator.wave_dispatch.scope_echo_checked` catalogue entry needs an
|
|
194
|
+
* `applicable` field row.)
|
|
195
|
+
*
|
|
196
|
+
* @param {{ echoed: boolean, match: boolean, expected: string|null, actual: string|null, applicable?: boolean, reason?: string }} verdict
|
|
197
|
+
* @param {{ wave?: unknown, agentId?: unknown }} [meta]
|
|
198
|
+
* @returns {Record<string, unknown>}
|
|
199
|
+
*/
|
|
200
|
+
export function scopeEchoPayload(verdict, meta = {}) {
|
|
201
|
+
const wave = Number(meta.wave);
|
|
202
|
+
const agentId = typeof meta.agentId === 'string' ? meta.agentId.slice(0, AGENT_ID_MAX) : null;
|
|
203
|
+
return {
|
|
204
|
+
...(Number.isFinite(wave) && wave > 0 ? { wave } : {}),
|
|
205
|
+
...(agentId ? { agent_id: agentId } : {}),
|
|
206
|
+
echoed: verdict.echoed,
|
|
207
|
+
match: verdict.match,
|
|
208
|
+
...(verdict.applicable === false ? { applicable: false } : {}),
|
|
209
|
+
expected_digest: verdict.expected ?? null,
|
|
210
|
+
actual_digest: verdict.actual ?? null,
|
|
211
|
+
...(verdict.reason ? { reason: verdict.reason } : {}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// CLI
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
/** `--help` text. Exit codes are named here per `.claude/rules/cli-design.md` § Discoverability. */
|
|
220
|
+
const USAGE = `Usage:
|
|
221
|
+
scope-echo --scope-file <path> --instruction
|
|
222
|
+
Print the ONE prompt line instructing an agent to echo its scope digest.
|
|
223
|
+
Prints nothing for an empty or unreadable scope file.
|
|
224
|
+
|
|
225
|
+
scope-echo --scope-file <path> [--report-file <path>] [--wave N]
|
|
226
|
+
[--agent-id ID] [--emit]
|
|
227
|
+
Compare the agent report's SCOPE-DIGEST echo against the scope file and
|
|
228
|
+
print the verdict payload as JSON on stdout. --emit additionally appends
|
|
229
|
+
an ${SCOPE_ECHO_EVENT} row to .orchestrator/metrics/events.jsonl
|
|
230
|
+
(best-effort: a failed emit never changes the verdict or the exit code).
|
|
231
|
+
|
|
232
|
+
scope-echo --help
|
|
233
|
+
|
|
234
|
+
Exit codes: 0 for every verdict, 1 for a missing --scope-file.
|
|
235
|
+
`;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Minimal `--flag value` / `--flag` parser (no dependency, same shape as the
|
|
239
|
+
* other `scripts/lib/*` CLIs in this repo).
|
|
240
|
+
*
|
|
241
|
+
* @param {string[]} argv
|
|
242
|
+
* @returns {Record<string, string|boolean>}
|
|
243
|
+
*/
|
|
244
|
+
function parseArgv(argv) {
|
|
245
|
+
/** @type {Record<string, string|boolean>} */
|
|
246
|
+
const out = {};
|
|
247
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
248
|
+
const arg = argv[i];
|
|
249
|
+
if (!arg.startsWith('--')) continue;
|
|
250
|
+
const key = arg.slice(2);
|
|
251
|
+
const next = argv[i + 1];
|
|
252
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
253
|
+
out[key] = next;
|
|
254
|
+
i += 1;
|
|
255
|
+
} else {
|
|
256
|
+
out[key] = true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {string[]} [argv]
|
|
264
|
+
* @returns {Promise<number>} process exit code: 0 for every verdict (this is an
|
|
265
|
+
* observability tool — a broken echo check never fails a wave), or 1 for a
|
|
266
|
+
* missing `--scope-file`, which is a USER error per `.claude/rules/cli-design.md`
|
|
267
|
+
* (1 = user/input error; 2 is reserved for system errors).
|
|
268
|
+
*/
|
|
269
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
270
|
+
const args = parseArgv(argv);
|
|
271
|
+
if (args.help) {
|
|
272
|
+
process.stdout.write(USAGE);
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
const scopeFilePath = typeof args['scope-file'] === 'string' ? args['scope-file'] : '';
|
|
276
|
+
if (!scopeFilePath) {
|
|
277
|
+
process.stderr.write('scope-echo: --scope-file <path> is required\n');
|
|
278
|
+
process.stderr.write(USAGE);
|
|
279
|
+
return 1;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (args.instruction) {
|
|
283
|
+
let paths = [];
|
|
284
|
+
try {
|
|
285
|
+
const parsed = JSON.parse(readFileSync(scopeFilePath, 'utf8'));
|
|
286
|
+
if (Array.isArray(parsed)) paths = parsed;
|
|
287
|
+
} catch {
|
|
288
|
+
// Unreadable scope file → no instruction. Silent on stdout, because the
|
|
289
|
+
// caller splices stdout straight into the prompt. But it must NOT be
|
|
290
|
+
// silent altogether: a corrupt scope file and a legitimate empty Discovery
|
|
291
|
+
// scope both produced exit 0 with no output at all, so the operator could
|
|
292
|
+
// not tell an injected-nothing-by-design run from a broken one. One stderr
|
|
293
|
+
// line separates them (#1092 review R3).
|
|
294
|
+
process.stderr.write(
|
|
295
|
+
`scope-echo: scope file unreadable (${scopeFilePath}) — no echo line injected\n`,
|
|
296
|
+
);
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
if (paths.length === 0) return 0;
|
|
300
|
+
process.stdout.write(`${renderScopeEchoInstruction(paths)}\n`);
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const reportFile = typeof args['report-file'] === 'string' ? args['report-file'] : '';
|
|
305
|
+
/** @type {string} */
|
|
306
|
+
let reportText;
|
|
307
|
+
try {
|
|
308
|
+
reportText = reportFile ? readFileSync(reportFile, 'utf8') : '';
|
|
309
|
+
} catch {
|
|
310
|
+
reportText = '';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const verdict = checkScopeEcho({ scopeFilePath, reportText });
|
|
314
|
+
const payload = scopeEchoPayload(verdict, {
|
|
315
|
+
wave: args.wave,
|
|
316
|
+
agentId: typeof args['agent-id'] === 'string' ? args['agent-id'] : undefined,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (args.emit) {
|
|
320
|
+
try {
|
|
321
|
+
const repoRoot = process.cwd();
|
|
322
|
+
const { emitEvent, sessionAttribution } = await import('./events.mjs');
|
|
323
|
+
await emitEvent(
|
|
324
|
+
SCOPE_ECHO_EVENT,
|
|
325
|
+
{ ...payload, ...sessionAttribution(repoRoot) },
|
|
326
|
+
{ repoRoot },
|
|
327
|
+
);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
// Telemetry never changes the verdict (same discipline as the send-side
|
|
330
|
+
// hook): an unwritable ledger — a full disk, or `events.jsonl` existing as
|
|
331
|
+
// a DIRECTORY — still prints the verdict on stdout and still exits 0.
|
|
332
|
+
process.stderr.write(`scope-echo: emit failed — ${err?.message ?? err}\n`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const invokedAsCli =
|
|
341
|
+
process.argv[1] !== undefined &&
|
|
342
|
+
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
343
|
+
|
|
344
|
+
if (invokedAsCli) {
|
|
345
|
+
process.exitCode = await main();
|
|
346
|
+
}
|