@codedrifters/configulator 0.0.404 → 0.0.405

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/lib/index.js CHANGED
@@ -213,6 +213,7 @@ __export(index_exports, {
213
213
  CDK_WATCH_DEFAULTS_BY_STAGE: () => CDK_WATCH_DEFAULTS_BY_STAGE,
214
214
  CLAUDE_RULE_TARGET: () => CLAUDE_RULE_TARGET,
215
215
  COMPLETE_JOB_ID: () => COMPLETE_JOB_ID,
216
+ CONVENTIONAL_COMMIT_TYPE_LABELS: () => CONVENTIONAL_COMMIT_TYPE_LABELS,
216
217
  CdkCli: () => CdkCli,
217
218
  DEFAULT_AC_THRESHOLDS: () => DEFAULT_AC_THRESHOLDS,
218
219
  DEFAULT_AGENT_PATHS: () => DEFAULT_AGENT_PATHS,
@@ -284,6 +285,7 @@ __export(index_exports, {
284
285
  MONOREPO_LAYOUT: () => MONOREPO_LAYOUT,
285
286
  MonorepoProject: () => MonorepoProject,
286
287
  Nvmrc: () => Nvmrc,
288
+ PHASE_LABEL_TYPE_MAP: () => PHASE_LABEL_TYPE_MAP,
287
289
  PROD_DEPLOY_NAME: () => PROD_DEPLOY_NAME,
288
290
  PROGRESS_FILES_FORMAT_VALUES: () => PROGRESS_FILES_FORMAT_VALUES,
289
291
  PnpmWorkspace: () => PnpmWorkspace,
@@ -444,6 +446,8 @@ __export(index_exports, {
444
446
  renderIssueTemplatesStarterPage: () => renderIssueTemplatesStarterPage,
445
447
  renderMeetingTypesSection: () => renderMeetingTypesSection,
446
448
  renderNextRequirementIdProcedure: () => renderNextRequirementIdProcedure,
449
+ renderPhaseTypeInvariantSection: () => renderPhaseTypeInvariantSection,
450
+ renderPhaseTypeInvariantShellHelpers: () => renderPhaseTypeInvariantShellHelpers,
447
451
  renderPriorityRulesSection: () => renderPriorityRulesSection,
448
452
  renderProgressFileName: () => renderProgressFileName,
449
453
  renderProgressFilePath: () => renderProgressFilePath,
@@ -495,6 +499,7 @@ __export(index_exports, {
495
499
  resolveSkillEvals: () => resolveSkillEvals,
496
500
  resolveTemplateVariables: () => resolveTemplateVariables,
497
501
  resolveTemporalFraming: () => resolveTemporalFraming,
502
+ resolveTypeLabelForLabels: () => resolveTypeLabelForLabels,
498
503
  resolveTypeScriptProjectOutdir: () => resolveTypeScriptProjectOutdir,
499
504
  resolveUnblockDependents: () => resolveUnblockDependents,
500
505
  runScan: () => runScan,
@@ -504,6 +509,7 @@ __export(index_exports, {
504
509
  stripToolArtifactTagsProcedure: () => stripToolArtifactTagsProcedure,
505
510
  tsdocRecordToFindings: () => tsdocRecordToFindings,
506
511
  turborepoBundle: () => turborepoBundle,
512
+ typeLabelForPhaseLabel: () => typeLabelForPhaseLabel,
507
513
  typescriptBundle: () => typescriptBundle,
508
514
  upstreamConfigulatorDocsBundle: () => upstreamConfigulatorDocsBundle,
509
515
  validateAgentTierConfig: () => validateAgentTierConfig,
@@ -13646,188 +13652,6 @@ function buildMeetingAnalysisBundle(tier = AGENT_MODEL.BALANCED) {
13646
13652
  }
13647
13653
  var meetingAnalysisBundle = buildMeetingAnalysisBundle();
13648
13654
 
13649
- // src/agent/bundles/run-ratio.ts
13650
- var DEFAULT_DISPATCH_TO_HOUSEKEEPING_RATIO = 4;
13651
- var DEFAULT_STATE_FILE_PATH = ".state/orchestrator-runs.json";
13652
- var DEFAULT_DISPATCH_MODEL = "opus";
13653
- var DEFAULT_HOUSEKEEPING_MODEL = "sonnet";
13654
- function resolveRunRatio(config) {
13655
- const ratio = config?.ratio ?? DEFAULT_DISPATCH_TO_HOUSEKEEPING_RATIO;
13656
- assertValidRatio(ratio);
13657
- const stateFilePath = config?.stateFilePath ?? DEFAULT_STATE_FILE_PATH;
13658
- assertValidStateFilePath(stateFilePath);
13659
- return {
13660
- enabled: config?.enabled ?? true,
13661
- ratio,
13662
- stateFilePath,
13663
- dispatchModel: config?.dispatchModel ?? DEFAULT_DISPATCH_MODEL,
13664
- housekeepingModel: config?.housekeepingModel ?? DEFAULT_HOUSEKEEPING_MODEL
13665
- };
13666
- }
13667
- function validateRunRatioConfig(config) {
13668
- return resolveRunRatio(config);
13669
- }
13670
- function classifyRun(runCounter, ratio) {
13671
- if (!ratio.enabled) {
13672
- return "dispatch";
13673
- }
13674
- const cycle = ratio.ratio + 1;
13675
- return runCounter > 0 && runCounter % cycle === 0 ? "housekeeping" : "dispatch";
13676
- }
13677
- function renderRunRatioSection(ratio) {
13678
- const lines = [
13679
- "## Run ratio (dispatch vs housekeeping)",
13680
- "",
13681
- "The orchestrator keeps a **persistent run counter** and interleaves",
13682
- "dispatch runs (pick the next ready issue, recommend a worker) with",
13683
- "**housekeeping runs** (batch PR review + maintenance scan) on a",
13684
- "configurable ratio. This mirrors openhi's `DISPATCHER.md` contract:",
13685
- "multiple dispatch runs feed the worker queue, then one batched",
13686
- "housekeeping run flushes review backlog and runs maintenance",
13687
- "triage so the pipeline never drifts.",
13688
- ""
13689
- ];
13690
- if (!ratio.enabled) {
13691
- lines.push(
13692
- "**The run ratio is disabled for this project.** Every orchestrator",
13693
- "run executes the full dispatch pipeline; PR review and maintenance",
13694
- "remain manual invocations. Enable the ratio via",
13695
- "`AgentConfigOptions.runRatio.enabled = true` once the operator is",
13696
- "comfortable with the counter-backed cadence.",
13697
- ""
13698
- );
13699
- return lines.join("\n");
13700
- }
13701
- const cycle = ratio.ratio + 1;
13702
- lines.push(
13703
- "### Cadence",
13704
- "",
13705
- `The cycle length is **${cycle}** runs:`,
13706
- "",
13707
- `- Runs 1 through ${ratio.ratio} execute the **dispatch** pipeline`,
13708
- ` (recommended model: \`${ratio.dispatchModel}\`).`,
13709
- `- Run ${cycle} executes the **housekeeping** pipeline`,
13710
- ` (recommended model: \`${ratio.housekeepingModel}\`).`,
13711
- `- The counter wraps \u2014 run ${cycle + 1} is a dispatch run again, run ${cycle * 2} is the next housekeeping run, and so on.`,
13712
- "",
13713
- "The orchestrator increments the counter **once per invocation** at",
13714
- "the top of the run, before any pipeline phase executes. The",
13715
- "pre-increment value is never observed \u2014 the tick always returns",
13716
- "the post-increment counter and the classified run type in a single",
13717
- "atomic update.",
13718
- "",
13719
- "### State file",
13720
- "",
13721
- `The run counter persists at \`${ratio.stateFilePath}\`. The file is`,
13722
- "plain JSON with a single `run_counter` integer field:",
13723
- "",
13724
- "```json",
13725
- '{ "run_counter": 42 }',
13726
- "```",
13727
- "",
13728
- "The state file is **gitignored** in consumer repos (it is local to",
13729
- "each operator's machine). On a first run, or if the file is missing",
13730
- "or corrupt, the orchestrator recreates it with `run_counter: 1`.",
13731
- "",
13732
- "### Dispatch-run pipeline",
13733
- "",
13734
- "1. Phase A \u2014 startup (fetch + checkout default branch).",
13735
- "2. Phase C \u2014 triage unblock (resolve `Depends on:` chains).",
13736
- "3. Phase E \u2014 queue scan (pick the top `PICK` line, run the scope",
13737
- " gate, emit `NEXT_WORK_ITEM`).",
13738
- "4. Phase F \u2014 cleanup.",
13739
- "",
13740
- "### Housekeeping-run pipeline",
13741
- "",
13742
- "1. Phase A \u2014 startup.",
13743
- "2. Phase B \u2014 batch PR review across every eligible open PR.",
13744
- "3. Phase D \u2014 maintenance scan (stale detection, orphaned branches,",
13745
- " needs-attention summary).",
13746
- "4. Phase F \u2014 cleanup.",
13747
- "",
13748
- "### Model recommendations",
13749
- "",
13750
- `Dispatch runs should use \`${ratio.dispatchModel}\` \u2014 the routing`,
13751
- "logic, scope gate, and funnel-tier sort benefit from the stronger",
13752
- "reasoning model. Housekeeping runs are mechanical (read CI status,",
13753
- "toggle labels, post canned comments) and should use",
13754
- `\`${ratio.housekeepingModel}\` so the batched PR review and`,
13755
- "maintenance scan cost less per invocation.",
13756
- "",
13757
- "These strings are **informational** \u2014 they surface in the",
13758
- "orchestrator's rendered rule content so operators know which model",
13759
- "to run each session against. Configulator does not inject them as",
13760
- "`model:` frontmatter on the sub-agent definition; the operator (or",
13761
- "a scheduled task) picks the model at invocation time."
13762
- );
13763
- return lines.join("\n");
13764
- }
13765
- function renderRunRatioShellHelpers(ratio) {
13766
- const cycle = ratio.ratio + 1;
13767
- return [
13768
- "# Increment the orchestrator run counter and classify the run.",
13769
- "# Reads the state file (creating it on first run or corruption),",
13770
- "# increments the counter, writes back atomically, and echoes",
13771
- "# `run=<n> type=<dispatch|housekeeping>` on stdout.",
13772
- "#",
13773
- "# Uses the cycle length (ratio + 1) hard-coded from the resolved",
13774
- "# RunRatioConfig so the shell helper matches the rendered rule",
13775
- "# content byte-for-byte.",
13776
- "run_counter_tick() {",
13777
- ' local state_file="$ORCHESTRATOR_STATE_FILE"',
13778
- " local state_dir",
13779
- ' state_dir=$(dirname "$state_file")',
13780
- ' mkdir -p "$state_dir" 2>/dev/null || true',
13781
- "",
13782
- " local current=0",
13783
- ' if [ -f "$state_file" ]; then',
13784
- " # jq returns empty string on parse failure; guard against it.",
13785
- ` current=$(jq -r '.run_counter // 0' "$state_file" 2>/dev/null || echo 0)`,
13786
- ' case "$current" in',
13787
- " ''|*[!0-9]*) current=0 ;;",
13788
- " esac",
13789
- " fi",
13790
- "",
13791
- " local next=$((current + 1))",
13792
- "",
13793
- ' local tmp_file="${state_file}.tmp.$$"',
13794
- ` printf '{ "run_counter": %d }\\n' "$next" > "$tmp_file"`,
13795
- ' mv "$tmp_file" "$state_file"',
13796
- "",
13797
- " local run_type=dispatch",
13798
- ` if [ $((next % ${cycle})) -eq 0 ]; then`,
13799
- " run_type=housekeeping",
13800
- " fi",
13801
- ` printf 'run=%d type=%s\\n' "$next" "$run_type"`,
13802
- "}"
13803
- ].join("\n");
13804
- }
13805
- function assertValidRatio(ratio) {
13806
- if (!Number.isInteger(ratio)) {
13807
- throw new Error(
13808
- `RunRatioConfig.ratio must be a positive integer; got ${ratio}`
13809
- );
13810
- }
13811
- if (ratio < 1) {
13812
- throw new Error(
13813
- `RunRatioConfig.ratio must be a positive integer; got ${ratio}`
13814
- );
13815
- }
13816
- }
13817
- function assertValidStateFilePath(stateFilePath) {
13818
- const trimmed = stateFilePath.trim();
13819
- if (trimmed.length === 0) {
13820
- throw new Error(
13821
- "RunRatioConfig.stateFilePath must be a non-empty string relative to the repo root"
13822
- );
13823
- }
13824
- if (trimmed.startsWith("/")) {
13825
- throw new Error(
13826
- `RunRatioConfig.stateFilePath must be relative to the repo root (no leading '/'); got ${stateFilePath}`
13827
- );
13828
- }
13829
- }
13830
-
13831
13655
  // src/agent/bundles/bundle-ownership.ts
13832
13656
  var BUNDLE_OWNERSHIP = {
13833
13657
  agenda: {
@@ -13971,6 +13795,255 @@ var BUNDLE_OWNERSHIP = {
13971
13795
  downstreamIssueKinds: true
13972
13796
  }
13973
13797
  };
13798
+ var CONVENTIONAL_COMMIT_TYPE_LABELS = [
13799
+ "type:chore",
13800
+ "type:docs",
13801
+ "type:feat",
13802
+ "type:fix",
13803
+ "type:hotfix",
13804
+ "type:refactor",
13805
+ "type:release"
13806
+ ];
13807
+ var PHASE_LABEL_TYPE_MAP = buildPhaseLabelTypeMap();
13808
+ function typeLabelForPhaseLabel(phaseLabel) {
13809
+ const exact = PHASE_LABEL_TYPE_MAP[phaseLabel];
13810
+ if (exact !== void 0 && !phaseLabel.endsWith(":")) {
13811
+ return exact;
13812
+ }
13813
+ let bestMatcher;
13814
+ for (const matcher of Object.keys(PHASE_LABEL_TYPE_MAP)) {
13815
+ if (!matcher.endsWith(":")) {
13816
+ continue;
13817
+ }
13818
+ if (!phaseLabel.startsWith(matcher)) {
13819
+ continue;
13820
+ }
13821
+ if (bestMatcher === void 0 || matcher.length > bestMatcher.length) {
13822
+ bestMatcher = matcher;
13823
+ }
13824
+ }
13825
+ return bestMatcher === void 0 ? void 0 : PHASE_LABEL_TYPE_MAP[bestMatcher];
13826
+ }
13827
+ function resolveTypeLabelForLabels(labels) {
13828
+ const phaseLabels = [];
13829
+ const candidates = /* @__PURE__ */ new Set();
13830
+ for (const label of labels) {
13831
+ const typeLabel = typeLabelForPhaseLabel(label);
13832
+ if (typeLabel === void 0) {
13833
+ continue;
13834
+ }
13835
+ phaseLabels.push(label);
13836
+ candidates.add(typeLabel);
13837
+ }
13838
+ const candidateTypeLabels = Array.from(candidates).sort();
13839
+ if (candidateTypeLabels.length === 0) {
13840
+ return { outcome: "none", candidateTypeLabels: [], phaseLabels: [] };
13841
+ }
13842
+ if (candidateTypeLabels.length === 1) {
13843
+ return {
13844
+ outcome: "match",
13845
+ typeLabel: candidateTypeLabels[0],
13846
+ candidateTypeLabels,
13847
+ phaseLabels
13848
+ };
13849
+ }
13850
+ return { outcome: "ambiguous", candidateTypeLabels, phaseLabels };
13851
+ }
13852
+ function renderPhaseTypeInvariantSection(excludeBundles = []) {
13853
+ const rows = Object.keys(PHASE_LABEL_TYPE_MAP).filter(
13854
+ (matcher) => !isPhaseLabelMatcherOwnedByExcluded(matcher, excludeBundles)
13855
+ ).sort().map((matcher) => {
13856
+ const display = matcher.endsWith(":") ? `${matcher}*` : matcher;
13857
+ const kind = matcher.endsWith(":") ? "prefix" : "exact";
13858
+ return `| \`${display}\` | ${kind} | \`${PHASE_LABEL_TYPE_MAP[matcher]}\` |`;
13859
+ });
13860
+ return [
13861
+ "## Phase-label \u2192 `type:<bundle>` invariant",
13862
+ "",
13863
+ "Every phased-pipeline bundle pairs its `<bundle>:<phase>` labels",
13864
+ "with exactly one `type:<bundle>` label. That type label is the",
13865
+ "**dedup + dispatch signal**: agents de-duplicate downstream work",
13866
+ 'with `gh issue list --label "type:<bundle>"`, and Phase E derives',
13867
+ "its funnel-tier sort key from the issue's `type:*` label. An issue",
13868
+ "that carries the phase label but a conventional-commit `type:*`",
13869
+ "(`type:feat`, `type:docs`, \u2026, stamped from its title prefix by the",
13870
+ "generic create-issue workflow) is invisible to that dedup query and",
13871
+ "mis-tiers in dispatch.",
13872
+ "",
13873
+ "The pairing below is derived from the canonical bundle-ownership",
13874
+ "map \u2014 the same source of truth that generates the label registry.",
13875
+ "Matchers ending in `*` match by prefix; the rest match exactly, and",
13876
+ "an exact match always beats a prefix match.",
13877
+ "",
13878
+ "| Phase label | Match | Required type label |",
13879
+ "|-------------|-------|---------------------|",
13880
+ ...rows,
13881
+ "",
13882
+ "### Enforcement",
13883
+ "",
13884
+ "The Phase D maintenance sweep runs the invariant in auto-correct",
13885
+ "mode and emits one summary line:",
13886
+ "",
13887
+ "```",
13888
+ "LABEL_INVARIANT_DONE mode=fix checked=<N> ok=<O> violations=<V> corrected=<C> flagged=<F>",
13889
+ "```",
13890
+ "",
13891
+ "A **report-only** audit is available for repos that prefer",
13892
+ "report-then-fix over auto-correct \u2014 same sweep, no mutations:",
13893
+ "",
13894
+ "```bash",
13895
+ ".claude/procedures/check-blocked.sh label-invariant # report only",
13896
+ ".claude/procedures/check-blocked.sh label-invariant --fix # apply",
13897
+ "```",
13898
+ "",
13899
+ "### Correction policy",
13900
+ "",
13901
+ "An issue must carry **exactly one** `type:*` label. Merely *adding*",
13902
+ "the required type label to an issue that already carries a",
13903
+ "conventional-commit one would leave two, making the funnel-tier sort",
13904
+ "key (which reads the **first** `type:*` label) non-deterministic and",
13905
+ "violating the label conventions. The correction therefore",
13906
+ "**replaces**, in a single atomic `gh issue edit`, so the issue is",
13907
+ "never observable carrying two `type:*` labels:",
13908
+ "",
13909
+ "| Issue state | Action |",
13910
+ "|-------------|--------|",
13911
+ "| Required type label present, nothing else | none \u2014 compliant, left untouched |",
13912
+ "| No `type:*` label at all | **add** the required label |",
13913
+ "| Only conventional-commit `type:*` label(s) | **replace** \u2014 remove them, add the required label |",
13914
+ "| Required label present **plus** a stray conventional-commit one | **remove** the stray, leaving exactly one |",
13915
+ "| A `type:*` label owned by a **different** bundle | **flag** `status:needs-attention` \u2014 never guessed at |",
13916
+ "| Phase labels imply **two or more** type labels | **flag** `status:needs-attention` \u2014 never auto-corrected |",
13917
+ "",
13918
+ "Only a conventional-commit `type:*` label is ever removed. A",
13919
+ "`type:*` label owned by another bundle carries real routing",
13920
+ "information, so the sweep refuses to guess and hands the issue to a",
13921
+ "human instead. Ambiguity is narrower than it looks: all three",
13922
+ "requirements bundles declare `type:requirement`, so every `req:*`",
13923
+ "phase label resolves to the same type label \u2014 genuine ambiguity",
13924
+ "needs phase labels from two bundles with *different* type labels.",
13925
+ "",
13926
+ "`status:needs-attention` is applied **additively** \u2014 the base",
13927
+ "`status:*` label always stays, per the additive-flag rule. Flagging",
13928
+ "is idempotent: an issue already carrying the flag is reported but",
13929
+ "not re-flagged."
13930
+ ].join("\n");
13931
+ }
13932
+ function isPhaseLabelMatcherOwnedByExcluded(matcher, excludeBundles) {
13933
+ if (excludeBundles.length === 0) {
13934
+ return false;
13935
+ }
13936
+ let owned = false;
13937
+ for (const [bundleName, ownership] of Object.entries(BUNDLE_OWNERSHIP)) {
13938
+ if (!ownership.phaseLabelPrefixes.includes(matcher)) {
13939
+ continue;
13940
+ }
13941
+ owned = true;
13942
+ if (!excludeBundles.includes(bundleName)) {
13943
+ return false;
13944
+ }
13945
+ }
13946
+ return owned;
13947
+ }
13948
+ function renderPhaseTypeInvariantShellHelpers() {
13949
+ const matchers = Object.keys(PHASE_LABEL_TYPE_MAP);
13950
+ const exactMatchers = matchers.filter((m) => !m.endsWith(":")).sort();
13951
+ const prefixMatchers = matchers.filter((m) => m.endsWith(":")).sort((a, b) => b.length - a.length || a.localeCompare(b));
13952
+ const lines = [
13953
+ "# Resolve ONE phase label to the `type:<bundle>` label its owning",
13954
+ "# bundle declares. Echoes the label (with the `type:` prefix) or",
13955
+ "# nothing when no bundle owns it. Exact-match branches come first,",
13956
+ "# so `req:write` (requirements-writer) beats the `req:` prefix",
13957
+ "# (requirements-analyst). Generated from BUNDLE_OWNERSHIP \u2014 do not",
13958
+ "# hand-edit.",
13959
+ "phase_label_type_of() {",
13960
+ ' case "${1:-}" in'
13961
+ ];
13962
+ for (const matcher of exactMatchers) {
13963
+ lines.push(` ${matcher}) echo "${PHASE_LABEL_TYPE_MAP[matcher]}" ;;`);
13964
+ }
13965
+ for (const matcher of prefixMatchers) {
13966
+ lines.push(` ${matcher}*) echo "${PHASE_LABEL_TYPE_MAP[matcher]}" ;;`);
13967
+ }
13968
+ lines.push(
13969
+ " *) : ;;",
13970
+ " esac",
13971
+ "}",
13972
+ "",
13973
+ "# Return 0 when the argument is a conventional-commit `type:*`",
13974
+ "# label \u2014 the ONLY type labels the invariant correction may remove.",
13975
+ "# A `type:*` label owned by another bundle is never removed; the",
13976
+ "# issue is flagged for human triage instead.",
13977
+ "is_conventional_type_label() {",
13978
+ ` case "\${1:-}" in`,
13979
+ ` ${CONVENTIONAL_COMMIT_TYPE_LABELS.join("|")}) return 0 ;;`,
13980
+ " *) return 1 ;;",
13981
+ " esac",
13982
+ "}",
13983
+ "",
13984
+ "# Resolve an issue's FULL label list (one label per line on stdin)",
13985
+ "# to the type label the phase-label invariant requires. Emits",
13986
+ "# KEY=VALUE assignments the caller parses:",
13987
+ "# OUTCOME=none \u2014 no recognised phase label; invariant N/A",
13988
+ "# OUTCOME=match \u2014 plus TYPE_LABEL=<type:bundle>",
13989
+ "# OUTCOME=ambiguous \u2014 plus CANDIDATE_TYPE_LABELS=<space-separated>",
13990
+ "# PHASE_LABELS=<space-separated recognised phase labels>",
13991
+ "phase_type_of() {",
13992
+ " local label resolved",
13993
+ ' local matched_types=""',
13994
+ ' local matched_phase=""',
13995
+ " while IFS= read -r label; do",
13996
+ ' [ -z "$label" ] && continue',
13997
+ ' resolved=$(phase_label_type_of "$label")',
13998
+ ' [ -z "$resolved" ] && continue',
13999
+ ' matched_phase="${matched_phase}${label} "',
14000
+ ' case " ${matched_types} " in',
14001
+ ' *" ${resolved} "*) ;;',
14002
+ ' *) matched_types="${matched_types}${resolved} " ;;',
14003
+ " esac",
14004
+ " done",
14005
+ " local count=0",
14006
+ " for resolved in $matched_types; do",
14007
+ " count=$((count + 1))",
14008
+ " done",
14009
+ ' if [ "$count" -eq 0 ]; then',
14010
+ ' echo "OUTCOME=none"',
14011
+ ' elif [ "$count" -eq 1 ]; then',
14012
+ ' echo "OUTCOME=match"',
14013
+ ' echo "TYPE_LABEL=${matched_types% }"',
14014
+ " else",
14015
+ ' echo "OUTCOME=ambiguous"',
14016
+ ' echo "CANDIDATE_TYPE_LABELS=${matched_types% }"',
14017
+ " fi",
14018
+ ' echo "PHASE_LABELS=${matched_phase% }"',
14019
+ "}"
14020
+ );
14021
+ return lines.join("\n");
14022
+ }
14023
+ function buildPhaseLabelTypeMap() {
14024
+ const map = {};
14025
+ for (const [bundleName, ownership] of Object.entries(BUNDLE_OWNERSHIP)) {
14026
+ if (ownership.phaseLabelPrefixes.length === 0) {
14027
+ continue;
14028
+ }
14029
+ if (ownership.typeLabels.length !== 1) {
14030
+ throw new Error(
14031
+ `BUNDLE_OWNERSHIP["${bundleName}"] declares ${ownership.phaseLabelPrefixes.length} phase-label matcher(s) but ${ownership.typeLabels.length} type label(s); a phase label must pair with exactly one type label`
14032
+ );
14033
+ }
14034
+ const typeLabel = `type:${ownership.typeLabels[0]}`;
14035
+ for (const matcher of ownership.phaseLabelPrefixes) {
14036
+ const existing = map[matcher];
14037
+ if (existing !== void 0 && existing !== typeLabel) {
14038
+ throw new Error(
14039
+ `Phase-label matcher "${matcher}" resolves to both "${existing}" and "${typeLabel}"; a matcher must imply exactly one type label`
14040
+ );
14041
+ }
14042
+ map[matcher] = typeLabel;
14043
+ }
14044
+ }
14045
+ return map;
14046
+ }
13974
14047
  function isTypeLabelOwnedByExcluded(typeLabel, excludedBundles) {
13975
14048
  if (excludedBundles.length === 0) {
13976
14049
  return false;
@@ -14060,6 +14133,188 @@ function findOwnersOfTypeLabel(typeLabel) {
14060
14133
  return owners;
14061
14134
  }
14062
14135
 
14136
+ // src/agent/bundles/run-ratio.ts
14137
+ var DEFAULT_DISPATCH_TO_HOUSEKEEPING_RATIO = 4;
14138
+ var DEFAULT_STATE_FILE_PATH = ".state/orchestrator-runs.json";
14139
+ var DEFAULT_DISPATCH_MODEL = "opus";
14140
+ var DEFAULT_HOUSEKEEPING_MODEL = "sonnet";
14141
+ function resolveRunRatio(config) {
14142
+ const ratio = config?.ratio ?? DEFAULT_DISPATCH_TO_HOUSEKEEPING_RATIO;
14143
+ assertValidRatio(ratio);
14144
+ const stateFilePath = config?.stateFilePath ?? DEFAULT_STATE_FILE_PATH;
14145
+ assertValidStateFilePath(stateFilePath);
14146
+ return {
14147
+ enabled: config?.enabled ?? true,
14148
+ ratio,
14149
+ stateFilePath,
14150
+ dispatchModel: config?.dispatchModel ?? DEFAULT_DISPATCH_MODEL,
14151
+ housekeepingModel: config?.housekeepingModel ?? DEFAULT_HOUSEKEEPING_MODEL
14152
+ };
14153
+ }
14154
+ function validateRunRatioConfig(config) {
14155
+ return resolveRunRatio(config);
14156
+ }
14157
+ function classifyRun(runCounter, ratio) {
14158
+ if (!ratio.enabled) {
14159
+ return "dispatch";
14160
+ }
14161
+ const cycle = ratio.ratio + 1;
14162
+ return runCounter > 0 && runCounter % cycle === 0 ? "housekeeping" : "dispatch";
14163
+ }
14164
+ function renderRunRatioSection(ratio) {
14165
+ const lines = [
14166
+ "## Run ratio (dispatch vs housekeeping)",
14167
+ "",
14168
+ "The orchestrator keeps a **persistent run counter** and interleaves",
14169
+ "dispatch runs (pick the next ready issue, recommend a worker) with",
14170
+ "**housekeeping runs** (batch PR review + maintenance scan) on a",
14171
+ "configurable ratio. This mirrors openhi's `DISPATCHER.md` contract:",
14172
+ "multiple dispatch runs feed the worker queue, then one batched",
14173
+ "housekeeping run flushes review backlog and runs maintenance",
14174
+ "triage so the pipeline never drifts.",
14175
+ ""
14176
+ ];
14177
+ if (!ratio.enabled) {
14178
+ lines.push(
14179
+ "**The run ratio is disabled for this project.** Every orchestrator",
14180
+ "run executes the full dispatch pipeline; PR review and maintenance",
14181
+ "remain manual invocations. Enable the ratio via",
14182
+ "`AgentConfigOptions.runRatio.enabled = true` once the operator is",
14183
+ "comfortable with the counter-backed cadence.",
14184
+ ""
14185
+ );
14186
+ return lines.join("\n");
14187
+ }
14188
+ const cycle = ratio.ratio + 1;
14189
+ lines.push(
14190
+ "### Cadence",
14191
+ "",
14192
+ `The cycle length is **${cycle}** runs:`,
14193
+ "",
14194
+ `- Runs 1 through ${ratio.ratio} execute the **dispatch** pipeline`,
14195
+ ` (recommended model: \`${ratio.dispatchModel}\`).`,
14196
+ `- Run ${cycle} executes the **housekeeping** pipeline`,
14197
+ ` (recommended model: \`${ratio.housekeepingModel}\`).`,
14198
+ `- The counter wraps \u2014 run ${cycle + 1} is a dispatch run again, run ${cycle * 2} is the next housekeeping run, and so on.`,
14199
+ "",
14200
+ "The orchestrator increments the counter **once per invocation** at",
14201
+ "the top of the run, before any pipeline phase executes. The",
14202
+ "pre-increment value is never observed \u2014 the tick always returns",
14203
+ "the post-increment counter and the classified run type in a single",
14204
+ "atomic update.",
14205
+ "",
14206
+ "### State file",
14207
+ "",
14208
+ `The run counter persists at \`${ratio.stateFilePath}\`. The file is`,
14209
+ "plain JSON with a single `run_counter` integer field:",
14210
+ "",
14211
+ "```json",
14212
+ '{ "run_counter": 42 }',
14213
+ "```",
14214
+ "",
14215
+ "The state file is **gitignored** in consumer repos (it is local to",
14216
+ "each operator's machine). On a first run, or if the file is missing",
14217
+ "or corrupt, the orchestrator recreates it with `run_counter: 1`.",
14218
+ "",
14219
+ "### Dispatch-run pipeline",
14220
+ "",
14221
+ "1. Phase A \u2014 startup (fetch + checkout default branch).",
14222
+ "2. Phase C \u2014 triage unblock (resolve `Depends on:` chains).",
14223
+ "3. Phase E \u2014 queue scan (pick the top `PICK` line, run the scope",
14224
+ " gate, emit `NEXT_WORK_ITEM`).",
14225
+ "4. Phase F \u2014 cleanup.",
14226
+ "",
14227
+ "### Housekeeping-run pipeline",
14228
+ "",
14229
+ "1. Phase A \u2014 startup.",
14230
+ "2. Phase B \u2014 batch PR review across every eligible open PR.",
14231
+ "3. Phase D \u2014 maintenance scan (stale detection, orphaned branches,",
14232
+ " needs-attention summary).",
14233
+ "4. Phase F \u2014 cleanup.",
14234
+ "",
14235
+ "### Model recommendations",
14236
+ "",
14237
+ `Dispatch runs should use \`${ratio.dispatchModel}\` \u2014 the routing`,
14238
+ "logic, scope gate, and funnel-tier sort benefit from the stronger",
14239
+ "reasoning model. Housekeeping runs are mechanical (read CI status,",
14240
+ "toggle labels, post canned comments) and should use",
14241
+ `\`${ratio.housekeepingModel}\` so the batched PR review and`,
14242
+ "maintenance scan cost less per invocation.",
14243
+ "",
14244
+ "These strings are **informational** \u2014 they surface in the",
14245
+ "orchestrator's rendered rule content so operators know which model",
14246
+ "to run each session against. Configulator does not inject them as",
14247
+ "`model:` frontmatter on the sub-agent definition; the operator (or",
14248
+ "a scheduled task) picks the model at invocation time."
14249
+ );
14250
+ return lines.join("\n");
14251
+ }
14252
+ function renderRunRatioShellHelpers(ratio) {
14253
+ const cycle = ratio.ratio + 1;
14254
+ return [
14255
+ "# Increment the orchestrator run counter and classify the run.",
14256
+ "# Reads the state file (creating it on first run or corruption),",
14257
+ "# increments the counter, writes back atomically, and echoes",
14258
+ "# `run=<n> type=<dispatch|housekeeping>` on stdout.",
14259
+ "#",
14260
+ "# Uses the cycle length (ratio + 1) hard-coded from the resolved",
14261
+ "# RunRatioConfig so the shell helper matches the rendered rule",
14262
+ "# content byte-for-byte.",
14263
+ "run_counter_tick() {",
14264
+ ' local state_file="$ORCHESTRATOR_STATE_FILE"',
14265
+ " local state_dir",
14266
+ ' state_dir=$(dirname "$state_file")',
14267
+ ' mkdir -p "$state_dir" 2>/dev/null || true',
14268
+ "",
14269
+ " local current=0",
14270
+ ' if [ -f "$state_file" ]; then',
14271
+ " # jq returns empty string on parse failure; guard against it.",
14272
+ ` current=$(jq -r '.run_counter // 0' "$state_file" 2>/dev/null || echo 0)`,
14273
+ ' case "$current" in',
14274
+ " ''|*[!0-9]*) current=0 ;;",
14275
+ " esac",
14276
+ " fi",
14277
+ "",
14278
+ " local next=$((current + 1))",
14279
+ "",
14280
+ ' local tmp_file="${state_file}.tmp.$$"',
14281
+ ` printf '{ "run_counter": %d }\\n' "$next" > "$tmp_file"`,
14282
+ ' mv "$tmp_file" "$state_file"',
14283
+ "",
14284
+ " local run_type=dispatch",
14285
+ ` if [ $((next % ${cycle})) -eq 0 ]; then`,
14286
+ " run_type=housekeeping",
14287
+ " fi",
14288
+ ` printf 'run=%d type=%s\\n' "$next" "$run_type"`,
14289
+ "}"
14290
+ ].join("\n");
14291
+ }
14292
+ function assertValidRatio(ratio) {
14293
+ if (!Number.isInteger(ratio)) {
14294
+ throw new Error(
14295
+ `RunRatioConfig.ratio must be a positive integer; got ${ratio}`
14296
+ );
14297
+ }
14298
+ if (ratio < 1) {
14299
+ throw new Error(
14300
+ `RunRatioConfig.ratio must be a positive integer; got ${ratio}`
14301
+ );
14302
+ }
14303
+ }
14304
+ function assertValidStateFilePath(stateFilePath) {
14305
+ const trimmed = stateFilePath.trim();
14306
+ if (trimmed.length === 0) {
14307
+ throw new Error(
14308
+ "RunRatioConfig.stateFilePath must be a non-empty string relative to the repo root"
14309
+ );
14310
+ }
14311
+ if (trimmed.startsWith("/")) {
14312
+ throw new Error(
14313
+ `RunRatioConfig.stateFilePath must be relative to the repo root (no leading '/'); got ${stateFilePath}`
14314
+ );
14315
+ }
14316
+ }
14317
+
14063
14318
  // src/agent/bundles/scheduled-tasks.ts
14064
14319
  var SCHEDULED_TASK_MODEL_VALUES = ["opus", "sonnet", "haiku"];
14065
14320
  var SCHEDULED_TASK_KIND_VALUES = ["issue-worker", "pipeline"];
@@ -16015,6 +16270,7 @@ function buildCheckBlockedScript(tiers, scopeGate, _runRatio) {
16015
16270
  "# .claude/procedures/check-blocked.sh maintenance",
16016
16271
  "# .claude/procedures/check-blocked.sh prs",
16017
16272
  "# .claude/procedures/check-blocked.sh scope <issue-number>",
16273
+ "# .claude/procedures/check-blocked.sh label-invariant [--fix]",
16018
16274
  "",
16019
16275
  "set -uo pipefail",
16020
16276
  "",
@@ -16064,6 +16320,8 @@ function buildCheckBlockedScript(tiers, scopeGate, _runRatio) {
16064
16320
  "",
16065
16321
  scopeHelperIndented,
16066
16322
  "",
16323
+ renderPhaseTypeInvariantShellHelpers(),
16324
+ "",
16067
16325
  ...renderDelegationActiveSignalsHelper(),
16068
16326
  "",
16069
16327
  "# \u2500\u2500 subcommands \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
@@ -16699,16 +16957,43 @@ function buildCheckBlockedScript(tiers, scopeGate, _runRatio) {
16699
16957
  " esac",
16700
16958
  ' done <<< "$lease_output"',
16701
16959
  "",
16960
+ " # \u2500\u2500 phase-label \u2192 type:<bundle> invariant (MUTATING) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
16961
+ " # Run the label-invariant sweep in --fix mode and surface its",
16962
+ " # per-issue lines for log visibility. cmd_label_invariant applies",
16963
+ " # the label edits itself (a single atomic remove+add per issue, so",
16964
+ " # an issue is never observable carrying two type:* labels) and",
16965
+ " # flags the cases it must not guess at. Its terminal",
16966
+ " # LABEL_INVARIANT_DONE line is captured and re-emitted alongside",
16967
+ " # MAINTENANCE_DONE so the orchestrator reads every summary from",
16968
+ " # one maintenance invocation.",
16969
+ " local label_output",
16970
+ " label_output=$(cmd_label_invariant --fix)",
16971
+ ' local label_summary="LABEL_INVARIANT_DONE mode=fix checked=0 ok=0 violations=0 corrected=0 flagged=0"',
16972
+ "",
16973
+ " while IFS= read -r line; do",
16974
+ ' [[ -z "$line" ]] && continue',
16975
+ ' case "$line" in',
16976
+ " 'LABEL_INVARIANT_DONE '*)",
16977
+ ' label_summary="$line"',
16978
+ " ;;",
16979
+ " *)",
16980
+ ' echo "$line"',
16981
+ " ;;",
16982
+ " esac",
16983
+ ' done <<< "$label_output"',
16984
+ "",
16702
16985
  " # \u2500\u2500 needs-attention total \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
16703
16986
  " local needs_attention_total",
16704
16987
  ' needs_attention_total=$(gh issue list --label "status:needs-attention" --state open \\',
16705
16988
  " --json number --limit 100 2>/dev/null | jq 'length' 2>/dev/null || echo 0)",
16706
16989
  " needs_attention_total=${needs_attention_total:-0}",
16707
16990
  "",
16708
- " # Two summary lines consumed by the orchestrator: the issue/orphan",
16709
- " # MAINTENANCE_DONE line and the PR-lease LEASE_RECONCILE line.",
16991
+ " # Three summary lines consumed by the orchestrator: the",
16992
+ " # issue/orphan MAINTENANCE_DONE line, the PR-lease LEASE_RECONCILE",
16993
+ " # line, and the LABEL_INVARIANT_DONE line.",
16710
16994
  ' echo "MAINTENANCE_DONE flagged_stale=${flagged_stale_count} flagged_blocked=${flagged_blocked_count} orphan_branches=${orphan_branches_count} orphan_prs=${orphan_prs_count} needs_attention_total=${needs_attention_total}"',
16711
16995
  ' echo "$lease_summary"',
16996
+ ' echo "$label_summary"',
16712
16997
  "}",
16713
16998
  "",
16714
16999
  "cmd_prs() {",
@@ -16774,6 +17059,196 @@ function buildCheckBlockedScript(tiers, scopeGate, _runRatio) {
16774
17059
  ' done <<< "$eligible"',
16775
17060
  "}",
16776
17061
  "",
17062
+ "cmd_label_invariant() {",
17063
+ " # Phase-label \u2192 type:<bundle> invariant sweep.",
17064
+ " #",
17065
+ " # Every phased-pipeline bundle pairs its <bundle>:<phase> labels",
17066
+ " # with exactly one type:<bundle> label. An agent that reconstructs",
17067
+ " # a gh issue create call from prose can stamp a conventional-commit",
17068
+ " # type label derived from the title prefix instead, producing an",
17069
+ " # issue that carries the phase label but the WRONG type label \u2014",
17070
+ " # invisible to the --label type:<bundle> duplicate-check idiom and",
17071
+ " # mis-tiered by the funnel-tier sort in cmd_eligible.",
17072
+ " #",
17073
+ " # Default mode is REPORT-ONLY (the consumer-runnable audit); pass",
17074
+ " # --fix to apply corrections. cmd_maintenance runs the --fix mode",
17075
+ " # as part of the Phase D sweep.",
17076
+ " #",
17077
+ " # CORRECTION POLICY. An issue must carry EXACTLY ONE type:* label",
17078
+ " # (cmd_eligible derives its funnel-tier sort key from the FIRST",
17079
+ " # one, and the label conventions mandate exactly one), so the",
17080
+ " # correction REPLACES rather than merely adds:",
17081
+ " #",
17082
+ " # no type:* at all \u2192 add the implied type label",
17083
+ " # only conventional-commit \u2192 remove them, add the implied",
17084
+ " # type labels present label (single atomic edit)",
17085
+ " # implied label already there \u2192 remove the stray conventional",
17086
+ " # alongside a conventional one label, leaving exactly one",
17087
+ " # a type:* owned by ANOTHER \u2192 FLAG status:needs-attention;",
17088
+ " # bundle never guess which to drop",
17089
+ " # phase labels imply 2+ types \u2192 FLAG status:needs-attention",
17090
+ " #",
17091
+ " # Only a conventional-commit type label is ever removed \u2014 the set",
17092
+ " # is_conventional_type_label() recognises. A type:* label owned by",
17093
+ " # a different bundle is a genuine conflict a human must resolve.",
17094
+ " # status:needs-attention is applied ADDITIVELY; the base status:*",
17095
+ " # label always stays (see the additive-flag rule).",
17096
+ " local apply=0",
17097
+ ' if [[ "${1:-}" == "--fix" ]]; then',
17098
+ " apply=1",
17099
+ " fi",
17100
+ ' local mode="report"',
17101
+ ' [[ "$apply" -eq 1 ]] && mode="fix"',
17102
+ "",
17103
+ " local issues",
17104
+ " issues=$(gh issue list --state open --json number,labels \\",
17105
+ ' --limit 1000 2>/dev/null || echo "[]")',
17106
+ "",
17107
+ " # Filter jq-side and emit a TWO-field record. The label list is",
17108
+ " # last AND guaranteed non-empty by the select, so no field can",
17109
+ " # collapse under IFS=tab and shift the record left (#884). The",
17110
+ " # title is deliberately not threaded through \u2014 every output line",
17111
+ " # keys off the issue number.",
17112
+ " local issue_data",
17113
+ ` issue_data=$(echo "$issues" | jq -r '`,
17114
+ " .[] |",
17115
+ " (.labels | map(.name)) as $names |",
17116
+ " select($names | length > 0) |",
17117
+ ' "\\(.number)\\t\\($names | join(","))"',
17118
+ " ' 2>/dev/null)",
17119
+ "",
17120
+ " local checked_count=0",
17121
+ " local ok_count=0",
17122
+ " local corrected_count=0",
17123
+ " local flagged_count=0",
17124
+ "",
17125
+ " while IFS=$'\\t' read -r num labels_csv; do",
17126
+ ' [[ -z "$num" ]] && continue',
17127
+ "",
17128
+ " local labels_nl",
17129
+ ` labels_nl=$(printf '%s' "$labels_csv" | tr ',' '\\n')`,
17130
+ "",
17131
+ " local resolution assignment",
17132
+ " local outcome='' type_label='' phase_labels='' candidates=''",
17133
+ ` resolution=$(printf '%s\\n' "$labels_nl" | phase_type_of)`,
17134
+ " while IFS= read -r assignment; do",
17135
+ ' case "$assignment" in',
17136
+ " OUTCOME=*) outcome=${assignment#OUTCOME=} ;;",
17137
+ " TYPE_LABEL=*) type_label=${assignment#TYPE_LABEL=} ;;",
17138
+ " CANDIDATE_TYPE_LABELS=*) candidates=${assignment#CANDIDATE_TYPE_LABELS=} ;;",
17139
+ " PHASE_LABELS=*) phase_labels=${assignment#PHASE_LABELS=} ;;",
17140
+ " esac",
17141
+ ' done <<< "$resolution"',
17142
+ "",
17143
+ " # No recognised phase label \u2014 the invariant does not apply.",
17144
+ " # Consumer-specific `foo:bar` labels are deliberately not policed.",
17145
+ ' [[ "$outcome" == "none" ]] && continue',
17146
+ " checked_count=$((checked_count + 1))",
17147
+ "",
17148
+ " # Idempotent flagging: read the existing flag off the labels we",
17149
+ " # already fetched rather than spending another API call.",
17150
+ " local already_flagged=0",
17151
+ ' case ",${labels_csv}," in',
17152
+ ' *",status:needs-attention,"*) already_flagged=1 ;;',
17153
+ " esac",
17154
+ "",
17155
+ ' if [[ "$outcome" == "ambiguous" ]]; then',
17156
+ ' echo "LABEL_AMBIGUOUS #${num} phase=\\"${phase_labels}\\" candidates=\\"${candidates}\\""',
17157
+ " flagged_count=$((flagged_count + 1))",
17158
+ ' if [[ "$apply" -eq 1 && "$already_flagged" -eq 0 ]]; then',
17159
+ ' if gh issue edit "$num" --add-label "status:needs-attention" >/dev/null 2>&1; then',
17160
+ ' gh issue comment "$num" \\',
17161
+ ' --body "Label invariant: phase label(s) ${phase_labels} imply more than one bundle type label (${candidates}). Flagged for human triage \u2014 an ambiguous pairing is never auto-corrected." >/dev/null 2>&1 || true',
17162
+ ' echo "LABEL_FLAGGED #${num} \u2014 added status:needs-attention (ambiguous)"',
17163
+ " else",
17164
+ ' echo "LABEL_FLAG_FAILED #${num} \u2014 could not add status:needs-attention (ambiguous)"',
17165
+ " fi",
17166
+ " fi",
17167
+ " continue",
17168
+ " fi",
17169
+ "",
17170
+ " # Partition the issue's existing type:* labels against the",
17171
+ " # implied one.",
17172
+ " local label",
17173
+ " local has_expected=0",
17174
+ " local conv_types=''",
17175
+ " local foreign_types=''",
17176
+ " while IFS= read -r label; do",
17177
+ ' [ -z "$label" ] && continue',
17178
+ ' case "$label" in',
17179
+ " type:*) ;;",
17180
+ " *) continue ;;",
17181
+ " esac",
17182
+ ' if [ "$label" = "$type_label" ]; then',
17183
+ " has_expected=1",
17184
+ " continue",
17185
+ " fi",
17186
+ ' if is_conventional_type_label "$label"; then',
17187
+ ' conv_types="${conv_types}${label} "',
17188
+ " else",
17189
+ ' foreign_types="${foreign_types}${label} "',
17190
+ " fi",
17191
+ ' done <<< "$labels_nl"',
17192
+ "",
17193
+ ' if [[ "$has_expected" -eq 1 && -z "${conv_types}${foreign_types}" ]]; then',
17194
+ ' echo "LABEL_OK #${num} type=${type_label}"',
17195
+ " ok_count=$((ok_count + 1))",
17196
+ " continue",
17197
+ " fi",
17198
+ "",
17199
+ " # A type:* label owned by a DIFFERENT bundle is a conflict the",
17200
+ " # sweep must not guess at: removing it would destroy routing",
17201
+ " # information, and adding alongside it would leave two bundle",
17202
+ " # type labels and a non-deterministic funnel tier.",
17203
+ ' if [[ -n "$foreign_types" ]]; then',
17204
+ ' echo "LABEL_CONFLICT #${num} phase=\\"${phase_labels}\\" expected=${type_label} foreign=\\"${foreign_types% }\\""',
17205
+ " flagged_count=$((flagged_count + 1))",
17206
+ ' if [[ "$apply" -eq 1 && "$already_flagged" -eq 0 ]]; then',
17207
+ ' if gh issue edit "$num" --add-label "status:needs-attention" >/dev/null 2>&1; then',
17208
+ ' gh issue comment "$num" \\',
17209
+ ' --body "Label invariant: phase label(s) ${phase_labels} require ${type_label}, but this issue carries ${foreign_types% }, which is owned by another bundle. Flagged for human triage \u2014 the sweep only removes conventional-commit type labels." >/dev/null 2>&1 || true',
17210
+ ' echo "LABEL_FLAGGED #${num} \u2014 added status:needs-attention (conflicting bundle type)"',
17211
+ " else",
17212
+ ' echo "LABEL_FLAG_FAILED #${num} \u2014 could not add status:needs-attention (conflict)"',
17213
+ " fi",
17214
+ " fi",
17215
+ " continue",
17216
+ " fi",
17217
+ "",
17218
+ " # Correctable: add the implied label and/or drop the",
17219
+ " # conventional-commit label(s) shadowing it, in ONE edit so the",
17220
+ " # issue is never observable carrying two type:* labels.",
17221
+ ' local removing="${conv_types% }"',
17222
+ " local adding=''",
17223
+ ' [[ "$has_expected" -eq 0 ]] && adding="$type_label"',
17224
+ ' echo "LABEL_VIOLATION #${num} phase=\\"${phase_labels}\\" expected=${type_label} add=\\"${adding}\\" remove=\\"${removing}\\""',
17225
+ ' if [[ "$apply" -eq 0 ]]; then',
17226
+ " continue",
17227
+ " fi",
17228
+ "",
17229
+ " local -a edit_args=()",
17230
+ " local stray",
17231
+ " for stray in $removing; do",
17232
+ ' edit_args+=(--remove-label "$stray")',
17233
+ " done",
17234
+ ' [[ -n "$adding" ]] && edit_args+=(--add-label "$adding")',
17235
+ ' if [[ "${#edit_args[@]}" -eq 0 ]]; then',
17236
+ " continue",
17237
+ " fi",
17238
+ ' if gh issue edit "$num" "${edit_args[@]}" >/dev/null 2>&1; then',
17239
+ ' gh issue comment "$num" \\',
17240
+ ' --body "Label invariant: phase label(s) ${phase_labels} require ${type_label}. Corrected \u2014 added: ${adding:-(none)}; removed: ${removing:-(none)}." >/dev/null 2>&1 || true',
17241
+ ' echo "LABEL_CORRECTED #${num} added=\\"${adding}\\" removed=\\"${removing}\\""',
17242
+ " corrected_count=$((corrected_count + 1))",
17243
+ " else",
17244
+ ' echo "LABEL_CORRECT_FAILED #${num} \u2014 label edit failed"',
17245
+ " fi",
17246
+ ' done <<< "$issue_data"',
17247
+ "",
17248
+ " local violations_count=$((checked_count - ok_count))",
17249
+ ' echo "LABEL_INVARIANT_DONE mode=${mode} checked=${checked_count} ok=${ok_count} violations=${violations_count} corrected=${corrected_count} flagged=${flagged_count}"',
17250
+ "}",
17251
+ "",
16777
17252
  "cmd_scope() {",
16778
17253
  ' local issue_num="${1:-}"',
16779
17254
  ' if [[ -z "$issue_num" ]]; then',
@@ -16860,8 +17335,9 @@ function buildCheckBlockedScript(tiers, scopeGate, _runRatio) {
16860
17335
  ' maintenance) shift; cmd_maintenance "$@" ;;',
16861
17336
  ' prs) shift; cmd_prs "$@" ;;',
16862
17337
  ' scope) shift; cmd_scope "$@" ;;',
17338
+ ' label-invariant) shift; cmd_label_invariant "$@" ;;',
16863
17339
  " help|*)",
16864
- ' echo "Usage: check-blocked.sh <unblock|eligible|stale|orphaned|lease-reconcile|maintenance|prs|scope>"',
17340
+ ' echo "Usage: check-blocked.sh <unblock|eligible|stale|orphaned|lease-reconcile|maintenance|prs|scope|label-invariant>"',
16865
17341
  " exit 1",
16866
17342
  " ;;",
16867
17343
  "esac"
@@ -16870,7 +17346,7 @@ function buildCheckBlockedScript(tiers, scopeGate, _runRatio) {
16870
17346
  function buildCheckBlockedProcedure(tiers, scopeGate = resolveScopeGate(), runRatio = resolveRunRatio()) {
16871
17347
  return {
16872
17348
  name: "check-blocked.sh",
16873
- description: "Token-efficient issue triage script with subcommands: eligible, unblock, stale, orphaned, lease-reconcile, maintenance, prs, scope. Sorts eligible issues by priority desc \u2192 funnel tier asc \u2192 issue number asc, excluding issues carrying `status:needs-attention` or `status:deferred`; the scope subcommand classifies a single issue against the scope-gate thresholds; the unblock subcommand applies the `status:blocked` \u2192 `status:ready` label flip itself, posts the canned unblock comment, skips human-parked `status:deferred` candidates with a `SKIP_DEFERRED` line, and emits a single `TRIAGE_DONE unblocked=N still_blocked=M deferred_skipped=K` summary line; the lease-reconcile subcommand auto-reconciles stuck `review:fixing` PR leases \u2014 re-applying `review:needs-worker` on an orphaned lease (fix-list stale, no worker report) so the Phase B1 drain retries, and removing `review:needs-worker` on a consumed-but-uncleared wedge (branch HEAD advanced past the fix-list, marker still set, no worker report) so the reviewer confirm pass can merge, always posting an audit-trail note comment and emitting a single `LEASE_RECONCILE orphaned=N consumed_uncleared=M` summary line; the maintenance subcommand flags stale issues with `status:needs-attention` (never auto-resets to `status:ready`), counts orphan branches/PRs, folds in the lease-reconcile sweep, and emits a `MAINTENANCE_DONE flagged_stale=N flagged_blocked=M orphan_branches=A orphan_prs=B needs_attention_total=T` summary line followed by the `LEASE_RECONCILE` line.",
17349
+ description: "Token-efficient issue triage script with subcommands: eligible, unblock, stale, orphaned, lease-reconcile, maintenance, prs, scope, label-invariant. Sorts eligible issues by priority desc \u2192 funnel tier asc \u2192 issue number asc, excluding issues carrying `status:needs-attention` or `status:deferred`; the scope subcommand classifies a single issue against the scope-gate thresholds; the unblock subcommand applies the `status:blocked` \u2192 `status:ready` label flip itself, posts the canned unblock comment, skips human-parked `status:deferred` candidates with a `SKIP_DEFERRED` line, and emits a single `TRIAGE_DONE unblocked=N still_blocked=M deferred_skipped=K` summary line; the lease-reconcile subcommand auto-reconciles stuck `review:fixing` PR leases \u2014 re-applying `review:needs-worker` on an orphaned lease (fix-list stale, no worker report) so the Phase B1 drain retries, and removing `review:needs-worker` on a consumed-but-uncleared wedge (branch HEAD advanced past the fix-list, marker still set, no worker report) so the reviewer confirm pass can merge, always posting an audit-trail note comment and emitting a single `LEASE_RECONCILE orphaned=N consumed_uncleared=M` summary line; the maintenance subcommand flags stale issues with `status:needs-attention` (never auto-resets to `status:ready`), counts orphan branches/PRs, folds in the lease-reconcile sweep and the label-invariant auto-correction, and emits a `MAINTENANCE_DONE flagged_stale=N flagged_blocked=M orphan_branches=A orphan_prs=B needs_attention_total=T` summary line followed by the `LEASE_RECONCILE` and `LABEL_INVARIANT_DONE` lines; the label-invariant subcommand enforces the phase-label \u2192 `type:<bundle>` pairing derived from the canonical `BUNDLE_OWNERSHIP` map \u2014 report-only by default, auto-correcting with `--fix` (replacing a shadowing conventional-commit `type:*` rather than merely adding, so the issue keeps exactly one `type:*`) and flagging `status:needs-attention` when the pairing is ambiguous or collides with another bundle's type label, emitting a single `LABEL_INVARIANT_DONE mode=M checked=N ok=O violations=V corrected=C flagged=F` summary line.",
16874
17350
  content: buildCheckBlockedScript(tiers, scopeGate, runRatio)
16875
17351
  };
16876
17352
  }
@@ -17586,32 +18062,36 @@ var orchestratorSubAgent = {
17586
18062
  "## Phase D: Maintenance",
17587
18063
  "",
17588
18064
  "Run the bundled `check-blocked.sh maintenance` procedure and read",
17589
- "**only** the two summary lines it emits \u2014 `MAINTENANCE_DONE` and",
17590
- "`LEASE_RECONCILE`. The procedure folds the stale-detection,",
17591
- "orphan-detection, needs-attention summary, and PR-lease",
17592
- "auto-reconcile that earlier revisions split across D1, D2, and D3",
17593
- "into a single sweep \u2014 applying the `status:needs-attention` label",
17594
- "and posting the canned flag comment for each stale / stale-blocked",
17595
- "issue itself, and reconciling stuck `review:fixing` PR leases",
17596
- "itself, mirroring the discipline of Phase B (`pr-sweep.sh`) and",
17597
- "Phase C (`check-blocked.sh unblock`).",
18065
+ "**only** the three summary lines it emits \u2014 `MAINTENANCE_DONE`,",
18066
+ "`LEASE_RECONCILE`, and `LABEL_INVARIANT_DONE`. The procedure folds",
18067
+ "the stale-detection, orphan-detection, needs-attention summary,",
18068
+ "PR-lease auto-reconcile, and phase-label invariant sweep that",
18069
+ "earlier revisions split across D1, D2, and D3 into a single sweep \u2014",
18070
+ "applying the `status:needs-attention` label and posting the canned",
18071
+ "flag comment for each stale / stale-blocked issue itself,",
18072
+ "reconciling stuck `review:fixing` PR leases itself, and correcting",
18073
+ "mislabeled pipeline issues itself, mirroring the discipline of",
18074
+ "Phase B (`pr-sweep.sh`) and Phase C (`check-blocked.sh unblock`).",
17598
18075
  "",
17599
18076
  "```bash",
17600
18077
  ".claude/procedures/check-blocked.sh maintenance",
17601
18078
  "```",
17602
18079
  "",
17603
- "The script emits two summary lines in this shape:",
18080
+ "The script emits three summary lines in this shape:",
17604
18081
  "",
17605
18082
  "```",
17606
18083
  "MAINTENANCE_DONE flagged_stale=<N> flagged_blocked=<M> orphan_branches=<A> orphan_prs=<B> needs_attention_total=<T>",
17607
18084
  "LEASE_RECONCILE orphaned=<N> consumed_uncleared=<M>",
18085
+ "LABEL_INVARIANT_DONE mode=fix checked=<N> ok=<O> violations=<V> corrected=<C> flagged=<F>",
17608
18086
  "```",
17609
18087
  "",
17610
18088
  "Per-issue / per-orphan / per-PR informational lines (`FLAGGED_STALE",
17611
18089
  "#N`, `FLAGGED_BLOCKED #N`, `STALE #N \u2014 \u2026`, `STALE_BLOCKED #N \u2014 \u2026`,",
17612
18090
  "`ORPHAN_BRANCH \u2026`, `ORPHAN_PR #N \u2014 \u2026`, `FLAG_FAILED #N \u2014 \u2026`,",
17613
18091
  "`LEASE_ORPHANED PR #N \u2014 \u2026`, `LEASE_CONSUMED_UNCLEARED PR #N \u2014 \u2026`,",
17614
- "`LEASE_RECONCILE_FAILED PR #N \u2014 \u2026`) are emitted for log visibility",
18092
+ "`LEASE_RECONCILE_FAILED PR #N \u2014 \u2026`, `LABEL_VIOLATION #N \u2014 \u2026`,",
18093
+ "`LABEL_CORRECTED #N \u2014 \u2026`, `LABEL_AMBIGUOUS #N \u2014 \u2026`,",
18094
+ "`LABEL_CONFLICT #N \u2014 \u2026`) are emitted for log visibility",
17615
18095
  "but are **not** load-bearing for the orchestrator \u2014 partial failures",
17616
18096
  "(one bad `gh` call) do not abort the sweep, they are simply omitted",
17617
18097
  "from the counters.",
@@ -17651,9 +18131,24 @@ var orchestratorSubAgent = {
17651
18131
  "is never silently force-released. The `review:fixing` lease itself",
17652
18132
  "is always left for the reviewer confirm pass to release.",
17653
18133
  "",
17654
- "Log both summary lines and continue to Phase E regardless of",
17655
- "outcomes \u2014 a non-zero `flagged_*`, `orphan_*`, `orphaned`, or",
17656
- "`consumed_uncleared` count is informational, not a failure.",
18134
+ "**Phase-label invariant (`LABEL_INVARIANT_DONE`).** The sweep also",
18135
+ "enforces the phase-label \u2192 `type:<bundle>` pairing on every open",
18136
+ "issue, so a pipeline issue filed with a conventional-commit",
18137
+ "`type:*` (derived from its title prefix) cannot stay invisible to",
18138
+ 'the `--label "type:<bundle>"` duplicate-check idiom or mis-tier in',
18139
+ "Phase E's funnel-tier sort. The script mutates the labels itself \u2014",
18140
+ "the orchestrator only reads the summary line. See the **Phase-label",
18141
+ "\u2192 `type:<bundle>` invariant** section in `CLAUDE.md` for the full",
18142
+ "correction policy; in short, a shadowing conventional-commit type",
18143
+ "label is **replaced** (never merely supplemented, so the issue",
18144
+ "keeps exactly one `type:*`), and an ambiguous or cross-bundle",
18145
+ "collision is **flagged** `status:needs-attention` rather than",
18146
+ "guessed at.",
18147
+ "",
18148
+ "Log all three summary lines and continue to Phase E regardless of",
18149
+ "outcomes \u2014 a non-zero `flagged_*`, `orphan_*`, `orphaned`,",
18150
+ "`consumed_uncleared`, `corrected`, or `flagged` count is",
18151
+ "informational, not a failure.",
17657
18152
  "",
17658
18153
  "## Phase E: Queue Scan",
17659
18154
  "",
@@ -18655,6 +19150,7 @@ var ORCHESTRATOR_CONVENTIONS_PREAMBLE = [
18655
19150
  "- Stale thresholds: 72h for in-progress, 168h for blocked",
18656
19151
  "- Flagged issues get `status:needs-attention` \u2014 they are not auto-reset",
18657
19152
  "- **Phase D auto-reconciles stuck PR leases.** The maintenance sweep's `check-blocked.sh maintenance` invocation folds in a PR-lease reconcile that unwedges `review:fixing` PRs the Phase B1 drain and the worker hand-off left stuck, emitting a `LEASE_RECONCILE orphaned=<N> consumed_uncleared=<M>` line alongside `MAINTENANCE_DONE`. An **orphaned lease** (`review:fixing` without `review:needs-worker`, fix-list older than the 72h stale threshold, no worker-report newer than the fix-list) is **re-armed** \u2014 the sweep re-applies `review:needs-worker` so the Phase B1 drain retries the delegation, leaving the lease untouched. A **consumed-but-uncleared wedge** (both labels present, branch HEAD newer than the fix-list, no worker-report \u2014 the worker pushed but skipped the hand-off) is **auto-reconciled** \u2014 the sweep removes `review:needs-worker` so the reviewer confirm pass can merge. Both paths post an audit-trail note comment; a lease is never silently force-released, and the `review:fixing` lease itself is always left for the reviewer confirm pass to release.",
19153
+ "- **Phase D enforces the phase-label \u2192 `type:<bundle>` invariant.** The same maintenance sweep folds in a label-invariant pass that auto-corrects open issues carrying a pipeline phase label without the matching `type:<bundle>` label, emitting a `LABEL_INVARIANT_DONE mode=fix checked=<N> ok=<O> violations=<V> corrected=<C> flagged=<F>` line alongside `MAINTENANCE_DONE`. The correction **replaces** a shadowing conventional-commit `type:*` rather than merely adding the bundle type, so the issue keeps exactly one `type:*` and Phase E's funnel-tier sort key stays deterministic. Ambiguous pairings and collisions with another bundle's `type:*` label are **flagged** `status:needs-attention` (additively) instead of guessed at. See the **Phase-label \u2192 `type:<bundle>` invariant** section below for the matcher table and the full correction policy.",
18658
19154
  "",
18659
19155
  "## Depth-0 invocation requirement",
18660
19156
  "",
@@ -18670,6 +19166,8 @@ function buildOrchestratorConventionsContent(tiers, scopeGate = resolveScopeGate
18670
19166
  "",
18671
19167
  renderScopeGateSection(scopeGate, excludeBundles),
18672
19168
  "",
19169
+ renderPhaseTypeInvariantSection(excludeBundles),
19170
+ "",
18673
19171
  renderScheduledTasksSection(scheduledTasks),
18674
19172
  "",
18675
19173
  renderUnblockDependentsSection(unblockDependents)
@@ -41022,6 +41520,7 @@ export const collections = {
41022
41520
  CDK_WATCH_DEFAULTS_BY_STAGE,
41023
41521
  CLAUDE_RULE_TARGET,
41024
41522
  COMPLETE_JOB_ID,
41523
+ CONVENTIONAL_COMMIT_TYPE_LABELS,
41025
41524
  CdkCli,
41026
41525
  DEFAULT_AC_THRESHOLDS,
41027
41526
  DEFAULT_AGENT_PATHS,
@@ -41093,6 +41592,7 @@ export const collections = {
41093
41592
  MONOREPO_LAYOUT,
41094
41593
  MonorepoProject,
41095
41594
  Nvmrc,
41595
+ PHASE_LABEL_TYPE_MAP,
41096
41596
  PROD_DEPLOY_NAME,
41097
41597
  PROGRESS_FILES_FORMAT_VALUES,
41098
41598
  PnpmWorkspace,
@@ -41253,6 +41753,8 @@ export const collections = {
41253
41753
  renderIssueTemplatesStarterPage,
41254
41754
  renderMeetingTypesSection,
41255
41755
  renderNextRequirementIdProcedure,
41756
+ renderPhaseTypeInvariantSection,
41757
+ renderPhaseTypeInvariantShellHelpers,
41256
41758
  renderPriorityRulesSection,
41257
41759
  renderProgressFileName,
41258
41760
  renderProgressFilePath,
@@ -41304,6 +41806,7 @@ export const collections = {
41304
41806
  resolveSkillEvals,
41305
41807
  resolveTemplateVariables,
41306
41808
  resolveTemporalFraming,
41809
+ resolveTypeLabelForLabels,
41307
41810
  resolveTypeScriptProjectOutdir,
41308
41811
  resolveUnblockDependents,
41309
41812
  runScan,
@@ -41313,6 +41816,7 @@ export const collections = {
41313
41816
  stripToolArtifactTagsProcedure,
41314
41817
  tsdocRecordToFindings,
41315
41818
  turborepoBundle,
41819
+ typeLabelForPhaseLabel,
41316
41820
  typescriptBundle,
41317
41821
  upstreamConfigulatorDocsBundle,
41318
41822
  validateAgentTierConfig,