session-orchestrator 3.22.0 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +168 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +211 -10
- package/hooks/on-session-start.mjs +214 -11
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +5 -0
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +159 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +11 -3
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +30 -16
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +102 -19
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -36,6 +36,11 @@ import { acquire, release, buildLockOwnerProof } from '../session-lock.mjs';
|
|
|
36
36
|
import { emitEvent } from '../events.mjs';
|
|
37
37
|
import { main as gcMain } from '../../gc-stale-worktrees.mjs';
|
|
38
38
|
import { SEMANTIC_ID_RE } from '../session-id.mjs';
|
|
39
|
+
import { repoPathHash } from '../session-registry.mjs';
|
|
40
|
+
// Marker location is owned by its READER (session-end Phase 4a) — importing the
|
|
41
|
+
// constant from there keeps writer and reader on one string. The dependency runs
|
|
42
|
+
// heavy→lean (this pipeline → the dependency-free cleanup helper), never back.
|
|
43
|
+
import { PROMOTION_MARKER_RELPATH } from '../session-end/worktree-cleanup.mjs';
|
|
39
44
|
|
|
40
45
|
// ---------------------------------------------------------------------------
|
|
41
46
|
// Type definitions
|
|
@@ -547,6 +552,134 @@ export async function runStoryPipeline(context, opts = {}) {
|
|
|
547
552
|
/** Valid git branch character set — mirrors isValidBranch from session-id.mjs. */
|
|
548
553
|
const ENTER_WORKTREE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
|
|
549
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Raised when the promotion branch `so/<sessionId>` is already checked out by
|
|
557
|
+
* another worktree. Refusing is the point: adopting a foreign target worktree
|
|
558
|
+
* would put two sessions on one branch (PSA-002 territory), so the caller gets
|
|
559
|
+
* the conflicting path by name instead of a silent takeover.
|
|
560
|
+
*/
|
|
561
|
+
export class WorktreePromotionBranchError extends Error {
|
|
562
|
+
/**
|
|
563
|
+
* @param {string} message
|
|
564
|
+
* @param {{branch: string, checkedOutAt: string}} detail
|
|
565
|
+
*/
|
|
566
|
+
constructor(message, { branch, checkedOutAt } = {}) {
|
|
567
|
+
super(message);
|
|
568
|
+
this.name = 'WorktreePromotionBranchError';
|
|
569
|
+
this.branch = branch;
|
|
570
|
+
this.checkedOutAt = checkedOutAt;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Map of branch name → worktree path for every branch currently checked out by
|
|
576
|
+
* ANY worktree of `repoRoot` (#1067).
|
|
577
|
+
*
|
|
578
|
+
* Deliberately NOT `listWorktrees()` from `../worktree/listing.mjs`: that helper
|
|
579
|
+
* runs `git worktree list --porcelain` in `process.cwd()` (it has no repo
|
|
580
|
+
* parameter), and `enterWorktree` takes `repoRoot` explicitly precisely to avoid
|
|
581
|
+
* CWD drift (#219) — so reusing it would query whichever repo the process
|
|
582
|
+
* happens to sit in. Its `$` seam also has a different shape (`$({cwd})` returns
|
|
583
|
+
* the tag) than `enterWorktree`'s (`exec` IS the tag), so the two cannot share
|
|
584
|
+
* one injected executor. Only the `branch` lines are parsed here; `HEAD`,
|
|
585
|
+
* `detached`, `bare` and `locked` records carry nothing this decision needs.
|
|
586
|
+
*
|
|
587
|
+
* @param {Function} exec zx-like template-tag executor (the DI seam).
|
|
588
|
+
* @param {string} repoRoot Absolute path to the source git repository.
|
|
589
|
+
* @returns {Promise<Map<string, string>>} Branch names (without the
|
|
590
|
+
* `refs/heads/` prefix) → the worktree path that has them checked out.
|
|
591
|
+
* Empty on git failure — the caller then behaves exactly as it did before this
|
|
592
|
+
* check existed, so a broken listing degrades to git's own loud
|
|
593
|
+
* `already used by worktree` error rather than to a silent wrong branch.
|
|
594
|
+
*/
|
|
595
|
+
async function listCheckedOutBranches(exec, repoRoot) {
|
|
596
|
+
let stdout;
|
|
597
|
+
try {
|
|
598
|
+
const result = await exec`git -C ${repoRoot} worktree list --porcelain`;
|
|
599
|
+
stdout = String(result?.stdout ?? '');
|
|
600
|
+
} catch {
|
|
601
|
+
return new Map();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const branches = new Map();
|
|
605
|
+
let currentPath = null;
|
|
606
|
+
for (const rawLine of stdout.split('\n')) {
|
|
607
|
+
const line = rawLine.trimEnd();
|
|
608
|
+
const wtMatch = /^worktree (.+)$/.exec(line);
|
|
609
|
+
if (wtMatch) {
|
|
610
|
+
currentPath = wtMatch[1];
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const branchMatch = /^branch refs\/heads\/(.+)$/.exec(line);
|
|
614
|
+
if (branchMatch) branches.set(branchMatch[1], currentPath ?? '');
|
|
615
|
+
}
|
|
616
|
+
return branches;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Does `<ref>` resolve in `repoRoot`? Wraps `git rev-parse --verify --quiet`.
|
|
621
|
+
*
|
|
622
|
+
* @param {Function} exec
|
|
623
|
+
* @param {string} repoRoot
|
|
624
|
+
* @param {string} ref
|
|
625
|
+
* @returns {Promise<boolean>} false on any git failure (the ref is absent, or
|
|
626
|
+
* git could not be asked — both lead to the create-new path, whose failure
|
|
627
|
+
* mode is git's own loud error rather than a silent wrong checkout).
|
|
628
|
+
*/
|
|
629
|
+
async function refExists(exec, repoRoot, ref) {
|
|
630
|
+
try {
|
|
631
|
+
await exec`git -C ${repoRoot} rev-parse --verify --quiet ${ref}`;
|
|
632
|
+
return true;
|
|
633
|
+
} catch {
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Record, inside the freshly created worktree, that it was auto-promoted —
|
|
640
|
+
* and from where. Best-effort: a failure to write the marker degrades Phase 4a
|
|
641
|
+
* detection to the legacy basename key, it never fails the promotion itself.
|
|
642
|
+
*
|
|
643
|
+
* No absolute source path is stored: `source_root_hash` is `repoPathHash()`, the
|
|
644
|
+
* same stable SHA-256 the session registry uses to correlate repos without
|
|
645
|
+
* exposing the operator's filesystem layout.
|
|
646
|
+
*
|
|
647
|
+
* @param {object} params
|
|
648
|
+
* @param {string} params.wtPath Freshly created worktree.
|
|
649
|
+
* @param {string} params.sourceRoot Resolved source checkout.
|
|
650
|
+
* @param {string} params.sessionId Session label the worktree was created for.
|
|
651
|
+
* @param {string} params.branch Branch the worktree actually landed on.
|
|
652
|
+
* @returns {boolean} true when the marker was written.
|
|
653
|
+
*/
|
|
654
|
+
function writePromotionMarker({ wtPath, sourceRoot, sessionId, branch }) {
|
|
655
|
+
try {
|
|
656
|
+
const markerPath = path.join(wtPath, PROMOTION_MARKER_RELPATH);
|
|
657
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
658
|
+
fs.writeFileSync(
|
|
659
|
+
markerPath,
|
|
660
|
+
JSON.stringify(
|
|
661
|
+
{
|
|
662
|
+
source_root_hash: repoPathHash(sourceRoot),
|
|
663
|
+
source_root_basename: path.basename(sourceRoot),
|
|
664
|
+
source_session_id: sessionId,
|
|
665
|
+
branch,
|
|
666
|
+
promoted_at: new Date().toISOString(),
|
|
667
|
+
},
|
|
668
|
+
null,
|
|
669
|
+
2,
|
|
670
|
+
) + '\n',
|
|
671
|
+
'utf8',
|
|
672
|
+
);
|
|
673
|
+
return true;
|
|
674
|
+
} catch (err) {
|
|
675
|
+
console.warn(
|
|
676
|
+
`enterWorktree: could not write promotion marker in ${wtPath} (${err.message}) — ` +
|
|
677
|
+
'session-end Phase 4a falls back to basename detection',
|
|
678
|
+
);
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
550
683
|
/**
|
|
551
684
|
* Create a sibling git worktree for Worktree-Auto-Promotion (#574, Epic #568 P3.1).
|
|
552
685
|
*
|
|
@@ -573,10 +706,25 @@ const ENTER_WORKTREE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
|
|
|
573
706
|
* check as `setupWorktree` (CWE-23 / SEC-013 defence-in-depth). Throws
|
|
574
707
|
* `WorktreeBoundaryError` if the computed path escapes `basePath`.
|
|
575
708
|
*
|
|
576
|
-
* Branch handling
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
709
|
+
* Branch handling — three cases, decided in this order (#1067):
|
|
710
|
+
* 1. `<branch>` is already checked out by ANY worktree of `repoRoot` (the
|
|
711
|
+
* normal session-start Phase 0.5 case, where `branch` is the CURRENT HEAD):
|
|
712
|
+
* git refuses a second checkout of the same branch
|
|
713
|
+
* (`fatal: '<branch>' is already used by worktree at '<repoRoot>'`). Treat
|
|
714
|
+
* `<branch>` as a START POINT only and create a fresh promotion branch
|
|
715
|
+
* `so/<sessionId>` at it: `git worktree add -b so/<sessionId> <wtPath> <branch>`.
|
|
716
|
+
* The source branch and the source worktree are left untouched.
|
|
717
|
+
* If `so/<sessionId>` ALREADY exists (a worktree directory was removed but
|
|
718
|
+
* its branch survived), it is REUSED — `git worktree add <wtPath>
|
|
719
|
+
* so/<sessionId>`, `reusedBranch: true` in the return — because `-b` would
|
|
720
|
+
* abort with `fatal: a branch named 'so/<sessionId>' already exists`.
|
|
721
|
+
* If it exists AND is checked out by some worktree, this throws
|
|
722
|
+
* `WorktreePromotionBranchError` naming that path: adopting a foreign
|
|
723
|
+
* target worktree is refused, never silently taken over.
|
|
724
|
+
* 2. `<branch>` exists but is not checked out (verified via
|
|
725
|
+
* `git rev-parse --verify <branch>`): `git worktree add <wtPath> <branch>`.
|
|
726
|
+
* 3. `<branch>` does not exist: `git worktree add -b <branch> <wtPath>`.
|
|
727
|
+
* Cases 2/3 differ from `setupWorktree`, which always passes `-b`.
|
|
580
728
|
*
|
|
581
729
|
* @param {object} params
|
|
582
730
|
* @param {string} params.basePath - Parent directory where the new worktree goes (absolute).
|
|
@@ -585,9 +733,22 @@ const ENTER_WORKTREE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
|
|
|
585
733
|
* @param {string} params.repoRoot - Path to the source git repository (passed explicitly to avoid CWD drift per #219).
|
|
586
734
|
* @param {object} [opts]
|
|
587
735
|
* @param {Function} [opts.$] - zx-like template-tag executor (DI seam); falls back to lazy `await import('zx')`.
|
|
588
|
-
*
|
|
736
|
+
* Every freshly created worktree also gets a `.orchestrator/promoted-from.json`
|
|
737
|
+
* marker (see {@link PROMOTION_MARKER_RELPATH}) so session-end Phase 4a can
|
|
738
|
+
* still recognise it after the #1069 process boundary hands the worktree to a
|
|
739
|
+
* session with a different id. Marker writing is best-effort and never fails
|
|
740
|
+
* the promotion.
|
|
741
|
+
*
|
|
742
|
+
* @returns {Promise<{ wtPath: string, reused: boolean, branch?: string, promotedFrom?: string, reusedBranch?: true }>}
|
|
743
|
+
* `branch` is the branch the new worktree actually landed on — equal to the
|
|
744
|
+
* `branch` param except in case 1 above, where it is `so/<sessionId>` and
|
|
745
|
+
* `promotedFrom` carries the requested source branch. `reusedBranch` is
|
|
746
|
+
* present (and `true`) only when an existing `so/<sessionId>` was checked out
|
|
747
|
+
* rather than created. All three are absent on the `reused: true` path (no
|
|
748
|
+
* branch was chosen — the worktree pre-existed).
|
|
589
749
|
* @throws {TypeError} when any required param is missing or fails validation.
|
|
590
750
|
* @throws {WorktreeBoundaryError} when the computed worktree path escapes `basePath`.
|
|
751
|
+
* @throws {WorktreePromotionBranchError} when `so/<sessionId>` is checked out elsewhere.
|
|
591
752
|
*/
|
|
592
753
|
export async function enterWorktree({ basePath, sessionId, branch, repoRoot } = {}, opts = {}) {
|
|
593
754
|
// -------------------------------------------------------------------------
|
|
@@ -686,28 +847,91 @@ export async function enterWorktree({ basePath, sessionId, branch, repoRoot } =
|
|
|
686
847
|
}
|
|
687
848
|
|
|
688
849
|
// -------------------------------------------------------------------------
|
|
689
|
-
// Step 5:
|
|
850
|
+
// Step 5: Is `branch` already checked out elsewhere? (#1067)
|
|
851
|
+
//
|
|
852
|
+
// Phase 0.5 passes the CURRENT HEAD as `branch`, so in the normal promotion
|
|
853
|
+
// case it is checked out by `repoRoot` itself and git would refuse a second
|
|
854
|
+
// checkout. Then `branch` is only a start point and the worktree lands on a
|
|
855
|
+
// fresh `so/<sessionId>`; the source branch/worktree stay untouched.
|
|
690
856
|
// -------------------------------------------------------------------------
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
857
|
+
const checkedOutBranches = await listCheckedOutBranches(exec, repoRoot);
|
|
858
|
+
const promoted = checkedOutBranches.has(branch);
|
|
859
|
+
const targetBranch = promoted ? `so/${sessionId}` : branch;
|
|
860
|
+
|
|
861
|
+
// -------------------------------------------------------------------------
|
|
862
|
+
// Step 6: Decide the `git worktree add` argv.
|
|
863
|
+
//
|
|
864
|
+
// On the promotion path the target is `so/<sessionId>`, which can be in any
|
|
865
|
+
// of THREE states — and only the first was handled before:
|
|
866
|
+
// (a) absent → create it at `<branch>`: `add -b so/<id> <wt> <branch>`
|
|
867
|
+
// (b) exists, free → REUSE it: `add <wt> so/<id>`. This is the survivor
|
|
868
|
+
// of a worktree whose directory was removed while its branch stayed
|
|
869
|
+
// behind (`git worktree remove` does not delete the branch); `-b` would
|
|
870
|
+
// abort Phase 0.5 with `fatal: a branch named 'so/<id>' already exists`.
|
|
871
|
+
// (c) exists, checked out elsewhere → REFUSE. Adopting a foreign target
|
|
872
|
+
// worktree is exactly what Phase 0.5 must not do.
|
|
873
|
+
// -------------------------------------------------------------------------
|
|
874
|
+
let reusedBranch = false;
|
|
875
|
+
if (promoted) {
|
|
876
|
+
const checkedOutAt = checkedOutBranches.get(targetBranch);
|
|
877
|
+
if (checkedOutAt !== undefined) {
|
|
878
|
+
throw new WorktreePromotionBranchError(
|
|
879
|
+
`enterWorktree: promotion branch '${targetBranch}' is already checked out at '${checkedOutAt}' — refusing to adopt a foreign worktree`,
|
|
880
|
+
{ branch: targetBranch, checkedOutAt },
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
reusedBranch = await refExists(exec, repoRoot, `refs/heads/${targetBranch}`);
|
|
697
884
|
}
|
|
698
885
|
|
|
699
|
-
|
|
886
|
+
// A branch that a worktree has checked out exists by construction, so the
|
|
887
|
+
// source-branch rev-parse probe is skipped on the promotion path.
|
|
888
|
+
let branchExists = promoted;
|
|
889
|
+
if (!promoted) {
|
|
890
|
+
try {
|
|
891
|
+
await exec`git -C ${repoRoot} rev-parse --verify ${branch}`;
|
|
892
|
+
branchExists = true;
|
|
893
|
+
} catch {
|
|
894
|
+
// Branch does not exist — fall through to create-new path with `-b`.
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (promoted && reusedBranch) {
|
|
899
|
+
await exec`git -C ${repoRoot} worktree add ${wtPath} ${targetBranch}`;
|
|
900
|
+
} else if (promoted) {
|
|
901
|
+
await exec`git -C ${repoRoot} worktree add -b ${targetBranch} ${wtPath} ${branch}`;
|
|
902
|
+
} else if (branchExists) {
|
|
700
903
|
await exec`git -C ${repoRoot} worktree add ${wtPath} ${branch}`;
|
|
701
904
|
} else {
|
|
702
905
|
await exec`git -C ${repoRoot} worktree add -b ${branch} ${wtPath}`;
|
|
703
906
|
}
|
|
704
907
|
|
|
705
908
|
// -------------------------------------------------------------------------
|
|
706
|
-
// Step
|
|
909
|
+
// Step 6b: Record the promotion FACT inside the new worktree (#1069 boundary).
|
|
910
|
+
// Session-end Phase 4a runs in a session whose id is NOT this one, so nothing
|
|
911
|
+
// in the path or the branch identifies the worktree to it — the marker does.
|
|
912
|
+
// -------------------------------------------------------------------------
|
|
913
|
+
writePromotionMarker({
|
|
914
|
+
wtPath,
|
|
915
|
+
sourceRoot: resolvedRepoRoot,
|
|
916
|
+
sessionId,
|
|
917
|
+
branch: targetBranch,
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
// -------------------------------------------------------------------------
|
|
921
|
+
// Step 7: WARN to stderr (PRD §3 P3 Gherkin row-1 + #574 DoD).
|
|
707
922
|
// -------------------------------------------------------------------------
|
|
708
923
|
console.warn(
|
|
709
|
-
|
|
924
|
+
promoted
|
|
925
|
+
? `enterWorktree: created sibling worktree at ${wtPath} (branch=${targetBranch}, promoted from ${branch}, sessionId=${sessionId})`
|
|
926
|
+
: `enterWorktree: created sibling worktree at ${wtPath} (branch=${branch}, sessionId=${sessionId})`,
|
|
710
927
|
);
|
|
711
928
|
|
|
712
|
-
|
|
929
|
+
if (promoted) {
|
|
930
|
+
const result = { wtPath, reused: false, branch: targetBranch, promotedFrom: branch };
|
|
931
|
+
// Present only when it happened, so the common shape stays byte-identical
|
|
932
|
+
// for every existing consumer and strict-equality pin.
|
|
933
|
+
if (reusedBranch) result.reusedBranch = true;
|
|
934
|
+
return result;
|
|
935
|
+
}
|
|
936
|
+
return { wtPath, reused: false, branch };
|
|
713
937
|
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* - Synchronous file I/O for STATE.md / sessions.jsonl / bootstrap.lock.
|
|
11
11
|
* The async wrapper exists only because `scanBacklog` is async.
|
|
12
12
|
* - Logging is NOT this helper's job — silent graceful-null on every error.
|
|
13
|
-
* -
|
|
13
|
+
* - Relative paths resolve against `opts.repoRoot` (which itself defaults to
|
|
14
|
+
* process.cwd()); an explicit ABSOLUTE path always wins over the root.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -32,9 +33,16 @@ import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
|
|
|
32
33
|
* null/[] value to the Signals object.
|
|
33
34
|
*
|
|
34
35
|
* @param {object} [opts]
|
|
35
|
-
* @param {string} [opts.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
36
|
+
* @param {string} [opts.repoRoot] — absolute project root every relative
|
|
37
|
+
* path below resolves against, and the root forwarded to `scanBacklog`.
|
|
38
|
+
* Defaults to `process.cwd()`. Without it (#1071) a caller running from a
|
|
39
|
+
* worktree or a subdirectory silently read a DIFFERENT repo's STATE.md and
|
|
40
|
+
* sessions.jsonl than the one it was reporting on — measured as
|
|
41
|
+
* `recentSessions: []` against a checkout holding 245 session records.
|
|
42
|
+
* An explicit absolute `statePath`/`sessionsPath`/`lockPath` still wins.
|
|
43
|
+
* @param {string} [opts.statePath] — defaults to '<repoRoot>/.claude/STATE.md'
|
|
44
|
+
* @param {string} [opts.sessionsPath] — defaults to '<repoRoot>/.orchestrator/metrics/sessions.jsonl'
|
|
45
|
+
* @param {string} [opts.lockPath] — defaults to '<repoRoot>/.orchestrator/bootstrap.lock'
|
|
38
46
|
* @param {Array} [opts.learnings] — pre-surfaced top-N learnings; defaults to []
|
|
39
47
|
* @param {number} [opts.backlogLimit] — passed to scanBacklog; defaults to
|
|
40
48
|
* `DEFAULT_BACKLOG_LIMIT` from backlog-scan.mjs (never a local copy of that
|
|
@@ -46,15 +54,24 @@ import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
|
|
|
46
54
|
* @returns {Promise<import('./mode-selector.mjs').Signals>}
|
|
47
55
|
*/
|
|
48
56
|
export async function buildLiveSignals(opts = {}) {
|
|
57
|
+
const repoRoot =
|
|
58
|
+
typeof opts.repoRoot === 'string' && opts.repoRoot.length > 0
|
|
59
|
+
? opts.repoRoot
|
|
60
|
+
: process.cwd();
|
|
61
|
+
// `resolve(root, p)` returns `p` unchanged when `p` is absolute — explicit
|
|
62
|
+
// per-file overrides therefore keep precedence over repoRoot.
|
|
49
63
|
const statePath = resolve(
|
|
64
|
+
repoRoot,
|
|
50
65
|
typeof opts.statePath === 'string' ? opts.statePath : '.claude/STATE.md'
|
|
51
66
|
);
|
|
52
67
|
const sessionsPath = resolve(
|
|
68
|
+
repoRoot,
|
|
53
69
|
typeof opts.sessionsPath === 'string'
|
|
54
70
|
? opts.sessionsPath
|
|
55
71
|
: '.orchestrator/metrics/sessions.jsonl'
|
|
56
72
|
);
|
|
57
73
|
const lockPath = resolve(
|
|
74
|
+
repoRoot,
|
|
58
75
|
typeof opts.lockPath === 'string' ? opts.lockPath : '.orchestrator/bootstrap.lock'
|
|
59
76
|
);
|
|
60
77
|
const learnings = Array.isArray(opts.learnings) ? opts.learnings : [];
|
|
@@ -137,7 +154,9 @@ export async function buildLiveSignals(opts = {}) {
|
|
|
137
154
|
let backlog = null;
|
|
138
155
|
|
|
139
156
|
try {
|
|
140
|
-
|
|
157
|
+
// `repoRoot` is forwarded so VCS detection and the `-R` host-pinning spec
|
|
158
|
+
// inside scanBacklog answer about the SAME repo the signals describe.
|
|
159
|
+
backlog = await _scan({ limit: backlogLimit, repoRoot });
|
|
141
160
|
} catch {
|
|
142
161
|
// Branch 6: scanBacklog threw — backlog stays null
|
|
143
162
|
}
|
|
@@ -47,6 +47,128 @@ async function execWithTimeout(cmd, args, opts = {}) {
|
|
|
47
47
|
]);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** C0 controls plus DEL — matching them is the POINT, hence the disable. */
|
|
51
|
+
// eslint-disable-next-line no-control-regex
|
|
52
|
+
const CONTROL_BYTE_RE = /[\u0000-\u001f\u007f]/g;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Replace every C0/DEL control byte with its printable `\uXXXX` escape.
|
|
56
|
+
*
|
|
57
|
+
* `JSON.stringify` covers the payload preview, but NOT `SyntaxError.message` —
|
|
58
|
+
* V8 quotes the offending input INTO that message verbatim, so an ANSI/CR
|
|
59
|
+
* payload reached the operator's terminal through the error text even after
|
|
60
|
+
* the preview was escaped. Both halves go through here.
|
|
61
|
+
*
|
|
62
|
+
* @param {unknown} text
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function escapeControlBytes(text) {
|
|
66
|
+
return String(text).replace(
|
|
67
|
+
CONTROL_BYTE_RE,
|
|
68
|
+
(ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Name a parsed JSON value's type WITHOUT quoting any of its content.
|
|
74
|
+
*
|
|
75
|
+
* @param {unknown} value
|
|
76
|
+
* @returns {'null'|'array'|'object'|'string'|'number'|'boolean'|'undefined'}
|
|
77
|
+
*/
|
|
78
|
+
function jsonTypeOf(value) {
|
|
79
|
+
if (value === null) return 'null';
|
|
80
|
+
if (Array.isArray(value)) return 'array';
|
|
81
|
+
return /** @type {any} */ (typeof value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse CLI stdout as JSON, degrading an unparseable payload onto this module's
|
|
86
|
+
* documented failure channel instead of a bare `SyntaxError` (CWE-502).
|
|
87
|
+
*
|
|
88
|
+
* The throw IS that channel, not an escape from it: every call site runs under
|
|
89
|
+
* `checkCiStatus`'s outer catch, whose comment already names "unparseable
|
|
90
|
+
* output" as a case it converts to `console.warn` + `null`. Measured
|
|
91
|
+
* 2026-08-28 at 30940cb, BEFORE this helper existed: an HTML login page from
|
|
92
|
+
* `gh repo view`, a literal `null`, and an empty `glab` stdout ALL already
|
|
93
|
+
* returned `null` with a warn — nothing crashed, and
|
|
94
|
+
* `tests/lib/ci-status-banner.test.mjs` § "error containment" pinned it. So
|
|
95
|
+
* this does not fix a crash — the outer catch already turned non-JSON output
|
|
96
|
+
* into a warn + `null`. The rule this helper closes is lexical
|
|
97
|
+
* (`json-parse-untrusted-input`, see below), not a crash it prevents.
|
|
98
|
+
*
|
|
99
|
+
* What it does fix is the message. The raw parse error (`Unexpected token
|
|
100
|
+
* '<'`) named neither the CLI nor the request, and this banner spawns four
|
|
101
|
+
* different subprocesses — an operator reading that line at session-start
|
|
102
|
+
* could not tell which one returned garbage, which is the same
|
|
103
|
+
* "could not read looks like nothing to report" class #1022/#1039 attacked.
|
|
104
|
+
* Guarding at the parse also satisfies the `json-parse-untrusted-input` rule,
|
|
105
|
+
* which keys on a LEXICALLY enclosing try/catch and cannot see the outer one.
|
|
106
|
+
*
|
|
107
|
+
* Ceiling (BV-004): the payload preview is clamped to 120 characters. An
|
|
108
|
+
* unbounded one would bury the session-start banner it is printed beside — a
|
|
109
|
+
* paginated HTML error page is the realistic worst case. Revisit if a CLI
|
|
110
|
+
* starts emitting a diagnostic that needs more than one line to identify.
|
|
111
|
+
*
|
|
112
|
+
* The preview is emitted through `JSON.stringify`, not raw. It comes from a
|
|
113
|
+
* subprocess whose stdout this module does not control and lands in a
|
|
114
|
+
* `console.warn` beside the session-start banner: a payload carrying ANSI
|
|
115
|
+
* escapes, a `\r`, or a bare newline could otherwise repaint or overwrite the
|
|
116
|
+
* lines around it. `JSON.stringify` escapes every control byte and quotes the
|
|
117
|
+
* result, so the preview stays exactly one line of printable text.
|
|
118
|
+
*
|
|
119
|
+
* `expect` closes the second half of the same gap: valid JSON of the WRONG
|
|
120
|
+
* SHAPE parsed fine and escaped this named channel entirely. Measured
|
|
121
|
+
* 2026-08-28 at 7daa3d2 — a `gh repo view` that printed `null` reached
|
|
122
|
+
* `const { nameWithOwner } = …` and threw a bare
|
|
123
|
+
* `TypeError: Cannot destructure property 'nameWithOwner' of 'null'`, which
|
|
124
|
+
* names neither the CLI nor the request; a `glab api …/pipelines` that printed
|
|
125
|
+
* `null` was swallowed by `!Array.isArray(pipelines) → return null`, silent.
|
|
126
|
+
* Both now fail as the SAME named error the parse failure produces.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} stdout Raw child-process stdout (untrusted)
|
|
129
|
+
* @param {string} label The command that produced it, for the failure message
|
|
130
|
+
* @param {'object'|'array'} [expect] Required shape; omit to accept any JSON
|
|
131
|
+
* @returns {unknown} The parsed value
|
|
132
|
+
* @throws {Error} Named parse/shape failure carrying a bounded payload preview
|
|
133
|
+
*/
|
|
134
|
+
function parseCliJson(stdout, label, expect) {
|
|
135
|
+
const raw = String(stdout ?? '');
|
|
136
|
+
// Bounded (BV-004: 120 chars — a paginated HTML error page is the realistic
|
|
137
|
+
// worst case) AND escaped, so it can never break the line it is printed on.
|
|
138
|
+
const preview = raw.trim().slice(0, 120);
|
|
139
|
+
const shown = preview
|
|
140
|
+
? `got: ${escapeControlBytes(JSON.stringify(preview))}`
|
|
141
|
+
: 'got: (empty stdout)';
|
|
142
|
+
|
|
143
|
+
let parsed;
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(raw);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const reason = escapeControlBytes(err instanceof Error ? err.message : String(err));
|
|
148
|
+
// `cause` preserves the original for a debugger; the reason is ALSO
|
|
149
|
+
// inlined into the message because the outer catch reads `err.message`
|
|
150
|
+
// only — a cause-only wrapper would lose it on the operator-facing line.
|
|
151
|
+
throw new Error(
|
|
152
|
+
`${label} returned unparseable JSON (${reason}) — ${shown}`,
|
|
153
|
+
{ cause: err },
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A shape mismatch reports the JSON TYPE, never the payload. The parse
|
|
158
|
+
// succeeded, so the bytes add nothing an operator can act on — and
|
|
159
|
+
// `tests/lib/ci-status-banner.test.mjs` § "unexpected benign pipeline
|
|
160
|
+
// metadata" pins that a well-formed-but-wrong-shaped API body must not have
|
|
161
|
+
// its contents echoed anywhere. A type name carries no body content.
|
|
162
|
+
const actual = jsonTypeOf(parsed);
|
|
163
|
+
if (expect === 'array' && actual !== 'array') {
|
|
164
|
+
throw new Error(`${label} returned JSON of an unexpected shape — expected an array, got ${actual}`);
|
|
165
|
+
}
|
|
166
|
+
if (expect === 'object' && actual !== 'object') {
|
|
167
|
+
throw new Error(`${label} returned JSON of an unexpected shape — expected an object, got ${actual}`);
|
|
168
|
+
}
|
|
169
|
+
return parsed;
|
|
170
|
+
}
|
|
171
|
+
|
|
50
172
|
/**
|
|
51
173
|
* The one failure this probe can produce that the frozen
|
|
52
174
|
* `REMOTE_RESOLUTION_REASONS` set has no member for: the async timeout race
|
|
@@ -201,16 +323,17 @@ async function getHeadSha(repoRoot, deps = {}) {
|
|
|
201
323
|
* @param {string} apiPath
|
|
202
324
|
* @param {string} repoRoot
|
|
203
325
|
* @param {{ execFile?: Function, timeoutMs?: number, repoHost: string }} deps
|
|
326
|
+
* @param {'object'|'array'} [expect] Required payload shape (see `parseCliJson`)
|
|
204
327
|
* @returns {Promise<unknown>}
|
|
205
328
|
*/
|
|
206
|
-
async function glabApi(apiPath, repoRoot, deps = {}) {
|
|
329
|
+
async function glabApi(apiPath, repoRoot, deps = {}, expect = undefined) {
|
|
207
330
|
const args = ['api', apiPath, '--hostname', deps.repoHost];
|
|
208
331
|
const result = await execWithTimeout(
|
|
209
332
|
'glab',
|
|
210
333
|
args,
|
|
211
334
|
{ cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
|
|
212
335
|
);
|
|
213
|
-
return
|
|
336
|
+
return parseCliJson(result.stdout, `glab api ${apiPath}`, expect);
|
|
214
337
|
}
|
|
215
338
|
|
|
216
339
|
/**
|
|
@@ -222,9 +345,10 @@ async function glabApi(apiPath, repoRoot, deps = {}) {
|
|
|
222
345
|
* @param {string} apiPath
|
|
223
346
|
* @param {string} repoRoot
|
|
224
347
|
* @param {{ execFile?: Function, timeoutMs?: number, repoHost?: string }} deps
|
|
348
|
+
* @param {'object'|'array'} [expect] Required payload shape (see `parseCliJson`)
|
|
225
349
|
* @returns {Promise<unknown>}
|
|
226
350
|
*/
|
|
227
|
-
async function ghApi(apiPath, repoRoot, deps = {}) {
|
|
351
|
+
async function ghApi(apiPath, repoRoot, deps = {}, expect = undefined) {
|
|
228
352
|
const args = ['api', apiPath];
|
|
229
353
|
if (deps.repoHost) args.push('--hostname', deps.repoHost);
|
|
230
354
|
const result = await execWithTimeout(
|
|
@@ -232,7 +356,7 @@ async function ghApi(apiPath, repoRoot, deps = {}) {
|
|
|
232
356
|
args,
|
|
233
357
|
{ cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
|
|
234
358
|
);
|
|
235
|
-
return
|
|
359
|
+
return parseCliJson(result.stdout, `gh api ${apiPath}`, expect);
|
|
236
360
|
}
|
|
237
361
|
|
|
238
362
|
/**
|
|
@@ -276,14 +400,18 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
276
400
|
const currentSha = await getHeadSha(repoRoot, deps);
|
|
277
401
|
const apiDeps = { ...deps, repoHost: project.host };
|
|
278
402
|
const projectPath = `projects/${project.encodedProjectPath}`;
|
|
403
|
+
// `'array'` is load-bearing, not decoration: before it, a `glab api` that
|
|
404
|
+
// returned valid JSON of the wrong shape (`null`, `"ok"`, an object) fell
|
|
405
|
+
// into `!Array.isArray(pipelines) → return null` — a SILENT no-op an operator
|
|
406
|
+
// reads as "nothing to report". It now raises the same named error an
|
|
407
|
+
// unparseable payload does, so the outer catch warns.
|
|
279
408
|
const pipelines = await glabApi(
|
|
280
409
|
`${projectPath}/pipelines?order_by=updated_at&sort=desc&per_page=15`,
|
|
281
410
|
repoRoot,
|
|
282
411
|
apiDeps,
|
|
412
|
+
'array',
|
|
283
413
|
);
|
|
284
414
|
|
|
285
|
-
if (!Array.isArray(pipelines)) return null;
|
|
286
|
-
|
|
287
415
|
const currentPipeline = pipelines.find((p) => p.sha === currentSha);
|
|
288
416
|
|
|
289
417
|
if (!currentPipeline) {
|
|
@@ -312,6 +440,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
312
440
|
`${projectPath}/pipelines/${currentPipeline.id}/jobs`,
|
|
313
441
|
repoRoot,
|
|
314
442
|
apiDeps,
|
|
443
|
+
'array',
|
|
315
444
|
);
|
|
316
445
|
if (Array.isArray(jobs)) {
|
|
317
446
|
const softFailed = jobs
|
|
@@ -379,6 +508,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
379
508
|
`${projectPath}/pipelines/${currentPipeline.id}/jobs`,
|
|
380
509
|
repoRoot,
|
|
381
510
|
apiDeps,
|
|
511
|
+
'array',
|
|
382
512
|
);
|
|
383
513
|
if (Array.isArray(jobs)) {
|
|
384
514
|
const failedJob = jobs.find((j) => j.status === 'failed');
|
|
@@ -450,12 +580,22 @@ async function checkGithub(repoRoot, deps = {}) {
|
|
|
450
580
|
repoViewArgs,
|
|
451
581
|
{ cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
|
|
452
582
|
);
|
|
453
|
-
|
|
583
|
+
// `'object'` before the destructuring: a `gh repo view` that printed `null`
|
|
584
|
+
// (or a bare string, or an array) used to throw a bare
|
|
585
|
+
// `TypeError: Cannot destructure property 'nameWithOwner' of 'null'`, whose
|
|
586
|
+
// message names neither the CLI nor the request — the exact identification
|
|
587
|
+
// failure this helper exists to fix.
|
|
588
|
+
const { nameWithOwner } = parseCliJson(
|
|
589
|
+
repoViewResult.stdout,
|
|
590
|
+
`gh ${repoViewArgs.join(' ')}`,
|
|
591
|
+
'object',
|
|
592
|
+
);
|
|
454
593
|
|
|
455
594
|
const data = await ghApi(
|
|
456
595
|
`repos/${nameWithOwner}/commits/HEAD/check-runs`,
|
|
457
596
|
repoRoot,
|
|
458
597
|
deps,
|
|
598
|
+
'object',
|
|
459
599
|
);
|
|
460
600
|
|
|
461
601
|
const checkRuns = data.check_runs;
|
|
@@ -516,12 +656,19 @@ async function checkGithub(repoRoot, deps = {}) {
|
|
|
516
656
|
* or >= 2 remotes with no preference match) — a benign, measured absence
|
|
517
657
|
* - Required CLI (glab / gh) not in PATH
|
|
518
658
|
* - Any CLI invocation times out
|
|
519
|
-
* - JSON parse failure on CLI output
|
|
520
659
|
*
|
|
521
660
|
* Also returns `null`, but with a `console.warn` trace, when the VCS-detection
|
|
522
|
-
* QUERY ITSELF failed (`git` not on PATH, `git remote -v` erroring)
|
|
523
|
-
* present CLI rejected its invocation
|
|
524
|
-
*
|
|
661
|
+
* QUERY ITSELF failed (`git` not on PATH, `git remote -v` erroring), when a
|
|
662
|
+
* present CLI rejected its invocation, or when a CLI returned output this
|
|
663
|
+
* module could not parse (see {@link parseCliJson}). `null` alone cannot
|
|
664
|
+
* express "could not read" — see the outer catch and Step 1 for why the warn
|
|
665
|
+
* channel carries it.
|
|
666
|
+
*
|
|
667
|
+
* The unparseable-output case was listed above as SILENT until 2026-08-28.
|
|
668
|
+
* That was never the behaviour — it has always fallen through to the outer
|
|
669
|
+
* catch's warn branch (measured at 30940cb). The drift survived because the
|
|
670
|
+
* one test covering it asserted only the `null` and let the file-wide
|
|
671
|
+
* `console.warn` spy swallow the rest.
|
|
525
672
|
*
|
|
526
673
|
* @param {{
|
|
527
674
|
* repoRoot?: string,
|