session-orchestrator 3.21.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 +11 -2
- 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/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +10 -10
- 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 +269 -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/memory-proposal-collector.md +6 -4
- 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/eli5.md +33 -0
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/commands/release.md +5 -3
- package/commands/test.md +2 -2
- 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 +7 -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 +52 -5
- package/docs/session-config-reference.md +57 -12
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +26 -16
- 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 +12 -2
- package/hooks/on-session-end.mjs +237 -104
- package/hooks/on-session-start.mjs +404 -110
- 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-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +133 -21
- 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 +3 -1
- package/pi/prompts/eli5.md +12 -0
- 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/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +12 -8
- 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/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -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 +221 -68
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config/vault-integration.mjs +12 -1
- 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/dispatcher/rank.mjs +4 -7
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +84 -11
- package/scripts/lib/gates/gate-helpers.mjs +93 -21
- 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/io.mjs +239 -0
- package/scripts/lib/issue-budget.mjs +284 -31
- 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/owner-interview.mjs +78 -32
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +104 -4
- 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 +165 -7
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- 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 +111 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +467 -72
- 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-auq-clarity.mjs +274 -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 +59 -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-rules.mjs +153 -9
- 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-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- 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/vcs-repo-spec.mjs +182 -13
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +478 -0
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/release.mjs +443 -122
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +6 -0
- package/scripts/validate-wave-scope.mjs +73 -1
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -25
- package/skills/_shared/parallel-aware-preamble.md +35 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +61 -8
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +5 -4
- 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/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/discovery/SKILL.md +28 -24
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/gitlab-ops/SKILL.md +30 -26
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/npm-publish/SKILL.md +4 -4
- package/skills/peekaboo-driver/SKILL.md +18 -6
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +35 -23
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +44 -4
- package/skills/session-end/SKILL.md +70 -24
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +48 -5
- 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 +96 -48
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- 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/session-start/soul.md +110 -0
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- 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 +129 -24
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/templates/_shared/journey-manifest.md +110 -0
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Exports:
|
|
20
20
|
* GENERATOR_MARKER — frontmatter sentinel identifying generator-owned files
|
|
21
|
+
* NARRATIVE_EVENT — canonical telemetry event name for a mirror attempt
|
|
21
22
|
* extractNarrative — pure: STATE.md contents → { waveHistory, deviations, whatNotToRetry, missionStatus }
|
|
22
23
|
* renderNarrative — pure: narrative + repo + now → full markdown (frontmatter + body)
|
|
23
24
|
* writeNarrative — idempotent write with skip-handwritten / skip-noop / dry-run + _overview refusal
|
|
@@ -30,6 +31,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
30
31
|
import path from 'node:path';
|
|
31
32
|
import os from 'node:os';
|
|
32
33
|
|
|
34
|
+
import { emitEvent, sessionAttribution } from '../events.mjs';
|
|
33
35
|
import { parseStateMd, parseMissionStatus } from '../state-md.mjs';
|
|
34
36
|
import {
|
|
35
37
|
parseFrontmatter,
|
|
@@ -557,6 +559,128 @@ function resolveLooseSlug(vaultDir, candidateSlug, fsSeam = {}) {
|
|
|
557
559
|
return matches.length === 1 ? matches[0] : candidateSlug;
|
|
558
560
|
}
|
|
559
561
|
|
|
562
|
+
// ── Telemetry ────────────────────────────────────────────────────────────────────
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Canonical event name for a narrative-mirror attempt (issue #1129).
|
|
566
|
+
*
|
|
567
|
+
* ONE event per {@link mirrorNarrative} call — the REJECTION and NO-OP paths
|
|
568
|
+
* included, because those are the point. Before this existed, a vault-disabled
|
|
569
|
+
* config, a missing STATE.md, a hand-authored target file and a healthy write
|
|
570
|
+
* were all indistinguishable from the ledger's side: measured 2026-08-23 over
|
|
571
|
+
* 28 387 records in `.orchestrator/metrics/events.jsonl`, ZERO board/mirror
|
|
572
|
+
* events, because this module did not import {@link emitEvent} at all. Its only
|
|
573
|
+
* caller is shell prose in `skills/session-end/session-metrics-write.md`, whose
|
|
574
|
+
* `mode: warn` degradation prints a WARNING and closes the session anyway — so
|
|
575
|
+
* an outage of this writer left no durable trace whatsoever.
|
|
576
|
+
*
|
|
577
|
+
* Name deliberately NOT minted here: it is the one the wave assigned, so the
|
|
578
|
+
* fact "a narrative mirror ran" has exactly one address in the ledger.
|
|
579
|
+
*/
|
|
580
|
+
export const NARRATIVE_EVENT = 'orchestrator.vault.narrative_mirrored';
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Emit the narrative-mirror telemetry record. Best-effort: never throws, never
|
|
584
|
+
* alters the mirror result.
|
|
585
|
+
*
|
|
586
|
+
* ABSENT IS NOT ZERO (`docs/events-schema.md`): every optional field is spread
|
|
587
|
+
* conditionally, so an UNMEASURED field is MISSING from the record rather than
|
|
588
|
+
* written as `0`. `chars: 0` would then honestly mean "the narrative rendered to
|
|
589
|
+
* an empty document"; an absent `chars` means "no render was reached on this
|
|
590
|
+
* path" — reading the missing key as `0` conflates the two in both directions.
|
|
591
|
+
*
|
|
592
|
+
* NAMED CEILING (build-value BV-004): when `repoRoot` is absent or empty this
|
|
593
|
+
* emits NOTHING. `emitEvent` without `opts.repoRoot` falls back to
|
|
594
|
+
* `SO_PROJECT_DIR` (#941), so the only destination left for a rootless call is
|
|
595
|
+
* whichever repo the process happens to sit in — i.e. a caller that named no
|
|
596
|
+
* repo would silently append to a FOREIGN ledger, and the two call shapes that
|
|
597
|
+
* reach this branch today are the `repoRoot: ''` / omitted-`repoRoot` unit
|
|
598
|
+
* tests, which would then write synthetic records into this repo's real fleet
|
|
599
|
+
* telemetry on every suite run. REVISIT TRIGGER: if a production caller ever
|
|
600
|
+
* legitimately invokes `mirrorNarrative` without a `repoRoot`, that call needs a
|
|
601
|
+
* destination decision of its own — not this silent skip.
|
|
602
|
+
*
|
|
603
|
+
* @param {object} opts
|
|
604
|
+
* @param {string} [opts.repoRoot] — pins the ledger to THIS repo's
|
|
605
|
+
* `.orchestrator/metrics/events.jsonl` (#941), and supplies session attribution.
|
|
606
|
+
* @param {string} opts.action — the `action` the mirror returned, or `'error'`
|
|
607
|
+
* when the writer threw. ALWAYS present.
|
|
608
|
+
* @param {string} [opts.path] — resolved narrative path, when one was resolved.
|
|
609
|
+
* @param {number} [opts.chars] — length of the rendered narrative document this
|
|
610
|
+
* call produced. Present whenever the render was reached (so also on
|
|
611
|
+
* `skipped-noop` / `dry-run`, where the document was built but not written —
|
|
612
|
+
* `action` is what says whether it landed); absent on the earlier no-op paths,
|
|
613
|
+
* which return before anything is rendered.
|
|
614
|
+
* @param {string} [opts.errorCode] — `err.code` on the throw path (a bounded
|
|
615
|
+
* token such as `EACCES`). The error MESSAGE is deliberately NOT recorded: it
|
|
616
|
+
* can quote a path or STATE.md content, and this module's whole reason for
|
|
617
|
+
* masking (#1025) is that such prose carries secrets.
|
|
618
|
+
* @returns {Promise<void>}
|
|
619
|
+
*/
|
|
620
|
+
/**
|
|
621
|
+
* Reduce an absolute vault path to its LAST TWO segments for telemetry.
|
|
622
|
+
*
|
|
623
|
+
* The full path is the module's public return contract and stays untouched.
|
|
624
|
+
* What must not travel is the path in the EMITTED payload: on a real host it
|
|
625
|
+
* reads `/Users/<name>/Projects/<vault>/01-projects/<private-slug>/…`, i.e. an
|
|
626
|
+
* OS username plus a private project slug. Those are exactly the two shapes
|
|
627
|
+
* `scripts/lib/validate/check-owner-leakage.mjs` blocks as CP1 and CP6 — and
|
|
628
|
+
* that scanner structurally cannot see this one, because it walks `git ls-files`
|
|
629
|
+
* and `.orchestrator/metrics/*.jsonl` is gitignored (`.gitignore:40`).
|
|
630
|
+
* The record is invisible to the pre-commit guard and visible to the optional
|
|
631
|
+
* Clank webhook (`scripts/lib/events.mjs`, `CLANK_EVENT_URL`), which posts the
|
|
632
|
+
* payload verbatim with no redaction.
|
|
633
|
+
*
|
|
634
|
+
* The BASENAME is the deliberate ceiling — one segment, not two. Two segments
|
|
635
|
+
* would keep the parent directory, and under `01-projects/` that directory IS
|
|
636
|
+
* the private project slug, i.e. exactly the CP6 shape this is meant to drop.
|
|
637
|
+
* The diagnostic value lives in the filename alone: it says WHICH writer ran
|
|
638
|
+
* (`_session-narrative.md` vs `_active-sessions.md`), which is the question the
|
|
639
|
+
* event exists to answer. Which project it was is already answerable from the
|
|
640
|
+
* record's own `session_id` / repo-scoped ledger location.
|
|
641
|
+
* Revisit trigger: a consumer that needs more than the filename — then it
|
|
642
|
+
* belongs in the RETURN value, which already carries the absolute path, never
|
|
643
|
+
* in the event.
|
|
644
|
+
*
|
|
645
|
+
* @param {unknown} outputPath
|
|
646
|
+
* @returns {string|undefined} `undefined` when there is nothing measured to report.
|
|
647
|
+
*/
|
|
648
|
+
function telemetrySafePath(outputPath) {
|
|
649
|
+
if (typeof outputPath !== 'string' || outputPath.length === 0) return undefined;
|
|
650
|
+
const base = path.basename(outputPath);
|
|
651
|
+
return base.length > 0 ? base : undefined;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function emitNarrativeEvent({ repoRoot, action, path: outputPath, chars, errorCode }) {
|
|
655
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
|
|
656
|
+
try {
|
|
657
|
+
await emitEvent(
|
|
658
|
+
NARRATIVE_EVENT,
|
|
659
|
+
{
|
|
660
|
+
action,
|
|
661
|
+
...(telemetrySafePath(outputPath) !== undefined ? { path_tail: telemetrySafePath(outputPath) } : {}),
|
|
662
|
+
// `typeof … === 'number'` rather than `!= null`: the repo's eslint
|
|
663
|
+
// `eqeqeq: always` forbids the loose form, and this shape additionally
|
|
664
|
+
// refuses a non-numeric `chars` outright. A measured `0` still lands.
|
|
665
|
+
...(typeof chars === 'number' ? { chars } : {}),
|
|
666
|
+
// `typeof === 'string'`, not truthiness: an `err.code` of '' is a measured
|
|
667
|
+
// empty code, and the sibling `chars` field four lines up already states
|
|
668
|
+
// why this file rejects the loose form.
|
|
669
|
+
...(typeof errorCode === 'string' && errorCode.length > 0 ? { error_code: errorCode } : {}),
|
|
670
|
+
// session_id / semantic_session_id — omitted entirely when no session
|
|
671
|
+
// lock is readable (CI, ad-hoc runs). See sessionAttribution's contract:
|
|
672
|
+
// a fabricated id would read as a real session.
|
|
673
|
+
...sessionAttribution(repoRoot),
|
|
674
|
+
},
|
|
675
|
+
{ repoRoot },
|
|
676
|
+
);
|
|
677
|
+
} catch {
|
|
678
|
+
/* Best-effort telemetry. emitEvent does real file I/O (mkdir + append), so a
|
|
679
|
+
read-only or occupied ledger path WILL throw — and a broken ledger must
|
|
680
|
+
never fail a narrative write. The mirror result is authoritative. */
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
560
684
|
// ── Convenience orchestration ────────────────────────────────────────────────────
|
|
561
685
|
|
|
562
686
|
/**
|
|
@@ -594,10 +718,50 @@ function resolveLooseSlug(vaultDir, candidateSlug, fsSeam = {}) {
|
|
|
594
718
|
* @returns {Promise<{ action: string, path?: string }>}
|
|
595
719
|
*/
|
|
596
720
|
export async function mirrorNarrative(opts) {
|
|
721
|
+
let outcome;
|
|
722
|
+
try {
|
|
723
|
+
outcome = await runNarrativeMirror(opts);
|
|
724
|
+
} catch (err) {
|
|
725
|
+
// The harshest silent-failure case: `session-metrics-write.md` catches this
|
|
726
|
+
// throw, prints a WARNING under `mode: warn` and closes the session anyway.
|
|
727
|
+
// Record it, then re-throw — telemetry observes, it never swallows.
|
|
728
|
+
await emitNarrativeEvent({
|
|
729
|
+
repoRoot: opts?.repoRoot,
|
|
730
|
+
action: 'error',
|
|
731
|
+
errorCode: typeof err?.code === 'string' ? err.code : undefined,
|
|
732
|
+
});
|
|
733
|
+
throw err;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
await emitNarrativeEvent({
|
|
737
|
+
repoRoot: opts?.repoRoot,
|
|
738
|
+
action: outcome.result.action,
|
|
739
|
+
path: outcome.result.path,
|
|
740
|
+
chars: outcome.chars,
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
return outcome.result;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* The mirror itself — every early return of {@link mirrorNarrative} lives here.
|
|
748
|
+
*
|
|
749
|
+
* Split out so that exactly ONE emit site covers EVERY outcome: a future early
|
|
750
|
+
* return added inside this function is telemetered by construction, whereas
|
|
751
|
+
* hand-placing an emit beside each of the seven `return`s makes "forgot the new
|
|
752
|
+
* one" the default failure. The `chars` companion travels beside the result
|
|
753
|
+
* rather than inside it because the returned object is a PUBLIC shape that
|
|
754
|
+
* callers (and tests) compare with `toEqual` — adding a key there would be an
|
|
755
|
+
* observable contract change for a purely internal measurement.
|
|
756
|
+
*
|
|
757
|
+
* @param {Parameters<typeof mirrorNarrative>[0]} opts
|
|
758
|
+
* @returns {Promise<{ result: { action: string, path?: string }, chars?: number }>}
|
|
759
|
+
*/
|
|
760
|
+
async function runNarrativeMirror(opts) {
|
|
597
761
|
const { repoRoot, repo, now = new Date(), dryRun = false, fs: injectedFs, hostPaths } = opts;
|
|
598
762
|
|
|
599
763
|
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
600
|
-
return { action: 'skipped-vault-disabled' };
|
|
764
|
+
return { result: { action: 'skipped-vault-disabled' } };
|
|
601
765
|
}
|
|
602
766
|
|
|
603
767
|
// Read Session Config (CLAUDE.md / AGENTS.md) and resolve vault settings.
|
|
@@ -606,12 +770,12 @@ export async function mirrorNarrative(opts) {
|
|
|
606
770
|
const configText = await readConfigFile(repoRoot);
|
|
607
771
|
config = parseSessionConfig(configText, { hostPaths });
|
|
608
772
|
} catch {
|
|
609
|
-
return { action: 'skipped-vault-disabled' };
|
|
773
|
+
return { result: { action: 'skipped-vault-disabled' } };
|
|
610
774
|
}
|
|
611
775
|
|
|
612
776
|
const vaultIntegration = config?.['vault-integration'];
|
|
613
777
|
if (!vaultIntegration || vaultIntegration.enabled !== true) {
|
|
614
|
-
return { action: 'skipped-vault-disabled' };
|
|
778
|
+
return { result: { action: 'skipped-vault-disabled' } };
|
|
615
779
|
}
|
|
616
780
|
|
|
617
781
|
// Defense-in-depth: when the caller omits (or passes an empty) `repo`, derive
|
|
@@ -628,7 +792,7 @@ export async function mirrorNarrative(opts) {
|
|
|
628
792
|
|
|
629
793
|
const rawVaultDir = vaultIntegration['vault-dir'];
|
|
630
794
|
if (!rawVaultDir || typeof rawVaultDir !== 'string') {
|
|
631
|
-
return { action: 'skipped-vault-disabled' };
|
|
795
|
+
return { result: { action: 'skipped-vault-disabled' } };
|
|
632
796
|
}
|
|
633
797
|
|
|
634
798
|
const vaultDir = path.resolve(expandHome(rawVaultDir));
|
|
@@ -640,9 +804,19 @@ export async function mirrorNarrative(opts) {
|
|
|
640
804
|
const outputPath = resolveNarrativePath(vaultDir, repoSlug);
|
|
641
805
|
|
|
642
806
|
// Defense-in-depth: ensure the resolved file stays inside the vault root.
|
|
643
|
-
|
|
807
|
+
//
|
|
808
|
+
// `canonicalizeRoot: true` (#1033) is load-bearing, not hygiene. The guard's
|
|
809
|
+
// realpath phase is skipped while the target is ABSENT (ENOENT) and fires once
|
|
810
|
+
// it EXISTS — so on a vault root reached through a symlink, run 1 writes and
|
|
811
|
+
// run 2 resolves the file to the canonical path, reads it as outside the
|
|
812
|
+
// LEXICAL root, and returns `skipped-invalid-path` forever after. Canonicalizing
|
|
813
|
+
// the root makes both sides of the comparison the same kind of path. Same call
|
|
814
|
+
// shape as scripts/lib/reconcile/writer.mjs and scripts/lib/memory-proposals/store.mjs.
|
|
815
|
+
const inside = validatePathInsideProject(path.relative(vaultDir, outputPath), vaultDir, {
|
|
816
|
+
canonicalizeRoot: true,
|
|
817
|
+
});
|
|
644
818
|
if (!inside.ok) {
|
|
645
|
-
return { action: 'skipped-invalid-path', path: outputPath };
|
|
819
|
+
return { result: { action: 'skipped-invalid-path', path: outputPath } };
|
|
646
820
|
}
|
|
647
821
|
|
|
648
822
|
// Read STATE.md (best-effort; absent STATE.md → nothing to mirror).
|
|
@@ -651,7 +825,7 @@ export async function mirrorNarrative(opts) {
|
|
|
651
825
|
try {
|
|
652
826
|
stateContents = await readFile(stateMdPath, 'utf8');
|
|
653
827
|
} catch {
|
|
654
|
-
return { action: 'skipped-no-statemd', path: outputPath };
|
|
828
|
+
return { result: { action: 'skipped-no-statemd', path: outputPath } };
|
|
655
829
|
}
|
|
656
830
|
|
|
657
831
|
// #1025: the ONE masking site for the narrative mirror — after the frontmatter
|
|
@@ -660,5 +834,11 @@ export async function mirrorNarrative(opts) {
|
|
|
660
834
|
const narrative = maskNarrative(extractNarrative(stateContents));
|
|
661
835
|
const content = renderNarrative({ repo: repoName, narrative, now });
|
|
662
836
|
|
|
663
|
-
|
|
837
|
+
// `chars` measures the document THIS call rendered — so it is present on
|
|
838
|
+
// `skipped-noop` and `dry-run` too, where the render happened but nothing
|
|
839
|
+
// landed. `action` is the field that says whether it landed.
|
|
840
|
+
return {
|
|
841
|
+
result: writeNarrative({ outputPath, content, dryRun, fs: injectedFs }),
|
|
842
|
+
chars: content.length,
|
|
843
|
+
};
|
|
664
844
|
}
|
|
@@ -227,17 +227,56 @@ const WRONG_FAMILY_HOST = {
|
|
|
227
227
|
github: 'gitlab.com',
|
|
228
228
|
};
|
|
229
229
|
|
|
230
|
+
/** URI schemes that can name a supported Git remote. */
|
|
231
|
+
const REMOTE_URI_PROTOCOLS = new Set(['http:', 'https:', 'ssh:']);
|
|
232
|
+
|
|
230
233
|
/**
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
+
* Parse a URI-style remote only when it uses one of this module's supported
|
|
235
|
+
* protocols. The `URL` parser makes hostname/port handling consistent between
|
|
236
|
+
* HTTP(S) and URI-style SSH while scp-style SSH stays a separate grammar.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} url
|
|
239
|
+
* @returns {URL|null}
|
|
240
|
+
*/
|
|
241
|
+
function parseRemoteUri(url) {
|
|
242
|
+
if (typeof url !== 'string' || !/^(?:https?|ssh):\/\//i.test(url)) return null;
|
|
243
|
+
try {
|
|
244
|
+
const parsed = new URL(url);
|
|
245
|
+
return REMOTE_URI_PROTOCOLS.has(parsed.protocol) ? parsed : null;
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Extract the operational host from a git remote URL, handling HTTPS
|
|
253
|
+
* (`https://host/owner/repo.git`), scp-style SSH (`git@host:owner/repo.git`),
|
|
254
|
+
* and URI-style SSH (`ssh://git@host/owner/repo.git`) forms. A non-default
|
|
255
|
+
* URI port is preserved because callers may need it to address a self-hosted
|
|
256
|
+
* instance. Returns `null` for an unrecognized shape (never throws).
|
|
234
257
|
*
|
|
235
258
|
* @param {string} url
|
|
236
259
|
* @returns {string|null}
|
|
237
260
|
*/
|
|
238
261
|
function extractHost(url) {
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
262
|
+
const parsed = parseRemoteUri(url);
|
|
263
|
+
if (parsed !== null) return parsed.host.toLowerCase() || null;
|
|
264
|
+
const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
|
|
265
|
+
if (sshMatch) return sshMatch[1].toLowerCase();
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Extract a bare hostname for VCS-family comparisons. This deliberately drops
|
|
271
|
+
* a URI port: `github.com:443` is still the public GitHub host, while the
|
|
272
|
+
* operational `extractHost()` value retains a non-default self-hosted port.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} url
|
|
275
|
+
* @returns {string|null}
|
|
276
|
+
*/
|
|
277
|
+
function extractHostname(url) {
|
|
278
|
+
const parsed = parseRemoteUri(url);
|
|
279
|
+
if (parsed !== null) return parsed.hostname.toLowerCase() || null;
|
|
241
280
|
const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
|
|
242
281
|
if (sshMatch) return sshMatch[1].toLowerCase();
|
|
243
282
|
return null;
|
|
@@ -367,12 +406,12 @@ export function resolveRepoSpec({ repoRoot, vcs = 'gitlab', gitRun = defaultGitR
|
|
|
367
406
|
}
|
|
368
407
|
|
|
369
408
|
/**
|
|
370
|
-
* Resolve the
|
|
371
|
-
* `glab api --hostname`/`gh api --hostname
|
|
372
|
-
* CLIs does NOT accept `-R`/`--repo` (it has no
|
|
373
|
-
* `--hostname`
|
|
374
|
-
* host-pinning counterpart to `resolveRepoSpec` for those api-only
|
|
375
|
-
* sites.
|
|
409
|
+
* Resolve the operational host of the matching remote, preserving a
|
|
410
|
+
* non-default self-hosted port for `glab api --hostname`/`gh api --hostname`.
|
|
411
|
+
* The `api` subcommand of both CLIs does NOT accept `-R`/`--repo` (it has no
|
|
412
|
+
* repo concept), only `--hostname` to pin which instance the request targets.
|
|
413
|
+
* This is the host-pinning counterpart to `resolveRepoSpec` for those api-only
|
|
414
|
+
* call sites.
|
|
376
415
|
*
|
|
377
416
|
* Applies the identical remote-preference-order + cross-family-guard
|
|
378
417
|
* resolution as `resolveRepoSpec`, just returning the host instead of the
|
|
@@ -393,6 +432,136 @@ export function resolveRepoHost({ repoRoot, vcs, gitRun } = {}) {
|
|
|
393
432
|
return isUnsafeForArgv(host) ? undefined : host;
|
|
394
433
|
}
|
|
395
434
|
|
|
435
|
+
/** Project-path characters that cannot name a GitLab namespace/project. */
|
|
436
|
+
// eslint-disable-next-line no-control-regex -- validate every C0/C1 control before encoding a GitLab API path
|
|
437
|
+
const UNSAFE_PROJECT_PATH_CHARS_RE = /[\\\x00-\x1f\x7f-\x9f?#]/;
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Decode a transport project path exactly once, then validate its canonical
|
|
441
|
+
* namespace/project shape. A percent sign surviving the one decode is rejected
|
|
442
|
+
* because `%252e%252e` is indistinguishable from a literal encoded escape; do
|
|
443
|
+
* not normalize that ambiguity into an API target.
|
|
444
|
+
*
|
|
445
|
+
* @param {string} rawProjectPath
|
|
446
|
+
* @param {{ uriPath: boolean }} opts
|
|
447
|
+
* @returns {string|undefined}
|
|
448
|
+
*/
|
|
449
|
+
function normalizeGitlabProjectPath(rawProjectPath, { uriPath }) {
|
|
450
|
+
if (typeof rawProjectPath !== 'string') return undefined;
|
|
451
|
+
|
|
452
|
+
let projectPath = rawProjectPath;
|
|
453
|
+
if (uriPath) {
|
|
454
|
+
// URI syntax supplies one separator before the path. More than one is a
|
|
455
|
+
// path segment, not syntax to trim away.
|
|
456
|
+
if (!projectPath.startsWith('/') || projectPath.startsWith('//')) return undefined;
|
|
457
|
+
projectPath = projectPath.slice(1);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (
|
|
461
|
+
projectPath === '' ||
|
|
462
|
+
projectPath.startsWith('/') ||
|
|
463
|
+
projectPath.endsWith('/') ||
|
|
464
|
+
UNSAFE_PROJECT_PATH_CHARS_RE.test(projectPath)
|
|
465
|
+
) {
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let decodedProjectPath;
|
|
470
|
+
try {
|
|
471
|
+
decodedProjectPath = decodeURIComponent(projectPath);
|
|
472
|
+
} catch {
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (
|
|
477
|
+
decodedProjectPath.includes('%') ||
|
|
478
|
+
UNSAFE_PROJECT_PATH_CHARS_RE.test(decodedProjectPath) ||
|
|
479
|
+
isUnsafeForArgv(decodedProjectPath)
|
|
480
|
+
) {
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const withoutGitSuffix = decodedProjectPath.replace(/\.git$/i, '');
|
|
485
|
+
const segments = withoutGitSuffix.split('/');
|
|
486
|
+
if (
|
|
487
|
+
withoutGitSuffix === '' ||
|
|
488
|
+
withoutGitSuffix.startsWith('/') ||
|
|
489
|
+
withoutGitSuffix.endsWith('/') ||
|
|
490
|
+
segments.length < 2 ||
|
|
491
|
+
segments.some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
492
|
+
) {
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return withoutGitSuffix;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Extract an operational GitLab host and the raw (not URL-normalized) project
|
|
501
|
+
* path. URI parsing owns authority/port validation, while the raw path keeps
|
|
502
|
+
* dot and percent-encoded traversal visible to {@link normalizeGitlabProjectPath}
|
|
503
|
+
* before WHATWG URL normalization could erase it.
|
|
504
|
+
*
|
|
505
|
+
* @param {string} url
|
|
506
|
+
* @returns {{ host: string, rawProjectPath: string, uriPath: boolean }|undefined}
|
|
507
|
+
*/
|
|
508
|
+
function extractGitlabProjectTargetParts(url) {
|
|
509
|
+
const scpMatch = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(url);
|
|
510
|
+
if (scpMatch) {
|
|
511
|
+
return { host: scpMatch[1], rawProjectPath: scpMatch[2], uriPath: false };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const parsed = parseRemoteUri(url);
|
|
515
|
+
if (
|
|
516
|
+
parsed === null ||
|
|
517
|
+
parsed.search !== '' ||
|
|
518
|
+
parsed.hash !== '' ||
|
|
519
|
+
((parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
|
520
|
+
(parsed.username !== '' || parsed.password !== ''))
|
|
521
|
+
) {
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// `parsed.pathname` is intentionally NOT used: the URL parser resolves
|
|
526
|
+
// `.`/`..` before this boundary can reject them. The regex reads only the
|
|
527
|
+
// path from an already-parsed, supported URI.
|
|
528
|
+
const rawPathMatch = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?(?:[?#].*)?$/i.exec(url);
|
|
529
|
+
if (rawPathMatch === null) return undefined;
|
|
530
|
+
return { host: parsed.host, rawProjectPath: rawPathMatch[1] ?? '', uriPath: true };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Derive the API target for a GitLab project from the same sanitized,
|
|
535
|
+
* preference-selected remote that powers {@link resolveRepoSpec}. GitLab's REST
|
|
536
|
+
* API takes a URL-encoded `namespace/project` path rather than a remote URL or
|
|
537
|
+
* numeric project ID, so this projection removes the ambient project-metadata
|
|
538
|
+
* lookup from callers that need to pin both project and host.
|
|
539
|
+
*
|
|
540
|
+
* Supports HTTPS, scp-style SSH, and `ssh://` remotes. Credential stripping
|
|
541
|
+
* happens upstream in {@link listRemotes}; this helper returns only an
|
|
542
|
+
* operational host (including a non-default self-hosted port) and a once-only
|
|
543
|
+
* encoded project path, never a remote URL or HTTP userinfo. An SSH login such
|
|
544
|
+
* as `git@host` is transport identity, not project-path userinfo.
|
|
545
|
+
*
|
|
546
|
+
* @param {{ repoRoot?: string, gitRun?: GitRun }} [opts]
|
|
547
|
+
* @returns {{ host: string, encodedProjectPath: string }|undefined}
|
|
548
|
+
*/
|
|
549
|
+
export function resolveGitlabProjectTarget({ repoRoot, gitRun = defaultGitRun } = {}) {
|
|
550
|
+
const url = resolveRawRemoteUrl({ repoRoot, vcs: 'gitlab', gitRun });
|
|
551
|
+
if (!url) return undefined;
|
|
552
|
+
|
|
553
|
+
const parts = extractGitlabProjectTargetParts(url);
|
|
554
|
+
if (!parts || isUnsafeForArgv(parts.host)) return undefined;
|
|
555
|
+
|
|
556
|
+
const projectPath = normalizeGitlabProjectPath(parts.rawProjectPath, { uriPath: parts.uriPath });
|
|
557
|
+
if (!projectPath) return undefined;
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
host: parts.host.toLowerCase(),
|
|
561
|
+
encodedProjectPath: encodeURIComponent(projectPath),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
396
565
|
/**
|
|
397
566
|
* @deprecated Back-compat alias for `archive-closed-prds.mjs`'s original
|
|
398
567
|
* `defaultGlabRepo(repoRoot, gitRunFn)` positional signature (gitlab-only).
|
|
@@ -636,7 +805,7 @@ export function resolvePreferredRemote({ repoRoot, vcs, gitRun = defaultGitRun }
|
|
|
636
805
|
const vcsPinned = vcs === 'github' || vcs === 'gitlab' ? vcs : null;
|
|
637
806
|
const order = vcsPinned ? REMOTE_PREFERENCE[vcsPinned] : VCS_LESS_PREFERENCE;
|
|
638
807
|
const wrongFamilyHost = vcsPinned ? WRONG_FAMILY_HOST[vcsPinned] : null;
|
|
639
|
-
const isWrongFamily = (url) => wrongFamilyHost !== null &&
|
|
808
|
+
const isWrongFamily = (url) => wrongFamilyHost !== null && extractHostname(url) === wrongFamilyHost;
|
|
640
809
|
|
|
641
810
|
/** @param {GitRemote} remote @param {'preference'|'sole-remote'} via */
|
|
642
811
|
const accept = (remote, via) =>
|
|
@@ -670,7 +839,7 @@ export function resolvePreferredRemote({ repoRoot, vcs, gitRun = defaultGitRun }
|
|
|
670
839
|
* @returns {{ family: 'gitlab'|'github', via: 'host-match'|'remote-name' }|null}
|
|
671
840
|
*/
|
|
672
841
|
function classifyRemoteFamily(remote) {
|
|
673
|
-
const host =
|
|
842
|
+
const host = extractHostname(remote.url);
|
|
674
843
|
if (host !== null) {
|
|
675
844
|
if (host === 'github.com' || host.startsWith('github.')) return { family: 'github', via: 'host-match' };
|
|
676
845
|
if (host === 'gitlab.com' || host.startsWith('gitlab.')) return { family: 'gitlab', via: 'host-match' };
|