session-orchestrator 3.20.0 → 3.22.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/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- 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 +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- 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/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- 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/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- 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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// frontmatter-block.mjs — Extract the leading `---`-fenced YAML block from a Markdown file.
|
|
2
|
+
//
|
|
3
|
+
// SHARED: THE EXTRACTION. NOT SHARED: THE RULES.
|
|
4
|
+
//
|
|
5
|
+
// This module owns exactly one thing — finding the frontmatter block's byte range and
|
|
6
|
+
// handing back its text. It deliberately owns NOTHING about what a valid frontmatter
|
|
7
|
+
// contains. Do not grow it into a validator, and do not "unify" the callers' rules
|
|
8
|
+
// against it: the three frontmatter checkers in this directory disagree on purpose.
|
|
9
|
+
//
|
|
10
|
+
// * check-agents.mjs BANS `description: >` — the agent loader cannot read a folded
|
|
11
|
+
// block scalar, so the form is a live defect for agents/*.md.
|
|
12
|
+
// * check-skills.mjs ALLOWS `description: >` — for SKILL.md the sign is REVERSED: the
|
|
13
|
+
// folded scalar is the only form that makes the `: `
|
|
14
|
+
// collision inside an unquoted description structurally
|
|
15
|
+
// impossible. Measured 2026-08-15: 23 of 46 SKILL.md files
|
|
16
|
+
// use it, and porting the agent ban here would red 35 of 46.
|
|
17
|
+
// * check-commands.mjs has its own, narrower field contract again (`argument-hint`).
|
|
18
|
+
//
|
|
19
|
+
// Those divergences are the product requirement, not drift. What WAS drift is this
|
|
20
|
+
// function: it stood verbatim in check-skills.mjs and check-commands.mjs, so the next
|
|
21
|
+
// change to the block format (a BOM, a new delimiter tolerance) would have been made in
|
|
22
|
+
// one copy and one gate would have started accepting what the other rejects — with no
|
|
23
|
+
// test able to see it, because each gate tested its own copy.
|
|
24
|
+
//
|
|
25
|
+
// BEHAVIOUR IS PINNED, NOT ASPIRATIONAL. tests/lib/validate/frontmatter-block.test.mjs
|
|
26
|
+
// documents what the body does today, including the two sharp edges below. Both are
|
|
27
|
+
// intentional records of the status quo, NOT endorsements — changing either is a
|
|
28
|
+
// behaviour change under two gates at once and needs its own task, not a drive-by edit.
|
|
29
|
+
//
|
|
30
|
+
// 1. CRLF input is handled: the split is `/\r?\n/`, so a CRLF file's first line
|
|
31
|
+
// compares equal to '---' and the returned yamlText is LF-normalised.
|
|
32
|
+
// 2. A line that is exactly `---` INSIDE the block (e.g. an unindented `---` inside a
|
|
33
|
+
// multi-line string) terminates the block early. Only a column-0, whitespace-free
|
|
34
|
+
// `---` does this; an indented ` ---` is ordinary content.
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extract the YAML frontmatter block delimited by the leading `---` fence.
|
|
38
|
+
*
|
|
39
|
+
* The opening fence must be the very first line of the file. The closing fence is the
|
|
40
|
+
* first subsequent line equal to `---`. Line endings may be LF or CRLF; the returned
|
|
41
|
+
* text is always LF-joined.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} content - full file text
|
|
44
|
+
* @returns {{ ok: true, yamlText: string } | { ok: false, diagnostic: string }}
|
|
45
|
+
* On success, `yamlText` is the block's inner text with no fences (empty string for an
|
|
46
|
+
* empty block). On failure, `diagnostic` is a caller-printable reason and there is no
|
|
47
|
+
* `yamlText` — callers must not fall through to a parse.
|
|
48
|
+
*/
|
|
49
|
+
export function extractInitialFrontmatter(content) {
|
|
50
|
+
const lines = content.split(/\r?\n/);
|
|
51
|
+
if (lines[0] !== '---') {
|
|
52
|
+
return { ok: false, diagnostic: 'missing YAML frontmatter opening delimiter' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const closingDelimiter = lines.indexOf('---', 1);
|
|
56
|
+
if (closingDelimiter === -1) {
|
|
57
|
+
return { ok: false, diagnostic: 'missing YAML frontmatter closing delimiter' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { ok: true, yamlText: lines.slice(1, closingDelimiter).join('\n') };
|
|
61
|
+
}
|
|
@@ -25,7 +25,36 @@ const WRITE_TOOLS = new Set(['Edit', 'Write']);
|
|
|
25
25
|
|
|
26
26
|
// Tools that are acceptable in the read-only tier (Bash is fine — fine-grained
|
|
27
27
|
// Bash control lives in hooks/pre-bash-destructive-guard.mjs, NOT here).
|
|
28
|
-
|
|
28
|
+
//
|
|
29
|
+
// SendMessage / ListAgents are pure agent-to-coordinator communication surfaces
|
|
30
|
+
// with no filesystem write path, so they do not lift an agent out of read-only
|
|
31
|
+
// (#1049, PRD § 2 A5). Without them here, every read-only agent that opts into
|
|
32
|
+
// SendMessage silently infers `repo-write` and trips validateTierConsistency.
|
|
33
|
+
const READ_ONLY_TOOLS = new Set([
|
|
34
|
+
'Read',
|
|
35
|
+
'Grep',
|
|
36
|
+
'Glob',
|
|
37
|
+
'Bash',
|
|
38
|
+
'Skill',
|
|
39
|
+
'SendMessage',
|
|
40
|
+
'ListAgents',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Normalise one raw frontmatter tool entry to its lookup key.
|
|
45
|
+
*
|
|
46
|
+
* Single source of truth for the "Skill(...)" → "Skill" collapse: both
|
|
47
|
+
* inferTierFromTools and validateTierConsistency's detail text must agree on
|
|
48
|
+
* what a tool entry IS, or the error message names offenders the inference
|
|
49
|
+
* never objected to.
|
|
50
|
+
*
|
|
51
|
+
* @param {unknown} t - raw entry from the frontmatter tools array
|
|
52
|
+
* @returns {string} lookup key
|
|
53
|
+
*/
|
|
54
|
+
function normaliseTool(t) {
|
|
55
|
+
if (typeof t !== 'string') return String(t);
|
|
56
|
+
return t.startsWith('Skill(') ? 'Skill' : t.trim();
|
|
57
|
+
}
|
|
29
58
|
|
|
30
59
|
/**
|
|
31
60
|
* Infer the sandbox tier from a parsed tools array.
|
|
@@ -47,10 +76,7 @@ export function inferTierFromTools(toolsArray) {
|
|
|
47
76
|
}
|
|
48
77
|
|
|
49
78
|
// Normalise "Skill(...)" → "Skill"
|
|
50
|
-
const normalised = toolsArray.map(
|
|
51
|
-
if (typeof t !== 'string') return String(t);
|
|
52
|
-
return t.startsWith('Skill(') ? 'Skill' : t.trim();
|
|
53
|
-
});
|
|
79
|
+
const normalised = toolsArray.map(normaliseTool);
|
|
54
80
|
|
|
55
81
|
// Any write tool → repo-write
|
|
56
82
|
for (const t of normalised) {
|
|
@@ -86,10 +112,22 @@ export function validateTierConsistency({ declared, inferred, tools }) {
|
|
|
86
112
|
|
|
87
113
|
// 2. Read-only agent must not have write tools.
|
|
88
114
|
if (declared === 'read-only' && inferred !== 'read-only') {
|
|
89
|
-
|
|
90
|
-
|
|
115
|
+
// Name EVERY tool responsible for the verdict, not only the write tools.
|
|
116
|
+
// inferTierFromTools falls through to `repo-write` for any UNRECOGNISED
|
|
117
|
+
// tool too, and filtering the detail text on WRITE_TOOLS alone reported
|
|
118
|
+
// "tools suggest repo-write" with no culprit named in exactly that case
|
|
119
|
+
// (#1049). Normalised via the same helper the inference uses, so a
|
|
120
|
+
// "Skill(...)" entry is never listed as an offender.
|
|
121
|
+
const offenders = Array.isArray(tools)
|
|
122
|
+
? [
|
|
123
|
+
...new Set(
|
|
124
|
+
tools
|
|
125
|
+
.map(normaliseTool)
|
|
126
|
+
.filter((t) => WRITE_TOOLS.has(t) || !READ_ONLY_TOOLS.has(t)),
|
|
127
|
+
),
|
|
128
|
+
]
|
|
91
129
|
: [];
|
|
92
|
-
const detail =
|
|
130
|
+
const detail = offenders.length > 0 ? ` (tools include: ${offenders.join(', ')})` : '';
|
|
93
131
|
return {
|
|
94
132
|
ok: false,
|
|
95
133
|
error: `agent declares sandbox-tier "read-only" but tools suggest "${inferred}"${detail}`,
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
9
|
|
|
10
|
+
import { redactUrlCredentials } from '../vcs-repo-spec.mjs';
|
|
11
|
+
import { isValidRepoPath } from './manifest.mjs';
|
|
12
|
+
|
|
10
13
|
let _verbose = false;
|
|
11
14
|
|
|
12
15
|
/** Enable verbose stderr logging. */
|
|
@@ -38,7 +41,7 @@ export function assertGlabExists(dieFn) {
|
|
|
38
41
|
* Run a glab command, return { ok, stdout, stderr }.
|
|
39
42
|
*
|
|
40
43
|
* Host-pinning (#872): deliberately ambient — this module runs instance-wide
|
|
41
|
-
* queries (
|
|
44
|
+
* queries (`glab api groups/<group>/projects`) that are not scoped to a single
|
|
42
45
|
* repo/project, so there is no single `-R`/`--repo` spec to pin. If a
|
|
43
46
|
* caller ever needs single-repo host-pinning here, use `--hostname` (the
|
|
44
47
|
* flag `glab api` and instance-wide subcommands accept), NOT `-R`/`--repo` —
|
|
@@ -46,79 +49,129 @@ export function assertGlabExists(dieFn) {
|
|
|
46
49
|
* `resolveRepoHost` (`--hostname`) contract this repo already established.
|
|
47
50
|
*/
|
|
48
51
|
export function glabRun(glabArgs) {
|
|
49
|
-
vlog(`glab ${glabArgs.join(' ')}`);
|
|
52
|
+
vlog(`glab ${redactUrlCredentials(glabArgs.join(' '))}`);
|
|
50
53
|
const result = spawnSync('glab', glabArgs, {
|
|
51
54
|
encoding: 'utf8',
|
|
52
55
|
maxBuffer: 10 * 1024 * 1024,
|
|
53
56
|
});
|
|
54
57
|
|
|
55
58
|
if (result.error) {
|
|
56
|
-
return { ok: false, stdout: '', stderr: result.error.message };
|
|
59
|
+
return { ok: false, stdout: '', stderr: redactUrlCredentials(result.error.message) };
|
|
57
60
|
}
|
|
58
61
|
return {
|
|
59
62
|
ok: result.status === 0,
|
|
60
63
|
stdout: result.stdout || '',
|
|
61
|
-
stderr: result.stderr || '',
|
|
64
|
+
stderr: redactUrlCredentials(result.stderr || ''),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const PROJECT_VISIBILITIES = new Set(['private', 'internal', 'public']);
|
|
69
|
+
const ISO_TIMESTAMP = /^(?<year>[1-9]\d{3})-(?<month>\d{2})-(?<day>\d{2})T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* GitLab returns `created_at` as an ISO-8601 timestamp. Validate the calendar
|
|
73
|
+
* components separately because Date normalizes impossible dates such as Feb 30.
|
|
74
|
+
*/
|
|
75
|
+
function isValidCreatedAt(value) {
|
|
76
|
+
if (typeof value !== 'string') return false;
|
|
77
|
+
|
|
78
|
+
const match = ISO_TIMESTAMP.exec(value);
|
|
79
|
+
if (!match?.groups) return false;
|
|
80
|
+
|
|
81
|
+
const { year, month, day } = match.groups;
|
|
82
|
+
const calendarDate = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
|
83
|
+
return (
|
|
84
|
+
calendarDate.getUTCFullYear() === Number(year) &&
|
|
85
|
+
calendarDate.getUTCMonth() === Number(month) - 1 &&
|
|
86
|
+
calendarDate.getUTCDate() === Number(day)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Reduce a GitLab project response to the fields vault-backfill consumes.
|
|
92
|
+
*
|
|
93
|
+
* The group API with `simple=true` still includes presentation and URL fields.
|
|
94
|
+
* Keeping only this projection prevents those opaque project objects from
|
|
95
|
+
* flowing into logs or downstream actions.
|
|
96
|
+
*/
|
|
97
|
+
function normalizeRepo(project) {
|
|
98
|
+
return {
|
|
99
|
+
id: project.id,
|
|
100
|
+
path: project.path_with_namespace ?? project.path,
|
|
101
|
+
visibility: project.visibility === undefined ? 'private' : project.visibility,
|
|
102
|
+
createdAt: project.created_at === undefined ? '' : project.created_at.slice(0, 10),
|
|
62
103
|
};
|
|
63
104
|
}
|
|
64
105
|
|
|
106
|
+
function isSimpleProject(project) {
|
|
107
|
+
const path = project?.path_with_namespace ?? project?.path;
|
|
108
|
+
return (
|
|
109
|
+
project !== null &&
|
|
110
|
+
typeof project === 'object' &&
|
|
111
|
+
!Array.isArray(project) &&
|
|
112
|
+
Number.isInteger(project.id) &&
|
|
113
|
+
project.id > 0 &&
|
|
114
|
+
isValidRepoPath(path) &&
|
|
115
|
+
(project.visibility === undefined || PROJECT_VISIBILITIES.has(project.visibility)) &&
|
|
116
|
+
(project.created_at === undefined || isValidCreatedAt(project.created_at))
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
65
120
|
/**
|
|
66
|
-
* Parse `glab
|
|
67
|
-
*
|
|
121
|
+
* Parse `glab api --paginate --slurp` output (a project array or page arrays).
|
|
122
|
+
*
|
|
123
|
+
* Returns an array of { id, path, visibility, createdAt }, or null when an
|
|
124
|
+
* exit-zero response does not fully match the simple-project list contract.
|
|
68
125
|
*/
|
|
69
126
|
export function parseRepoList(stdout) {
|
|
70
127
|
const trimmed = stdout.trim();
|
|
71
|
-
if (!trimmed) return
|
|
128
|
+
if (!trimmed) return null;
|
|
72
129
|
|
|
130
|
+
let data;
|
|
73
131
|
try {
|
|
74
|
-
|
|
75
|
-
if (!Array.isArray(data)) return [];
|
|
76
|
-
return data.map((r) => ({
|
|
77
|
-
id: r.id ?? 0,
|
|
78
|
-
path: r.path_with_namespace ?? r.path ?? '',
|
|
79
|
-
name: r.name ?? '',
|
|
80
|
-
visibility: r.visibility ?? 'private',
|
|
81
|
-
createdAt: (r.created_at ?? '').slice(0, 10),
|
|
82
|
-
}));
|
|
132
|
+
data = JSON.parse(trimmed);
|
|
83
133
|
} catch {
|
|
84
|
-
|
|
85
|
-
const repos = [];
|
|
86
|
-
for (const line of trimmed.split('\n')) {
|
|
87
|
-
const l = line.trim();
|
|
88
|
-
if (!l) continue;
|
|
89
|
-
try {
|
|
90
|
-
const r = JSON.parse(l);
|
|
91
|
-
repos.push({
|
|
92
|
-
id: r.id ?? 0,
|
|
93
|
-
path: r.path_with_namespace ?? r.path ?? '',
|
|
94
|
-
name: r.name ?? '',
|
|
95
|
-
visibility: r.visibility ?? 'private',
|
|
96
|
-
createdAt: (r.created_at ?? '').slice(0, 10),
|
|
97
|
-
});
|
|
98
|
-
} catch {
|
|
99
|
-
// skip malformed line
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return repos;
|
|
134
|
+
return null;
|
|
103
135
|
}
|
|
136
|
+
|
|
137
|
+
if (!Array.isArray(data)) return null;
|
|
138
|
+
if (data.length === 0) return [];
|
|
139
|
+
|
|
140
|
+
const projects = data.every(isSimpleProject)
|
|
141
|
+
? data
|
|
142
|
+
: data.every(Array.isArray)
|
|
143
|
+
? data.flat()
|
|
144
|
+
: null;
|
|
145
|
+
|
|
146
|
+
if (!projects || !projects.every(isSimpleProject)) return null;
|
|
147
|
+
return projects.map(normalizeRepo);
|
|
104
148
|
}
|
|
105
149
|
|
|
106
150
|
/**
|
|
107
151
|
* List all repos in a GitLab group. Returns repo array or null on API error.
|
|
108
152
|
*/
|
|
109
153
|
export function listGroupRepos(group) {
|
|
154
|
+
const encodedGroup = encodeURIComponent(group);
|
|
110
155
|
const { ok, stdout, stderr } = glabRun([
|
|
111
|
-
'
|
|
156
|
+
'api', `groups/${encodedGroup}/projects?simple=true&per_page=100`, '--paginate', '--slurp',
|
|
112
157
|
]);
|
|
113
158
|
|
|
114
159
|
if (!ok) {
|
|
115
160
|
process.stderr.write(
|
|
116
|
-
`[vault-backfill] WARN: glab
|
|
161
|
+
`[vault-backfill] WARN: glab api groups/<group>/projects failed for group '${group}': ${stderr.trim()}\n`,
|
|
162
|
+
);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const repos = parseRepoList(stdout);
|
|
167
|
+
if (repos === null) {
|
|
168
|
+
process.stderr.write(
|
|
169
|
+
`[vault-backfill] WARN: glab api groups/<group>/projects returned an unexpected project-list response shape for group '${group}'\n`,
|
|
117
170
|
);
|
|
118
171
|
return null;
|
|
119
172
|
}
|
|
120
173
|
|
|
121
|
-
return
|
|
174
|
+
return repos;
|
|
122
175
|
}
|
|
123
176
|
|
|
124
177
|
/**
|
|
@@ -150,23 +203,3 @@ export function checkVaultYaml(repoPath) {
|
|
|
150
203
|
);
|
|
151
204
|
return 'error';
|
|
152
205
|
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Fetch repo owner via glab API. Returns username or 'unknown' on failure.
|
|
156
|
-
*/
|
|
157
|
-
export function fetchRepoOwner(repoId) {
|
|
158
|
-
const { ok, stdout } = glabRun(['api', `projects/${repoId}`]);
|
|
159
|
-
if (!ok) return 'unknown';
|
|
160
|
-
|
|
161
|
-
try {
|
|
162
|
-
const data = JSON.parse(stdout);
|
|
163
|
-
return (
|
|
164
|
-
data?.namespace?.path ||
|
|
165
|
-
data?.owner?.username ||
|
|
166
|
-
data?.namespace?.name ||
|
|
167
|
-
'unknown'
|
|
168
|
-
);
|
|
169
|
-
} catch {
|
|
170
|
-
return 'unknown';
|
|
171
|
-
}
|
|
172
|
-
}
|
|
@@ -7,8 +7,34 @@
|
|
|
7
7
|
|
|
8
8
|
const VALID_TIERS = new Set(['top', 'active', 'archived']);
|
|
9
9
|
const VALID_VISIBILITIES = new Set(['public', 'internal', 'private']);
|
|
10
|
+
const GITLAB_PATH_SEGMENT_RE = /^[A-Za-z0-9_.-]+$/;
|
|
10
11
|
export const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Return whether a value is a canonical GitLab namespace/project path.
|
|
15
|
+
*
|
|
16
|
+
* GitLab project paths have at least one namespace and one project segment.
|
|
17
|
+
* Each segment uses the slug character set; dot segments remain excluded so
|
|
18
|
+
* the result is safe to use as a path below a controlled staging directory.
|
|
19
|
+
*
|
|
20
|
+
* @param {unknown} value
|
|
21
|
+
* @returns {value is string}
|
|
22
|
+
*/
|
|
23
|
+
export function isValidRepoPath(value) {
|
|
24
|
+
if (typeof value !== 'string') return false;
|
|
25
|
+
|
|
26
|
+
const segments = value.split('/');
|
|
27
|
+
return (
|
|
28
|
+
segments.length >= 2 &&
|
|
29
|
+
segments.every(
|
|
30
|
+
(segment) =>
|
|
31
|
+
segment !== '.' &&
|
|
32
|
+
segment !== '..' &&
|
|
33
|
+
GITLAB_PATH_SEGMENT_RE.test(segment),
|
|
34
|
+
)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
12
38
|
/**
|
|
13
39
|
* Validate and normalise a manifest object.
|
|
14
40
|
* Calls dieFn(1, ...) on any schema violation.
|
|
@@ -34,14 +60,8 @@ export function validateManifest(raw, dieFn) {
|
|
|
34
60
|
const prefix = `manifest.repos[${i}]`;
|
|
35
61
|
|
|
36
62
|
if (typeof entry.id !== 'number') dieFn(1, `${prefix}.id must be a number`);
|
|
37
|
-
if (
|
|
38
|
-
dieFn(1, `${prefix}.path must be a
|
|
39
|
-
}
|
|
40
|
-
if (entry.path.startsWith('/') || entry.path.split('/').includes('..')) {
|
|
41
|
-
dieFn(
|
|
42
|
-
1,
|
|
43
|
-
`${prefix}.path '${entry.path}' is invalid — must be a relative repo path (no leading '/' or '..' segments)`,
|
|
44
|
-
);
|
|
63
|
+
if (!isValidRepoPath(entry.path)) {
|
|
64
|
+
dieFn(1, `${prefix}.path must be a valid GitLab namespace/project path`);
|
|
45
65
|
}
|
|
46
66
|
if (typeof entry.slug !== 'string') dieFn(1, `${prefix}.slug must be a string`);
|
|
47
67
|
if (!SLUG_RE.test(entry.slug)) {
|
|
@@ -22,13 +22,31 @@
|
|
|
22
22
|
* - Redacts UNMAPPED owner-privacy leaks (CP1/CP6/CP10) to 'redacted-repo' +
|
|
23
23
|
* stderr WARN — identical to pre-#725 behaviour when no map is configured.
|
|
24
24
|
* - Falls back to 'unknown-repo' when slug derivation produces an empty string.
|
|
25
|
+
*
|
|
26
|
+
* Dependency direction (issue #734b): this module OWNS {@link deriveRepo}; it does
|
|
27
|
+
* NOT import from `./process.mjs`. Until #734b, `deriveRepo` lived in `process.mjs`
|
|
28
|
+
* while `process.mjs` imported `resolveRepoNamespace` from here — the repo's only
|
|
29
|
+
* import cycle (`namespace.mjs ↔ process.mjs`). The cycle was broken by moving the
|
|
30
|
+
* *identity* half down here (this module is the repo-identity resolver; `process.mjs`
|
|
31
|
+
* is the record-mirroring pipeline that CONSUMES an identity), and `process.mjs`
|
|
32
|
+
* re-exports `deriveRepo` from here so its public surface is unchanged.
|
|
33
|
+
*
|
|
34
|
+
* The direction is load-bearing beyond cycle-breaking: three modules
|
|
35
|
+
* (`vault-repo-backfill.mjs`, `vault-relocation-rules.mjs`, `scripts/vault-mirror.mjs`)
|
|
36
|
+
* import ONLY `resolveRepoNamespace` and previously dragged the entire `process.mjs`
|
|
37
|
+
* graph (secret-masker, render-learnings, render-sessions, session-schema/filters)
|
|
38
|
+
* in behind it. Keep this module leaf-ward: it may import `./utils.mjs`,
|
|
39
|
+
* `./pseudonym-map.mjs`, the leak-guard, host-paths and `../vcs-repo-spec.mjs`
|
|
40
|
+
* (itself a leaf — `node:child_process` only) — never the pipeline.
|
|
25
41
|
*/
|
|
26
42
|
|
|
27
|
-
import {
|
|
43
|
+
import { basename } from 'node:path';
|
|
44
|
+
|
|
28
45
|
import { subjectToSlug } from './utils.mjs';
|
|
29
46
|
import { isOwnerLeakySegment } from '../../lib/validate/check-owner-leakage.mjs';
|
|
30
47
|
import { loadPseudonymMap } from './pseudonym-map.mjs';
|
|
31
48
|
import { loadHostPaths, resolveHostPath } from '../config/host-paths.mjs';
|
|
49
|
+
import { isQueryFailure, resolvePreferredRemote } from '../vcs-repo-spec.mjs';
|
|
32
50
|
|
|
33
51
|
// ── Lazy pseudonym-map path resolution (Epic #725 D5) ────────────────────────
|
|
34
52
|
// The map path comes from env SO_NAMESPACE_MAP > owner.yaml paths.namespace-map-path
|
|
@@ -68,6 +86,133 @@ function currentMapPath() {
|
|
|
68
86
|
return _lazyPath;
|
|
69
87
|
}
|
|
70
88
|
|
|
89
|
+
// ── Repo identity (issue #343; moved here from process.mjs for #734b) ────────
|
|
90
|
+
|
|
91
|
+
let _cachedRepo = null;
|
|
92
|
+
|
|
93
|
+
/** scp-like SSH remote: `git@host:org/name.git` (no `://`, an `@` before any `/`). */
|
|
94
|
+
const SCP_LIKE_REMOTE_RE = /^[^@/\s]+@[^:/\s]+:(.+)$/;
|
|
95
|
+
|
|
96
|
+
/** `scheme://[authority]/path` remote: https, ssh, git, file, … */
|
|
97
|
+
const SCHEME_REMOTE_RE = /^([a-z][a-z0-9+.-]*):\/\/[^/]*\/(.+)$/i;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Split a remote's path portion into meaningful segments: drop a trailing
|
|
101
|
+
* `.git` (with any trailing slashes), then discard empty and `.`/`..` segments.
|
|
102
|
+
*
|
|
103
|
+
* The `.`-dropping is the load-bearing part: `git clone <path>/.` records the
|
|
104
|
+
* origin VERBATIM as `/…/<repo>/.`, so the final segment of a filesystem remote
|
|
105
|
+
* is routinely a bare dot (measured golden record, 2026-08-19).
|
|
106
|
+
*
|
|
107
|
+
* @param {string} path
|
|
108
|
+
* @returns {string[]}
|
|
109
|
+
*/
|
|
110
|
+
function remotePathSegments(path) {
|
|
111
|
+
return path
|
|
112
|
+
.replace(/\.git\/*$/i, '')
|
|
113
|
+
.split('/')
|
|
114
|
+
.map((segment) => segment.trim())
|
|
115
|
+
.filter((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Derive the RAW repo identifier from one git remote URL.
|
|
120
|
+
*
|
|
121
|
+
* - Hosted remote (scp-like SSH or a non-`file` scheme URL) → the last two path
|
|
122
|
+
* segments, `org/name` — byte-identical to the pre-#1039 regex for every
|
|
123
|
+
* hosted shape, so no existing vault namespace moves.
|
|
124
|
+
* - Filesystem remote (`git clone <path>`, `file://…`) → the repo DIRECTORY
|
|
125
|
+
* name alone. A local clone has no owner segment, so `org/name` is not
|
|
126
|
+
* derivable and inventing one from the parent directory would namespace vault
|
|
127
|
+
* notes under an arbitrary path component.
|
|
128
|
+
*
|
|
129
|
+
* Returns `''` when nothing usable can be derived (caller falls back).
|
|
130
|
+
*
|
|
131
|
+
* @param {string} url
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
function repoIdentifierFromRemoteUrl(url) {
|
|
135
|
+
const value = String(url ?? '').trim();
|
|
136
|
+
if (value === '') return '';
|
|
137
|
+
|
|
138
|
+
const scp = SCP_LIKE_REMOTE_RE.exec(value);
|
|
139
|
+
const asUrl = scp === null ? SCHEME_REMOTE_RE.exec(value) : null;
|
|
140
|
+
const isFileUrl = asUrl !== null && asUrl[1].toLowerCase() === 'file';
|
|
141
|
+
|
|
142
|
+
if (scp !== null || (asUrl !== null && !isFileUrl)) {
|
|
143
|
+
const segments = remotePathSegments(scp !== null ? scp[1] : asUrl[2]);
|
|
144
|
+
if (segments.length === 0) return '';
|
|
145
|
+
return segments.length >= 2
|
|
146
|
+
? `${segments[segments.length - 2]}/${segments[segments.length - 1]}`
|
|
147
|
+
: segments[segments.length - 1];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const segments = remotePathSegments(isFileUrl ? asUrl[2] : value);
|
|
151
|
+
return segments.length === 0 ? '' : segments[segments.length - 1];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Derive the canonical repo identifier for cross-repo vault aggregation (issue #343).
|
|
156
|
+
*
|
|
157
|
+
* Strategy (#1039): ask the shared remote-resolution core for the repo's
|
|
158
|
+
* PREFERRED remote — `resolvePreferredRemote` without a `vcs`, i.e. the
|
|
159
|
+
* `origin` → `gitlab` → `github` order, plus its sole-remote fallback — then
|
|
160
|
+
* derive `org/name` (hosted) or the repo directory name (filesystem clone) from
|
|
161
|
+
* that remote's URL. The pre-#1039 implementation read the hard-coded literal
|
|
162
|
+
* `git remote get-url origin`, which produced two live defects:
|
|
163
|
+
*
|
|
164
|
+
* 1. A repo whose remotes are named `gitlab`/`github` (no `origin`) silently
|
|
165
|
+
* namespaced its vault notes under the CHECKOUT DIRECTORY name.
|
|
166
|
+
* 2. A `git clone <path>` origin (`/…/<repo>/.` — what the pre-push hook's
|
|
167
|
+
* clone records) parsed to `<repo>/.`, whose slug is empty, so
|
|
168
|
+
* {@link resolveRepoNamespace} returned `'unknown-repo'`. Measured
|
|
169
|
+
* 2026-08-19; it turned a namespace assertion red and blocked a push.
|
|
170
|
+
*
|
|
171
|
+
* Fallback: `path.basename(process.cwd())`, as before — but the two reasons for
|
|
172
|
+
* reaching it are no longer indistinguishable. A QUERY FAILURE (not a git repo,
|
|
173
|
+
* git not on PATH, git errored — {@link isQueryFailure}) emits a stderr WARN,
|
|
174
|
+
* because the identity under which vault notes are written was GUESSED. A real
|
|
175
|
+
* ABSENCE (a repo with no remotes) stays silent: that is a legitimate repo state
|
|
176
|
+
* and the directory name is the best available identity, not a degraded one.
|
|
177
|
+
* An `ok` resolution whose URL yields no usable identifier also falls back
|
|
178
|
+
* silently — the query succeeded and the answer was simply unusable.
|
|
179
|
+
*
|
|
180
|
+
* Cached per-process — repo identity does not change mid-run, and the cache also
|
|
181
|
+
* keeps the WARN to at most one line per process.
|
|
182
|
+
*
|
|
183
|
+
* NOTE — this is the RAW identifier and is NOT leak-guarded. Never write its
|
|
184
|
+
* output to the vault directly; route it through {@link resolveRepoNamespace}
|
|
185
|
+
* (which is what the `vaultName`-less path below does). The WARN above therefore
|
|
186
|
+
* deliberately does NOT print the derived value.
|
|
187
|
+
*
|
|
188
|
+
* Re-exported by `./process.mjs` for backwards compatibility — that was its home
|
|
189
|
+
* until the #734b cycle break, and the module-level cache means there must remain
|
|
190
|
+
* exactly ONE definition.
|
|
191
|
+
*
|
|
192
|
+
* @returns {string} e.g. 'Kanevry/session-orchestrator' or a bare directory name.
|
|
193
|
+
*/
|
|
194
|
+
export function deriveRepo() {
|
|
195
|
+
if (_cachedRepo !== null) return _cachedRepo;
|
|
196
|
+
|
|
197
|
+
const resolved = resolvePreferredRemote({});
|
|
198
|
+
if (resolved.ok) {
|
|
199
|
+
const identifier = repoIdentifierFromRemoteUrl(resolved.url);
|
|
200
|
+
if (identifier !== '') {
|
|
201
|
+
_cachedRepo = identifier;
|
|
202
|
+
return _cachedRepo;
|
|
203
|
+
}
|
|
204
|
+
} else if (isQueryFailure(resolved.reason)) {
|
|
205
|
+
process.stderr.write(
|
|
206
|
+
`WARN vault-mirror/namespace: could not query git remotes (${resolved.reason}); ` +
|
|
207
|
+
'falling back to the checkout directory name — vault notes may be namespaced ' +
|
|
208
|
+
'under the directory rather than the repo identity\n',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_cachedRepo = basename(process.cwd());
|
|
213
|
+
return _cachedRepo;
|
|
214
|
+
}
|
|
215
|
+
|
|
71
216
|
/**
|
|
72
217
|
* Look up a stable pseudonym for this repo. Checks the sanitised segment first
|
|
73
218
|
* (the canonical, stable key) then the raw base (covers a vaultName override
|