mandrel 2.30.0 → 2.32.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/README.md +13 -17
- package/.agents/agents/acceptance-critic.md +1 -2
- package/.agents/docs/SDLC.md +4 -4
- package/.agents/docs/agentrc-reference.json +61 -57
- package/.agents/docs/configuration.md +274 -227
- package/.agents/docs/execution-reference.md +13 -14
- package/.agents/docs/quality-gates.md +195 -23
- package/.agents/instructions.md +2 -5
- package/.agents/rules/ci-remediation.md +44 -1
- package/.agents/rules/git-conventions-reference.md +27 -27
- package/.agents/rules/git-conventions.md +4 -2
- package/.agents/rules/known-tooling-behavior.md +66 -30
- package/.agents/rules/testing-standards.md +35 -71
- package/.agents/runtime-deps.json +0 -1
- package/.agents/schemas/agentrc.schema.json +1939 -1400
- package/.agents/schemas/lifecycle/README.md +21 -14
- package/.agents/schemas/lifecycle/ledger-record.schema.json +76 -22
- package/.agents/schemas/story-deliver-terminal.schema.json +2 -2
- package/.agents/scripts/README.md +7 -29
- package/.agents/scripts/apply-quality-bootstrap.js +27 -34
- package/.agents/scripts/bootstrap.js +28 -26
- package/.agents/scripts/check-baseline-drift.js +73 -13
- package/.agents/scripts/check-baseline-scope.js +362 -0
- package/.agents/scripts/check-dead-exports.js +9 -1
- package/.agents/scripts/check-gherkin-corpus.js +508 -0
- package/.agents/scripts/check-knip-entries.js +136 -0
- package/.agents/scripts/check-lifecycle-lint.js +36 -112
- package/.agents/scripts/check-schema-references.js +1 -1
- package/.agents/scripts/diagnose-friction.js +7 -4
- package/.agents/scripts/generate-config-docs.js +263 -171
- package/.agents/scripts/install-matrix-assert.js +0 -1
- package/.agents/scripts/lib/ITicketingProvider.js +0 -58
- package/.agents/scripts/lib/audit-baselines/staleness.js +6 -6
- package/.agents/scripts/lib/audit-baselines/trend.js +7 -8
- package/.agents/scripts/lib/audit-baselines/weights.js +4 -5
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +0 -1
- package/.agents/scripts/lib/baselines/envelope.js +41 -60
- package/.agents/scripts/lib/baselines/git-base.js +30 -37
- package/.agents/scripts/lib/baselines/kinds/_crap-new-method-gate.js +103 -0
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +150 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +25 -65
- package/.agents/scripts/lib/baselines/orphan-pruner.js +233 -0
- package/.agents/scripts/lib/baselines/refresh-service.js +6 -8
- package/.agents/scripts/lib/baselines/scope-assert.js +223 -0
- package/.agents/scripts/lib/baselines/scope-inventory.js +314 -0
- package/.agents/scripts/lib/bdd-step-index.js +326 -0
- package/.agents/scripts/lib/bootstrap/install-ledger.js +5 -3
- package/.agents/scripts/lib/bootstrap/issue-forms-template.js +4 -6
- package/.agents/scripts/lib/bootstrap/manifest.js +17 -40
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +12 -59
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +62 -2
- package/.agents/scripts/lib/checks/loop-health.js +9 -37
- package/.agents/scripts/lib/child-exec.js +193 -0
- package/.agents/scripts/lib/cli/standard-args.js +1 -1
- package/.agents/scripts/lib/cli-args.js +64 -0
- package/.agents/scripts/lib/close-validation/gates.js +2 -2
- package/.agents/scripts/lib/close-validation/runner.js +3 -3
- package/.agents/scripts/lib/config/acceptance-eval.js +5 -52
- package/.agents/scripts/lib/config/commands.js +3 -5
- package/.agents/scripts/lib/config/explain.js +5 -7
- package/.agents/scripts/lib/config/gates/bundle-size.schema.js +32 -6
- package/.agents/scripts/lib/config/gates/coverage.schema.js +25 -5
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +12 -2
- package/.agents/scripts/lib/config/gates/crap.schema.js +68 -23
- package/.agents/scripts/lib/config/gates/duplication.schema.js +29 -17
- package/.agents/scripts/lib/config/gates/index.js +5 -2
- package/.agents/scripts/lib/config/gates/lighthouse.schema.js +34 -6
- package/.agents/scripts/lib/config/gates/lint.schema.js +11 -2
- package/.agents/scripts/lib/config/gates/maintainability.schema.js +37 -15
- package/.agents/scripts/lib/config/gates/mutation.schema.js +15 -3
- package/.agents/scripts/lib/config/gates/shared.js +58 -9
- package/.agents/scripts/lib/config/github.js +0 -1
- package/.agents/scripts/lib/config/limits.js +3 -48
- package/.agents/scripts/lib/config/qa.js +105 -0
- package/.agents/scripts/lib/config/temp-paths.js +6 -5
- package/.agents/scripts/lib/config-settings-schema-delivery.js +237 -56
- package/.agents/scripts/lib/config-settings-schema-quality.js +209 -29
- package/.agents/scripts/lib/config-settings-schema.js +386 -39
- package/.agents/scripts/lib/crap-baseline-join.js +126 -9
- package/.agents/scripts/lib/crap-utils.js +84 -520
- package/.agents/scripts/lib/dead-exports-knip.js +79 -10
- package/.agents/scripts/lib/degraded-mode.js +2 -2
- package/.agents/scripts/lib/doc-tiers.js +3 -3
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +46 -104
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +10 -8
- package/.agents/scripts/lib/fs-walk.js +52 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +2 -2
- package/.agents/scripts/lib/git-utils.js +16 -36
- package/.agents/scripts/lib/knip-entry-sync.js +469 -0
- package/.agents/scripts/lib/observability/metrics-ledger.js +1 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +10 -0
- package/.agents/scripts/lib/observability/signal-validator.js +5 -85
- package/.agents/scripts/lib/observability/signals-writer.js +19 -62
- package/.agents/scripts/lib/observability/source-classifier.js +5 -7
- package/.agents/scripts/lib/observability/terse-result.js +3 -3
- package/.agents/scripts/lib/orchestration/behind-recovery.js +114 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +7 -8
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +34 -33
- package/.agents/scripts/lib/orchestration/code-review.js +2 -2
- package/.agents/scripts/lib/orchestration/complexity-gate.js +43 -161
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +4 -4
- package/.agents/scripts/lib/orchestration/label-transitions.js +3 -2
- package/.agents/scripts/lib/orchestration/lease-guard-shared.js +12 -38
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +5 -6
- package/.agents/scripts/lib/orchestration/plan-metrics.js +2 -3
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +6 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +0 -1
- package/.agents/scripts/lib/orchestration/{lifecycle/listeners/watcher.js → pr-watch.js} +58 -208
- package/.agents/scripts/lib/orchestration/resolve-stories.js +5 -15
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +1 -1
- package/.agents/scripts/lib/orchestration/review-providers/mi-exemptions.js +130 -0
- package/.agents/scripts/lib/orchestration/review-providers/native.js +30 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +37 -26
- package/.agents/scripts/lib/orchestration/single-story-close/phases/conventional-subject.js +376 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +161 -151
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +15 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +10 -15
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +17 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +5 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +157 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +0 -14
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +65 -25
- package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +20 -31
- package/.agents/scripts/lib/orchestration/spec-spill.js +17 -3
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +7 -6
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +2 -1
- package/.agents/scripts/lib/orchestration/task-body-validator.js +4 -1
- package/.agents/scripts/lib/orchestration/ticket-lease.js +28 -127
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -1
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +5 -5
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +5 -4
- package/.agents/scripts/lib/orchestration/verify-tier-repair.js +107 -0
- package/.agents/scripts/lib/qa/coverage-verdict.js +5 -87
- package/.agents/scripts/lib/signals/detectors/common.js +1 -1
- package/.agents/scripts/lib/signals/index.js +8 -6
- package/.agents/scripts/lib/signals/schema.js +20 -25
- package/.agents/scripts/lib/signals/write.js +8 -8
- package/.agents/scripts/lib/story-body/story-body.js +12 -59
- package/.agents/scripts/lib/temp-retention.js +1 -1
- package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -14
- package/.agents/scripts/lib/ticket-body-sections.js +4 -5
- package/.agents/scripts/lib/worktree/lifecycle/merge-reachability.js +13 -45
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +4 -5
- package/.agents/scripts/lib/worktree-manager.js +2 -3
- package/.agents/scripts/lint-label-vocabulary.js +2 -24
- package/.agents/scripts/pr-watch-with-update.js +7 -5
- package/.agents/scripts/providers/github/cache.js +2 -2
- package/.agents/scripts/providers/github/comments.js +6 -28
- package/.agents/scripts/providers/github/compose.js +0 -15
- package/.agents/scripts/providers/github/errors.js +10 -27
- package/.agents/scripts/providers/github/request-helpers.js +1 -2
- package/.agents/scripts/providers/github/sub-issues.js +10 -218
- package/.agents/scripts/providers/github.js +4 -7
- package/.agents/scripts/prune-baseline-orphans.js +181 -0
- package/.agents/scripts/resolve-stories.js +0 -2
- package/.agents/scripts/run-lint.js +61 -61
- package/.agents/scripts/run-test-profile.js +6 -6
- package/.agents/scripts/run-verify.js +48 -30
- package/.agents/scripts/single-story-close.js +20 -0
- package/.agents/scripts/single-story-init.js +12 -35
- package/.agents/scripts/update-dead-exports-baseline.js +321 -0
- package/.agents/skills/core/gates-and-baselines/SKILL.md +2 -2
- package/.agents/skills/skills.index.json +2 -12
- package/.agents/skills/stack/qa/playwright/SKILL.md +48 -0
- package/.agents/workflows/audit-documentation.md +5 -6
- package/.agents/workflows/audit-to-stories.md +2 -2
- package/.agents/workflows/helpers/audit-lens-core.md +11 -12
- package/.agents/workflows/helpers/code-quality-guardrails.md +15 -14
- package/.agents/workflows/helpers/code-review.md +3 -8
- package/.agents/workflows/helpers/deliver-reference.md +2 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +27 -16
- package/.agents/workflows/helpers/worktree-lifecycle.md +1 -2
- package/.agents/workflows/mandrel-update.md +10 -10
- package/.agents/workflows/qa-assist.md +15 -20
- package/.agents/workflows/qa-explore.md +9 -8
- package/README.md +1 -1
- package/docs/CHANGELOG.md +49 -0
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +127 -0
- package/package.json +12 -3
- package/.agents/schemas/lifecycle/checkpoint.written.schema.json +0 -13
- package/.agents/schemas/lifecycle/close-validate.end.schema.json +0 -18
- package/.agents/schemas/lifecycle/close-validate.start.schema.json +0 -13
- package/.agents/schemas/lifecycle/code-review.end.schema.json +0 -30
- package/.agents/schemas/lifecycle/code-review.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/intervention.recorded.schema.json +0 -15
- package/.agents/schemas/lifecycle/loop.tick.schema.json +0 -20
- package/.agents/schemas/lifecycle/notification.emitted.schema.json +0 -18
- package/.agents/schemas/lifecycle/pr.created.schema.json +0 -14
- package/.agents/schemas/lifecycle/retro.end.schema.json +0 -16
- package/.agents/schemas/lifecycle/retro.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/story.blocked.schema.json +0 -13
- package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +0 -17
- package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +0 -15
- package/.agents/schemas/lifecycle/story.merged.schema.json +0 -13
- package/.agents/scripts/check-gherkin-placeholders.js +0 -663
- package/.agents/scripts/check-lifecycle-doc-drift.js +0 -411
- package/.agents/scripts/lib/audit-suite/cli.js +0 -64
- package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +0 -202
- package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +0 -212
- package/.agents/scripts/lib/checks/baseline-drift-main-checkout.js +0 -104
- package/.agents/scripts/lib/checks/push-hook-parity.js +0 -106
- package/.agents/scripts/lib/checks/windows-coverage-noise-floor.js +0 -92
- package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +0 -81
- package/.agents/scripts/lib/checks/worktree-residue-biome.js +0 -55
- package/.agents/scripts/lib/crap-baseline-index.js +0 -46
- package/.agents/scripts/lib/crap-utils-incremental.js +0 -113
- package/.agents/scripts/lib/dynamic-workflow/capability.js +0 -396
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -335
- package/.agents/scripts/lib/mutation/baseline-snapshot.js +0 -239
- package/.agents/scripts/lib/mutation/config-detector.js +0 -119
- package/.agents/scripts/lib/mutation/stryker-runner.js +0 -306
- package/.agents/scripts/lib/mutation/survivor-report.js +0 -160
- package/.agents/scripts/lib/observability/active-story-env.js +0 -170
- package/.agents/scripts/lib/observability/tool-trace-hook.js +0 -456
- package/.agents/scripts/lib/orchestration/acceptance-clusters.js +0 -111
- package/.agents/scripts/lib/orchestration/context-envelope.js +0 -277
- package/.agents/scripts/lib/orchestration/detectors-phase.js +0 -194
- package/.agents/scripts/lib/orchestration/lifecycle/bus.js +0 -309
- package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +0 -181
- package/.agents/scripts/lib/orchestration/lifecycle/ledger-writer.js +0 -229
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +0 -54
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -344
- package/.agents/scripts/lib/orchestration/lint-baseline-service.js +0 -114
- package/.agents/scripts/lib/orchestration/pr-base-guard.js +0 -37
- package/.agents/scripts/lib/orchestration/resolves-token.js +0 -127
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +0 -130
- package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +0 -55
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +0 -211
- package/.agents/scripts/lib/planning-corpus.js +0 -37
- package/.agents/scripts/lib/qa/coverage-report.js +0 -181
- package/.agents/scripts/lib/qa/propose-missing-test.js +0 -95
- package/.agents/scripts/lib/qa/qa-context-hydrator.js +0 -217
- package/.agents/scripts/lib/signals/detectors/index.js +0 -14
- package/.agents/scripts/lib/signals/detectors/retry.js +0 -253
- package/.agents/scripts/lib/signals/detectors/rework.js +0 -167
- package/.agents/scripts/lib/signals/read.js +0 -268
- package/.agents/scripts/lib/signals/span-tree.js +0 -291
- package/.agents/scripts/lib/story-lifecycle.js +0 -194
- package/.agents/scripts/lib/story-plan.js +0 -379
- package/.agents/scripts/lib/util/phase-timer-state.js +0 -72
- package/.agents/scripts/lib/util/phase-timer.js +0 -163
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +0 -169
- package/.agents/scripts/lint-baseline.js +0 -507
- package/.agents/scripts/providers/github/prs.js +0 -103
- package/.agents/scripts/signals-view.js +0 -309
- package/.agents/scripts/story-plan.js +0 -370
- package/.agents/scripts/sync-branch-from-base.js +0 -149
- package/.agents/scripts/validate-docs-freshness.js +0 -314
- package/.agents/skills/core/diagnose-friction/SKILL.md +0 -78
- package/.agents/workflows/helpers/signals.md +0 -112
|
@@ -1,456 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tool-trace hook (Epic #1030 Story #1043 / Task #1058).
|
|
3
|
-
*
|
|
4
|
-
* Entry point invoked from `.claude/settings.json` PreToolUse and
|
|
5
|
-
* PostToolUse hook entries. Resolves the active Epic + Story from
|
|
6
|
-
* environment variables (`CC_EPIC_ID` / `CC_STORY_ID`), pairs Pre/Post
|
|
7
|
-
* tool-call events, and appends one `kind:"trace"` NDJSON line per tool
|
|
8
|
-
* call to `temp/run-<id>/stories/story-<sid>/traces.ndjson` via the
|
|
9
|
-
* `signals-writer.appendTrace` helper.
|
|
10
|
-
*
|
|
11
|
-
* Robustness contract (Tech Spec #1032 §observability + §security):
|
|
12
|
-
* - **No-op outside an active Story.** When `CC_EPIC_ID` or
|
|
13
|
-
* `CC_STORY_ID` is unset / non-numeric the hook returns immediately
|
|
14
|
-
* without touching the filesystem. This prevents tooling invoked
|
|
15
|
-
* during planning, dispatch, or close phases from polluting random
|
|
16
|
-
* NDJSON files.
|
|
17
|
-
* - **Best-effort.** The top-level `main(event)` is wrapped in
|
|
18
|
-
* `try/catch`. A failing hook MUST never block tool execution —
|
|
19
|
-
* `appendTrace` already swallows fs / serialisation failures, but
|
|
20
|
-
* the outer guard catches anything that might still escape (e.g. an
|
|
21
|
-
* event with a circular reference reachable before serialisation).
|
|
22
|
-
* - **Privacy-preserving detail.** Bash commands and file paths are
|
|
23
|
-
* hashed (sha256) before being recorded. We deliberately store
|
|
24
|
-
* `targetHash` rather than the raw value because a Bash command may
|
|
25
|
-
* embed an env-var-laden token and a file path may leak the
|
|
26
|
-
* operator's local layout. The hash gives the analyzer enough to
|
|
27
|
-
* count repeats (churn / retry detectors) without retaining the
|
|
28
|
-
* plaintext.
|
|
29
|
-
* - **Capped detail size.** Even after hashing, supplementary fields
|
|
30
|
-
* (durationMs, exit code summaries, etc.) are clamped to
|
|
31
|
-
* `MAX_DETAIL_BYTES` so a runaway tool argument can't bloat the
|
|
32
|
-
* trace file.
|
|
33
|
-
*
|
|
34
|
-
* Pre/Post pairing:
|
|
35
|
-
* - `PreToolUse` records the start timestamp keyed by the event's
|
|
36
|
-
* `tool_use_id` (provided by the harness) into a process-local
|
|
37
|
-
* `Map`. Pre events themselves do NOT append to `traces.ndjson` —
|
|
38
|
-
* only the matching Post event does, once it has a duration to
|
|
39
|
-
* attach.
|
|
40
|
-
* - `PostToolUse` looks up the start record, computes
|
|
41
|
-
* `durationMs = now - startedAt`, appends the trace line, and
|
|
42
|
-
* evicts the start record. A Post without a matching Pre still
|
|
43
|
-
* appends a single trace line (with `durationMs: null`) so the
|
|
44
|
-
* event is not silently dropped — this matches the AC: "PostToolUse
|
|
45
|
-
* without a matching PreToolUse logs once and returns without
|
|
46
|
-
* throwing".
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
|
-
import { createHash } from 'node:crypto';
|
|
50
|
-
|
|
51
|
-
import { appendTrace } from './signals-writer.js';
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Maximum size (bytes) for any single string field stored in the trace
|
|
55
|
-
* `details` payload after hashing. Hashes are 64 hex chars (always under
|
|
56
|
-
* the cap); the limit guards supplementary plaintext fields that may
|
|
57
|
-
* legitimately appear (e.g. tool name, phase, exit-code summaries).
|
|
58
|
-
*/
|
|
59
|
-
const MAX_DETAIL_BYTES = 1024;
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Process-local Map keyed by the harness-provided `tool_use_id` (or a
|
|
63
|
-
* synthetic key when the harness omits it). Holds `{ startedAt, tool }`
|
|
64
|
-
* for each in-flight tool call until the matching Post event lands.
|
|
65
|
-
*
|
|
66
|
-
* The Map lives at module scope on purpose: PreToolUse and PostToolUse
|
|
67
|
-
* are invoked as separate hook calls within the same parent agent
|
|
68
|
-
* process, so a module-level Map preserves state across calls without
|
|
69
|
-
* any external coordination. If the agent process crashes mid-tool-
|
|
70
|
-
* call, the Map dies with it and no orphan record leaks.
|
|
71
|
-
*/
|
|
72
|
-
const inflight = new Map();
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* SHA-256 hex digest of the input string, prefixed with `sha256:` to
|
|
76
|
-
* mirror the convention used elsewhere in the signals pipeline (see
|
|
77
|
-
* `signal-event.schema.json` `details.targetHash`). Returns `null` for
|
|
78
|
-
* empty / non-string input so callers can omit the field entirely
|
|
79
|
-
* rather than recording a hash of the empty string.
|
|
80
|
-
*
|
|
81
|
-
* @param {unknown} value
|
|
82
|
-
* @returns {string|null}
|
|
83
|
-
*/
|
|
84
|
-
function hashTarget(value) {
|
|
85
|
-
if (typeof value !== 'string' || value.length === 0) return null;
|
|
86
|
-
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Normalise a Bash command string into a stable identity for the retry
|
|
91
|
-
* detector (Story #1768 / Task #1775). The output is what we hash to
|
|
92
|
-
* derive `details.normalizedHash`; the raw value is still hashed
|
|
93
|
-
* separately into `details.targetHash` so the privacy contract for the
|
|
94
|
-
* raw command is unchanged.
|
|
95
|
-
*
|
|
96
|
-
* ## Rules (documented contract — kept narrow on purpose)
|
|
97
|
-
*
|
|
98
|
-
* 1. **Whitespace collapse.** Leading/trailing whitespace is stripped
|
|
99
|
-
* and runs of internal whitespace collapse to a single space.
|
|
100
|
-
* `npm test` → `npm test`. `\n` and `\t` count as whitespace.
|
|
101
|
-
*
|
|
102
|
-
* 2. **Strip benign trailing flags.** `--no-color` and `--quiet`
|
|
103
|
-
* anywhere in the argv are removed (with their surrounding
|
|
104
|
-
* whitespace re-collapsed). These flags affect output only and
|
|
105
|
-
* never change the command's identity for retry-detection
|
|
106
|
-
* purposes. The list is deliberately tiny — adding more flags is
|
|
107
|
-
* a new ADR conversation, not a hook tweak.
|
|
108
|
-
*
|
|
109
|
-
* 3. **`npm test` ≡ `npm run test`.** Only this single paraphrase
|
|
110
|
-
* collapses. `npm run lint` and `npm test` do **not** collapse;
|
|
111
|
-
* `npm run build` and `npm build` do **not** collapse. The
|
|
112
|
-
* treatment is intentional: it covers the one paraphrase the
|
|
113
|
-
* `package.json` `scripts.test` shorthand makes idiomatic, and
|
|
114
|
-
* stops there.
|
|
115
|
-
*
|
|
116
|
-
* Returns `null` when the input is not a non-empty string (so the
|
|
117
|
-
* caller can omit the field rather than recording a hash of `''`).
|
|
118
|
-
*
|
|
119
|
-
* Exported for testing — the unit suite asserts the collapse rules.
|
|
120
|
-
*
|
|
121
|
-
* @param {unknown} command
|
|
122
|
-
* @returns {string|null}
|
|
123
|
-
*/
|
|
124
|
-
export function normaliseBashCommand(command) {
|
|
125
|
-
if (typeof command !== 'string' || command.length === 0) return null;
|
|
126
|
-
|
|
127
|
-
// Step 1: whitespace collapse. Trim then collapse internal runs.
|
|
128
|
-
let normalised = command.trim().replace(/\s+/g, ' ');
|
|
129
|
-
if (normalised.length === 0) return null;
|
|
130
|
-
|
|
131
|
-
// Step 2: strip benign trailing/inline flags. Token-level removal so
|
|
132
|
-
// we don't accidentally chew into a longer flag (e.g. `--quiet-mode`
|
|
133
|
-
// would not match `--quiet`).
|
|
134
|
-
const benignFlags = new Set(['--no-color', '--quiet']);
|
|
135
|
-
const tokens = normalised.split(' ').filter((tok) => !benignFlags.has(tok));
|
|
136
|
-
normalised = tokens.join(' ');
|
|
137
|
-
if (normalised.length === 0) return null;
|
|
138
|
-
|
|
139
|
-
// Step 3: collapse `npm run test` → `npm test`. Only this exact
|
|
140
|
-
// paraphrase. We rewrite to the shorter form because the longer form
|
|
141
|
-
// is more verbose; either direction would work, but the `scripts.test`
|
|
142
|
-
// shorthand is the form most operators type.
|
|
143
|
-
if (normalised === 'npm run test') {
|
|
144
|
-
normalised = 'npm test';
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return normalised;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Clamp a string field so a misbehaving tool argument can't bloat the
|
|
152
|
-
* trace file. Non-string input is returned unchanged (numbers, booleans,
|
|
153
|
-
* etc. are size-bounded by JSON serialisation).
|
|
154
|
-
*/
|
|
155
|
-
function clamp(value) {
|
|
156
|
-
if (typeof value !== 'string') return value;
|
|
157
|
-
if (Buffer.byteLength(value, 'utf8') <= MAX_DETAIL_BYTES) return value;
|
|
158
|
-
// Truncate by code units; exact byte cap not critical because the
|
|
159
|
-
// limit exists for size protection, not for security.
|
|
160
|
-
return `${value.slice(0, MAX_DETAIL_BYTES)}…`;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Resolve the active Epic + Story from env vars. Returns `null` when
|
|
165
|
-
* `CC_STORY_ID` is unset / non-numeric — the caller treats this as
|
|
166
|
-
* the "outside an active Story, no-op" case.
|
|
167
|
-
*
|
|
168
|
-
* Story #2874 — `epicId` can be `null` for standalone Stories (run
|
|
169
|
-
* via `/single-story-deliver`). When `CC_STORY_ID` is present but
|
|
170
|
-
* `CC_EPIC_ID` is absent, the hook still emits trace lines, keyed
|
|
171
|
-
* to the story only; the trace's `epicId` field is `null`. The
|
|
172
|
-
* no-op contract for the fully-no-context case (both vars absent)
|
|
173
|
-
* is preserved: it returns null.
|
|
174
|
-
*
|
|
175
|
-
* Exported for testing (the unit suite asserts that an unset
|
|
176
|
-
* `CC_STORY_ID` makes the hook take the no-op branch, and that an
|
|
177
|
-
* unset `CC_EPIC_ID` with a present `CC_STORY_ID` yields a
|
|
178
|
-
* `{ epicId: null, storyId }` envelope).
|
|
179
|
-
*
|
|
180
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
181
|
-
* @returns {{ epicId: number|null, storyId: number }|null}
|
|
182
|
-
*/
|
|
183
|
-
export function resolveActiveStory(env = process.env) {
|
|
184
|
-
const epicRaw = env.CC_EPIC_ID;
|
|
185
|
-
const storyRaw = env.CC_STORY_ID;
|
|
186
|
-
if (!storyRaw) return null;
|
|
187
|
-
const storyId = Number.parseInt(storyRaw, 10);
|
|
188
|
-
if (!Number.isInteger(storyId) || storyId <= 0) return null;
|
|
189
|
-
if (!epicRaw) return { epicId: null, storyId };
|
|
190
|
-
const epicId = Number.parseInt(epicRaw, 10);
|
|
191
|
-
if (!Number.isInteger(epicId) || epicId <= 0) return null;
|
|
192
|
-
return { epicId, storyId };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Story #1768 / Task #1775 — extract the Bash-input hashing into a
|
|
197
|
-
* helper so `buildDetails` stays under its CRAP baseline. Returns
|
|
198
|
-
* `{ targetHash, normalizedHash? }` for Bash input, `null` for any
|
|
199
|
-
* other shape so the caller can fall through to the file_path / pattern
|
|
200
|
-
* branches unchanged. The `normalizedHash` is omitted when
|
|
201
|
-
* `normaliseBashCommand` rejects the input (non-string / empty after
|
|
202
|
-
* normalisation).
|
|
203
|
-
*
|
|
204
|
-
* @param {object} toolInput
|
|
205
|
-
* @returns {{ targetHash: string, normalizedHash?: string } | null}
|
|
206
|
-
*/
|
|
207
|
-
function hashBashInput(toolInput) {
|
|
208
|
-
if (typeof toolInput?.command !== 'string') return null;
|
|
209
|
-
const out = { targetHash: hashTarget(toolInput.command) };
|
|
210
|
-
const normalised = normaliseBashCommand(toolInput.command);
|
|
211
|
-
if (normalised !== null) {
|
|
212
|
-
out.normalizedHash = hashTarget(normalised);
|
|
213
|
-
}
|
|
214
|
-
return out;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Build the canonical `details` block for a trace line. Hashes the
|
|
219
|
-
* Bash command (`tool_input.command`) and any file-path-shaped input
|
|
220
|
-
* (`tool_input.file_path`, `tool_input.path`, `tool_input.pattern`)
|
|
221
|
-
* before recording. The raw value never appears on disk.
|
|
222
|
-
*
|
|
223
|
-
* @param {{ tool: string, toolInput?: object, durationMs?: number|null, exitCode?: number|null }} args
|
|
224
|
-
* @returns {object}
|
|
225
|
-
*/
|
|
226
|
-
function buildDetails({ tool, toolInput, durationMs, exitCode }) {
|
|
227
|
-
const details = {};
|
|
228
|
-
if (typeof durationMs === 'number') {
|
|
229
|
-
details.durationMs = durationMs;
|
|
230
|
-
} else if (durationMs === null) {
|
|
231
|
-
details.durationMs = null;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Bash exit code — the retry detector's failure predicate
|
|
235
|
-
// (`details.exitCode !== 0`) can only fire once this is recorded. Only
|
|
236
|
-
// Bash PostToolUse events carry a meaningful exit code; other tools omit
|
|
237
|
-
// the field entirely (Epic #4406 / Story #4413).
|
|
238
|
-
if (tool === 'Bash' && typeof exitCode === 'number') {
|
|
239
|
-
details.exitCode = exitCode;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (toolInput && typeof toolInput === 'object') {
|
|
243
|
-
// Bash: hash `command` so a token-laden string never lands on disk.
|
|
244
|
-
// Story #1768 also records `normalizedHash` (paraphrase-collapsed)
|
|
245
|
-
// for retry detection. See `hashBashInput`.
|
|
246
|
-
const bash = hashBashInput(toolInput);
|
|
247
|
-
if (bash) Object.assign(details, bash);
|
|
248
|
-
// Edit / Write / Read: hash `file_path` for the same reason — the
|
|
249
|
-
// operator's local path layout is not interesting to the analyzer.
|
|
250
|
-
if (
|
|
251
|
-
typeof toolInput.file_path === 'string' &&
|
|
252
|
-
details.targetHash === undefined
|
|
253
|
-
) {
|
|
254
|
-
details.targetHash = hashTarget(toolInput.file_path);
|
|
255
|
-
}
|
|
256
|
-
// Glob / Grep: pattern is not secret, but we hash for consistency
|
|
257
|
-
// and so the `churn` detector can count repeats by hash equality.
|
|
258
|
-
if (
|
|
259
|
-
typeof toolInput.pattern === 'string' &&
|
|
260
|
-
details.targetHash === undefined
|
|
261
|
-
) {
|
|
262
|
-
details.targetHash = hashTarget(toolInput.pattern);
|
|
263
|
-
}
|
|
264
|
-
// Agent: record the literal `model` arg (Story #2590). The arg is a
|
|
265
|
-
// short enum value ('haiku'/'sonnet'/'opus') with no operator-secret
|
|
266
|
-
// content, so we store it plaintext. Records absence as `null` for
|
|
267
|
-
// Agent calls only — the field is omitted for every other tool — so
|
|
268
|
-
// the analyzer can distinguish "Agent fired without model" from
|
|
269
|
-
// "non-Agent tool".
|
|
270
|
-
if (tool === 'Agent') {
|
|
271
|
-
details.model =
|
|
272
|
-
typeof toolInput.model === 'string' ? clamp(toolInput.model) : null;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Clamp the tool name (defensive — Claude's tool names are short, but
|
|
277
|
-
// a hostile harness could inject a long string and we'd rather cap
|
|
278
|
-
// here than leak it onto disk).
|
|
279
|
-
if (tool) details.tool = clamp(tool);
|
|
280
|
-
|
|
281
|
-
return details;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Extract the Bash exit code from a PostToolUse event. The harness reports
|
|
286
|
-
* the tool result under `tool_response` (occasionally at the event root);
|
|
287
|
-
* the exit-code field name is not contractually fixed across harness
|
|
288
|
-
* versions, so we probe the known aliases and return the first numeric
|
|
289
|
-
* hit. Returns `null` when no numeric exit code is present — the caller
|
|
290
|
-
* omits `details.exitCode` entirely rather than recording a guess.
|
|
291
|
-
*
|
|
292
|
-
* Exported for testing.
|
|
293
|
-
*
|
|
294
|
-
* @param {object} event
|
|
295
|
-
* @returns {number|null}
|
|
296
|
-
*/
|
|
297
|
-
export function extractExitCode(event) {
|
|
298
|
-
const candidates = [
|
|
299
|
-
event?.tool_response?.exitCode,
|
|
300
|
-
event?.tool_response?.exit_code,
|
|
301
|
-
event?.tool_response?.returnCode,
|
|
302
|
-
event?.tool_response?.code,
|
|
303
|
-
event?.tool_response?.status,
|
|
304
|
-
event?.exit_code,
|
|
305
|
-
event?.exitCode,
|
|
306
|
-
];
|
|
307
|
-
for (const c of candidates) {
|
|
308
|
-
if (typeof c === 'number' && Number.isFinite(c)) return c;
|
|
309
|
-
}
|
|
310
|
-
return null;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* PreToolUse handler. Stashes `{ startedAt, tool }` into the in-flight
|
|
315
|
-
* Map keyed by the harness `tool_use_id`. Does not append to
|
|
316
|
-
* `traces.ndjson` — the matching Post event does that once it has a
|
|
317
|
-
* duration to attach.
|
|
318
|
-
*
|
|
319
|
-
* Exported for testing.
|
|
320
|
-
*
|
|
321
|
-
* @param {object} event
|
|
322
|
-
*/
|
|
323
|
-
export function handlePre(event) {
|
|
324
|
-
const id = event?.tool_use_id ?? event?.id ?? null;
|
|
325
|
-
if (!id) return; // No id => no way to pair on Post; drop silently.
|
|
326
|
-
const tool = event?.tool_name ?? event?.tool ?? null;
|
|
327
|
-
inflight.set(id, { startedAt: Date.now(), tool });
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* PostToolUse handler. Looks up the start record, computes a duration,
|
|
332
|
-
* appends one `kind:"trace"` line to `traces.ndjson`, and evicts the
|
|
333
|
-
* start record. A Post without a matching Pre still appends a single
|
|
334
|
-
* line with `durationMs: null` (AC: "PostToolUse without a matching
|
|
335
|
-
* PreToolUse logs once and returns without throwing").
|
|
336
|
-
*
|
|
337
|
-
* Exported for testing.
|
|
338
|
-
*
|
|
339
|
-
* @param {object} event
|
|
340
|
-
* @param {{ epicId: number, storyId: number }} active
|
|
341
|
-
*/
|
|
342
|
-
export async function handlePost(event, active) {
|
|
343
|
-
const id = event?.tool_use_id ?? event?.id ?? null;
|
|
344
|
-
const tool = event?.tool_name ?? event?.tool ?? 'unknown';
|
|
345
|
-
const toolInput = event?.tool_input;
|
|
346
|
-
const exitCode = extractExitCode(event);
|
|
347
|
-
|
|
348
|
-
let durationMs = null;
|
|
349
|
-
if (id && inflight.has(id)) {
|
|
350
|
-
const start = inflight.get(id);
|
|
351
|
-
inflight.delete(id);
|
|
352
|
-
durationMs = Date.now() - start.startedAt;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const trace = {
|
|
356
|
-
ts: new Date().toISOString(),
|
|
357
|
-
kind: 'trace',
|
|
358
|
-
emitter: { tool: clamp(tool) },
|
|
359
|
-
epicId: active.epicId,
|
|
360
|
-
storyId: active.storyId,
|
|
361
|
-
taskId: null,
|
|
362
|
-
phase:
|
|
363
|
-
typeof process.env.CC_PHASE === 'string' ? process.env.CC_PHASE : null,
|
|
364
|
-
details: buildDetails({ tool, toolInput, durationMs, exitCode }),
|
|
365
|
-
};
|
|
366
|
-
|
|
367
|
-
await appendTrace({
|
|
368
|
-
epicId: active.epicId,
|
|
369
|
-
storyId: active.storyId,
|
|
370
|
-
trace,
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Top-level entry point. Routes by `event.hook_event_name` to the
|
|
376
|
-
* Pre/Post handlers; swallows every error so tool execution is never
|
|
377
|
-
* blocked.
|
|
378
|
-
*
|
|
379
|
-
* @param {object} event
|
|
380
|
-
* @returns {Promise<void>}
|
|
381
|
-
*/
|
|
382
|
-
export async function main(event) {
|
|
383
|
-
try {
|
|
384
|
-
if (!event || typeof event !== 'object') return;
|
|
385
|
-
const active = resolveActiveStory();
|
|
386
|
-
|
|
387
|
-
const phase = event.hook_event_name;
|
|
388
|
-
if (phase === 'PreToolUse') {
|
|
389
|
-
// Pre-pairing only matters for the trace-line duration, which only
|
|
390
|
-
// the Story-scoped trace path records.
|
|
391
|
-
if (active) handlePre(event);
|
|
392
|
-
} else if (phase === 'PostToolUse') {
|
|
393
|
-
if (active) await handlePost(event, active);
|
|
394
|
-
}
|
|
395
|
-
// Any other phase is silently ignored — the hook is registered for
|
|
396
|
-
// Pre/Post only; receiving anything else is a configuration error
|
|
397
|
-
// we should not amplify by throwing.
|
|
398
|
-
} catch {
|
|
399
|
-
// Swallow. Observability MUST NOT take down the runner.
|
|
400
|
-
// signals-writer.appendTrace already logs its own failures via
|
|
401
|
-
// Logger.warn; anything that escapes to here is a programmer
|
|
402
|
-
// error in this module, not an operator-visible signal.
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* CLI entry point: read one JSON event from stdin, dispatch to `main`,
|
|
408
|
-
* and exit 0 unconditionally. The harness pipes the hook payload as
|
|
409
|
-
* stdin per the `.claude/settings.json` `type: "command"` contract.
|
|
410
|
-
*
|
|
411
|
-
* Exported so tests can run the file as a module without re-spawning
|
|
412
|
-
* Node. Production callers go through the bin shim
|
|
413
|
-
* (`node lib/observability/tool-trace-hook.js`) which immediately
|
|
414
|
-
* delegates here.
|
|
415
|
-
*/
|
|
416
|
-
export async function runFromStdin() {
|
|
417
|
-
let raw = '';
|
|
418
|
-
try {
|
|
419
|
-
process.stdin.setEncoding('utf8');
|
|
420
|
-
for await (const chunk of process.stdin) raw += chunk;
|
|
421
|
-
} catch {
|
|
422
|
-
return; // No stdin => nothing to do.
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
let event;
|
|
426
|
-
try {
|
|
427
|
-
event = raw.length > 0 ? JSON.parse(raw) : null;
|
|
428
|
-
} catch {
|
|
429
|
-
return; // Malformed JSON => silent no-op (never block the tool).
|
|
430
|
-
}
|
|
431
|
-
if (!event) return;
|
|
432
|
-
|
|
433
|
-
await main(event);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Auto-run when invoked directly: `node tool-trace-hook.js`.
|
|
437
|
-
const isDirect = (() => {
|
|
438
|
-
try {
|
|
439
|
-
const argv1 = process.argv[1] ?? '';
|
|
440
|
-
return argv1.endsWith('tool-trace-hook.js');
|
|
441
|
-
} catch {
|
|
442
|
-
return false;
|
|
443
|
-
}
|
|
444
|
-
})();
|
|
445
|
-
if (isDirect) {
|
|
446
|
-
runFromStdin();
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Test-only: clear the in-flight Map. Module-level state survives
|
|
451
|
-
* across tests within the same Node process; the unit suite calls this
|
|
452
|
-
* from `beforeEach` to keep tests independent.
|
|
453
|
-
*/
|
|
454
|
-
export function _resetInflightForTests() {
|
|
455
|
-
inflight.clear();
|
|
456
|
-
}
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* acceptance-clusters.js — Epic #4475 (M4-B), design §S2a.
|
|
3
|
-
*
|
|
4
|
-
* The **acceptance-dilution guard** for single delivery. In the fan-out
|
|
5
|
-
* shape, each Story ran its own fresh-context acceptance self-eval critic, so
|
|
6
|
-
* a 14-AC Epic decomposed into 4 Stories got ~4 independent critic passes for
|
|
7
|
-
* free. Single delivery collapses the whole Epic into ONE guarded session — if
|
|
8
|
-
* we ran a single critic over all 14 ACs it would degrade to two redraft
|
|
9
|
-
* rounds scoring everything at once (the exact "acceptance dilution" risk the
|
|
10
|
-
* adopted design calls out as blocking).
|
|
11
|
-
*
|
|
12
|
-
* This module restores the distributed coverage: the Epic's
|
|
13
|
-
* `## Acceptance Table` AC ids are split into clusters of at most
|
|
14
|
-
* `clusterCeiling` (config `delivery.acceptanceEval.clusterCeiling`, default 4,
|
|
15
|
-
* hard-clamped to `[1, 8]`), and the executor spawns ONE maker-blind
|
|
16
|
-
* fresh-context `Agent` critic per cluster. The cluster count is therefore
|
|
17
|
-
* `ceil(totalACs / clusterCeiling)` — a deterministic fan-out width the
|
|
18
|
-
* consuming executor and its isolation test both key off.
|
|
19
|
-
*
|
|
20
|
-
* Pure and total — inputs in, clusters out. No I/O, no throws on malformed
|
|
21
|
-
* input (a non-array / empty AC set yields zero clusters — the executor then
|
|
22
|
-
* has nothing to critique, which the front gate already forecloses by refusing
|
|
23
|
-
* `acceptance::n-a`).
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { getAcceptanceEval } from '../config/acceptance-eval.js';
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* @typedef {object} AcceptanceCluster
|
|
30
|
-
* @property {number} clusterIndex Zero-based position in the fan-out.
|
|
31
|
-
* @property {string} clusterId Stable id (`ac-cluster-<n>`, 1-based).
|
|
32
|
-
* @property {string[]} acIds The AC ids this critic scores (≤ ceiling).
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Split an ordered list of AC ids into fresh-context critic clusters of at
|
|
37
|
-
* most `ceiling` ids each. The count is exactly `ceil(acIds.length / ceiling)`
|
|
38
|
-
* — the load-bearing fan-out-width invariant the isolation test pins.
|
|
39
|
-
*
|
|
40
|
-
* The input order is preserved (the executor orders ACs by their associated
|
|
41
|
-
* Delivery-Slicing slice before calling this, so same-slice ACs land adjacent
|
|
42
|
-
* and tend to share a cluster), but ordering is a nicety — the count guarantee
|
|
43
|
-
* is what forecloses dilution.
|
|
44
|
-
*
|
|
45
|
-
* @param {string[]} acIds Ordered AC ids (e.g. `['AC-1', 'AC-2', …]`).
|
|
46
|
-
* @param {number} [ceiling=4] Max ACs per cluster. A non-positive / non-
|
|
47
|
-
* integer ceiling degrades to 1 (one AC per
|
|
48
|
-
* cluster — the maximally-distributed, never-
|
|
49
|
-
* collapsed fan-out).
|
|
50
|
-
* @returns {AcceptanceCluster[]} `ceil(n / ceiling)` clusters; `[]` when there
|
|
51
|
-
* are no AC ids.
|
|
52
|
-
*/
|
|
53
|
-
export function clusterAcceptanceCriteria(acIds, ceiling = 4) {
|
|
54
|
-
const ids = Array.isArray(acIds)
|
|
55
|
-
? acIds.filter((id) => typeof id === 'string' && id.length > 0)
|
|
56
|
-
: [];
|
|
57
|
-
if (ids.length === 0) return [];
|
|
58
|
-
|
|
59
|
-
const size =
|
|
60
|
-
typeof ceiling === 'number' && Number.isInteger(ceiling) && ceiling >= 1
|
|
61
|
-
? ceiling
|
|
62
|
-
: 1;
|
|
63
|
-
|
|
64
|
-
const clusters = [];
|
|
65
|
-
for (let start = 0; start < ids.length; start += size) {
|
|
66
|
-
const clusterIndex = clusters.length;
|
|
67
|
-
clusters.push({
|
|
68
|
-
clusterIndex,
|
|
69
|
-
clusterId: `ac-cluster-${clusterIndex + 1}`,
|
|
70
|
-
acIds: ids.slice(start, start + size),
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
return clusters;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* The expected fan-out width for `total` ACs at `ceiling` — the number of
|
|
78
|
-
* independent maker-blind critic passes the single-delivery executor spawns.
|
|
79
|
-
* Exported so the executor (and its isolation test) can assert the number of
|
|
80
|
-
* `Agent` critic spawns equals this without re-deriving the ceil math.
|
|
81
|
-
*
|
|
82
|
-
* @param {number} total Total AC count.
|
|
83
|
-
* @param {number} ceiling Max ACs per cluster (≥ 1; degrades to 1 otherwise).
|
|
84
|
-
* @returns {number} `ceil(total / ceiling)`; `0` when `total <= 0`.
|
|
85
|
-
*/
|
|
86
|
-
export function expectedClusterCount(total, ceiling) {
|
|
87
|
-
const n = Number.isInteger(total) && total > 0 ? total : 0;
|
|
88
|
-
if (n === 0) return 0;
|
|
89
|
-
const size =
|
|
90
|
-
typeof ceiling === 'number' && Number.isInteger(ceiling) && ceiling >= 1
|
|
91
|
-
? ceiling
|
|
92
|
-
: 1;
|
|
93
|
-
return Math.ceil(n / size);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Convenience wrapper: resolve the effective `clusterCeiling` from config
|
|
98
|
-
* (applying the framework default + the undisableable `[1, 8]` clamp) and
|
|
99
|
-
* cluster `acIds` with it. The single home the executor's `slice-phase`
|
|
100
|
-
* substrate and any future CLI use so the clamp is applied once.
|
|
101
|
-
*
|
|
102
|
-
* @param {string[]} acIds
|
|
103
|
-
* @param {object | null | undefined} config Resolved `.agentrc.json`.
|
|
104
|
-
* @returns {{ clusters: AcceptanceCluster[], clusterCeiling: number, totalAcs: number }}
|
|
105
|
-
*/
|
|
106
|
-
export function clusterAcceptanceForConfig(acIds, config) {
|
|
107
|
-
const { clusterCeiling } = getAcceptanceEval(config);
|
|
108
|
-
const clusters = clusterAcceptanceCriteria(acIds, clusterCeiling);
|
|
109
|
-
const ids = Array.isArray(acIds) ? acIds.filter(Boolean) : [];
|
|
110
|
-
return { clusters, clusterCeiling, totalAcs: ids.length };
|
|
111
|
-
}
|