session-orchestrator 3.21.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.
Files changed (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/040-discovery.mdc +6 -6
  6. package/.cursor/rules/050-plan.mdc +8 -8
  7. package/CHANGELOG.md +101 -0
  8. package/README.md +10 -10
  9. package/agents/memory-proposal-collector.md +6 -4
  10. package/commands/eli5.md +33 -0
  11. package/commands/release.md +5 -3
  12. package/commands/test.md +2 -2
  13. package/docs/components.md +6 -5
  14. package/docs/scope-collision-guard.md +3 -3
  15. package/docs/session-config-reference.md +31 -8
  16. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  17. package/hooks/hooks-codex.json +1 -1
  18. package/hooks/hooks.json +11 -1
  19. package/hooks/on-session-end.mjs +24 -92
  20. package/hooks/on-session-start.mjs +195 -104
  21. package/hooks/pre-auq-clarity.mjs +787 -0
  22. package/hooks/pre-bash-issue-budget.mjs +17 -18
  23. package/package.json +3 -1
  24. package/pi/prompts/eli5.md +12 -0
  25. package/scripts/auq-audit.mjs +825 -0
  26. package/scripts/autopilot.mjs +7 -8
  27. package/scripts/lib/auq/clarity.mjs +1314 -0
  28. package/scripts/lib/auq/parse.mjs +1006 -0
  29. package/scripts/lib/auq/schema.mjs +1457 -0
  30. package/scripts/lib/ci-status-banner.mjs +63 -57
  31. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  32. package/scripts/lib/config/vault-integration.mjs +12 -1
  33. package/scripts/lib/dispatcher/rank.mjs +4 -7
  34. package/scripts/lib/gates/gate-full.mjs +3 -3
  35. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  36. package/scripts/lib/io.mjs +239 -0
  37. package/scripts/lib/issue-budget.mjs +63 -9
  38. package/scripts/lib/owner-interview.mjs +78 -32
  39. package/scripts/lib/peer-discovery.mjs +73 -22
  40. package/scripts/lib/project-hygiene.mjs +64 -4
  41. package/scripts/lib/reconcile/renderer.mjs +17 -4
  42. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  43. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  44. package/scripts/lib/resource-probe.mjs +18 -2
  45. package/scripts/lib/spiral-carryover.mjs +23 -2
  46. package/scripts/lib/state-md/mission-status.mjs +147 -50
  47. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  48. package/scripts/lib/validate/check-hooks-symmetry.mjs +30 -0
  49. package/scripts/lib/validate/check-rules.mjs +153 -9
  50. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  51. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  52. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  53. package/scripts/lib/wave-resource-gate.mjs +67 -73
  54. package/scripts/materialize-wave-scope.mjs +281 -0
  55. package/scripts/release.mjs +443 -122
  56. package/scripts/run-quality-gate.mjs +14 -0
  57. package/scripts/validate-plugin.mjs +3 -0
  58. package/scripts/validate-wave-scope.mjs +6 -1
  59. package/scripts/vault-backfill.mjs +32 -5
  60. package/skills/_shared/parallel-aware-auq.md +30 -24
  61. package/skills/_shared/parallel-aware-preamble.md +31 -2
  62. package/skills/_shared/state-ownership.md +32 -6
  63. package/skills/bootstrap/SKILL.md +2 -1
  64. package/skills/brainstorm/SKILL.md +18 -18
  65. package/skills/brainstorm/soul.md +12 -0
  66. package/skills/discovery/SKILL.md +28 -24
  67. package/skills/eli5/SKILL.md +43 -0
  68. package/skills/evolve/SKILL.md +8 -9
  69. package/skills/gitlab-ops/SKILL.md +30 -26
  70. package/skills/grill/SKILL.md +6 -6
  71. package/skills/grill/soul.md +16 -0
  72. package/skills/memory-cleanup/SKILL.md +2 -2
  73. package/skills/npm-publish/SKILL.md +4 -4
  74. package/skills/peekaboo-driver/SKILL.md +3 -3
  75. package/skills/plan/SKILL.md +18 -16
  76. package/skills/plan/mode-feature.md +1 -1
  77. package/skills/plan/mode-new.md +35 -23
  78. package/skills/plan/soul.md +12 -0
  79. package/skills/reconcile/SKILL.md +3 -3
  80. package/skills/session-end/SKILL.md +53 -20
  81. package/skills/session-end/phase-3-6-tail.md +37 -2
  82. package/skills/session-start/SKILL.md +69 -35
  83. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  84. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  85. package/skills/session-start/soul.md +110 -0
  86. package/skills/test-runner/SKILL.md +2 -2
  87. package/skills/using-orchestrator/SKILL.md +1 -1
  88. package/skills/wave-executor/wave-loop.md +27 -5
  89. package/skills/write-executable-plan/SKILL.md +6 -6
  90. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  91. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  92. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  93. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  94. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  95. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  96. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  97. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  98. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  99. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  100. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  101. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  102. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  103. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  104. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  105. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  106. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  107. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  108. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  109. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  110. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  111. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  112. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  113. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  114. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  115. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  116. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  117. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -84,6 +84,16 @@ const DOCUMENTED_ASYMMETRIES = {
84
84
  // Cursor IDE and Pi v1 have no parallel agent dispatch at all
85
85
  // (skills/session-plan/SKILL.md Platform Note), and Codex has no payload
86
86
  // adapter for it. A matcher that can never fire is not enforcement.
87
+ // pre-auq-clarity (#1107): NOT ported by construction. The hook matches
88
+ // the `AskUserQuestion` tool, which this platform does not have — it
89
+ // renders a numbered Markdown list instead (docs/codex-setup.md:112,
90
+ // docs/cursor-setup.md:86), and pi-hook-bridge's TOOL_NAME_MAP carries
91
+ // no `askuserquestion` entry. A matcher on a tool that never fires is a
92
+ // dead entry: maintenance cost that ASSERTS a protection it does not
93
+ // provide. Revisit the day the platform gains the tool — the mechanical
94
+ // witness for that day is the mapPiToolName assertion in
95
+ // tests/hooks/pre-auq-clarity-wiring.test.mjs, which goes red then.
96
+ 'pre-auq-clarity.mjs',
87
97
  'pre-task-scope-disjoint.mjs',
88
98
  'skill-invocation-telemetry.mjs',
89
99
  'enforce-scope.mjs',
@@ -132,6 +142,16 @@ const DOCUMENTED_ASYMMETRIES = {
132
142
  // Cursor IDE and Pi v1 have no parallel agent dispatch at all
133
143
  // (skills/session-plan/SKILL.md Platform Note), and Codex has no payload
134
144
  // adapter for it. A matcher that can never fire is not enforcement.
145
+ // pre-auq-clarity (#1107): NOT ported by construction. The hook matches
146
+ // the `AskUserQuestion` tool, which this platform does not have — it
147
+ // renders a numbered Markdown list instead (docs/codex-setup.md:112,
148
+ // docs/cursor-setup.md:86), and pi-hook-bridge's TOOL_NAME_MAP carries
149
+ // no `askuserquestion` entry. A matcher on a tool that never fires is a
150
+ // dead entry: maintenance cost that ASSERTS a protection it does not
151
+ // provide. Revisit the day the platform gains the tool — the mechanical
152
+ // witness for that day is the mapPiToolName assertion in
153
+ // tests/hooks/pre-auq-clarity-wiring.test.mjs, which goes red then.
154
+ 'pre-auq-clarity.mjs',
135
155
  'pre-task-scope-disjoint.mjs',
136
156
  'skill-invocation-telemetry.mjs',
137
157
  'pre-bash-sessions-ledger-guard.mjs', // #958
@@ -160,6 +180,16 @@ const DOCUMENTED_ASYMMETRIES = {
160
180
  // Cursor IDE and Pi v1 have no parallel agent dispatch at all
161
181
  // (skills/session-plan/SKILL.md Platform Note), and Codex has no payload
162
182
  // adapter for it. A matcher that can never fire is not enforcement.
183
+ // pre-auq-clarity (#1107): NOT ported by construction. The hook matches
184
+ // the `AskUserQuestion` tool, which this platform does not have — it
185
+ // renders a numbered Markdown list instead (docs/codex-setup.md:112,
186
+ // docs/cursor-setup.md:86), and pi-hook-bridge's TOOL_NAME_MAP carries
187
+ // no `askuserquestion` entry. A matcher on a tool that never fires is a
188
+ // dead entry: maintenance cost that ASSERTS a protection it does not
189
+ // provide. Revisit the day the platform gains the tool — the mechanical
190
+ // witness for that day is the mapPiToolName assertion in
191
+ // tests/hooks/pre-auq-clarity-wiring.test.mjs, which goes red then.
192
+ 'pre-auq-clarity.mjs',
163
193
  'pre-task-scope-disjoint.mjs',
164
194
  'skill-invocation-telemetry.mjs', // #919
165
195
  'enforce-scope.mjs', // #919
@@ -30,6 +30,51 @@
30
30
  // parse error as always-on with empty meta, so the skipped file is
31
31
  // exactly the one that loads everywhere and clears every gate. See
32
32
  // the inline rationale at the parse site below.
33
+ // (e) HARNESS-PARITY (#1108): a rule that expresses a path restriction
34
+ // must express it in `paths:`, because `paths:` is the ONLY key
35
+ // Claude Code's own rule loader reads. Its documentation
36
+ // (code.claude.com/docs/en/memory § "Path-specific rules") is
37
+ // explicit: rules are scoped "using YAML frontmatter with the
38
+ // `paths` field", and "rules without a `paths` field are loaded
39
+ // unconditionally and apply to all files". `globs:` is the CURSOR
40
+ // field name. A `globs:`-only rule is therefore scoped for
41
+ // rule-loader.mjs (#795 alias) and for Cursor, yet ALWAYS-ON for
42
+ // Claude Code — the rule *looks* scoped everywhere it is inspected
43
+ // and is silently loaded everywhere it is used.
44
+ // Measured 2026-08-22 before the fix: 16 of 31 rule files carried
45
+ // `globs:` and 0 carried `paths:`, so all 31 loaded unconditionally
46
+ // — 186,993 bytes ≈ 46,700 tokens per dispatch, 72,195 of them
47
+ // unwanted (testing.md alone is 36,431 bytes and carries
48
+ // `tier: wave-only`, i.e. is explicitly meant to be conditional).
49
+ // This check is the recurrence guard for that fix, not the fix.
50
+ // It is formulated as a PARITY check rather than a presence check,
51
+ // which folds two defects into one invariant: the rule must load in
52
+ // the SAME contexts under Claude Code's loader (which reads
53
+ // `paths:`, treating absence as always-on) as under rule-loader.mjs
54
+ // (which reads `globs:` and falls back to `paths:` — #795, globs
55
+ // wins). Two ways to violate it:
56
+ // - `globs:` present and non-empty, `paths:` ABSENT → always-on
57
+ // in Claude Code.
58
+ // - both present with DIFFERENT pattern sets → Claude Code scopes
59
+ // on one list, rule-loader.mjs and Cursor on the other.
60
+ // Order and duplicates are irrelevant (a glob list is matched
61
+ // any-of), so the comparison is over the sorted unique set.
62
+ // NOT flagged: `paths:` alone. It is the form the native
63
+ // documentation prescribes and the form the primary downstream
64
+ // consumer uses exclusively (projects-baseline: 26 rule files, all
65
+ // `paths:`, 0 `globs:` — rule-loader.mjs module doc). The
66
+ // `globs:`-is-canonical preference for VENDORED rules is a separate,
67
+ // warn-level concern already owned by
68
+ // scripts/lib/validate-vendored-rules.mjs (~:289, issue #742) and is
69
+ // deliberately not duplicated here.
70
+ // NOT flagged either: an EMPTY `globs: []`. That shape is already
71
+ // owned — with an accurate, opposite message — by the dedicated
72
+ // empty-globs branches below (#880 QA Defect 1 / #892), and firing
73
+ // a second, contradictory-sounding finding ("never loads" beside
74
+ // "always-on in Claude Code") on one file is exactly the confusion
75
+ // those fixes went out of their way to remove. The parity check
76
+ // therefore requires a NON-EMPTY `globs:` list; populating it is the
77
+ // prescribed remedy for `globs: []`, at which point parity binds.
33
78
  //
34
79
  // (2) HANDWRITTEN rules (no `auto-generated: true` — #880 FA5, WARN-only,
35
80
  // NEVER affects the exit code). The auto-generated brandmauer above only
@@ -70,9 +115,10 @@
70
115
  //
71
116
  // Usage: check-rules.mjs <plugin-root>
72
117
  // Outputs lines of the form " PASS: ..." / " FAIL: ..." / " WARN: ...".
73
- // Exit 0 = all AUTO-GENERATED invariants satisfied (WARN lines from the
74
- // handwritten check NEVER affect the exit code); exit 1 = at least one
75
- // auto-generated FAIL.
118
+ // Exit 0 = all AUTO-GENERATED invariants plus the two cohort-independent
119
+ // invariants (d) frontmatter-parses and (e) harness-parity are satisfied (WARN
120
+ // lines from the handwritten check NEVER affect the exit code); exit 1 = at
121
+ // least one FAIL.
76
122
 
77
123
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
78
124
  import { join } from 'node:path';
@@ -132,6 +178,69 @@ function hasFrontmatterKey(contents, key) {
132
178
  return Boolean(keyMatch && keyMatch[1] && keyMatch[1].trim() !== '');
133
179
  }
134
180
 
181
+ // ---------------------------------------------------------------------------
182
+ // Per-key list access for the harness-parity check (#1108).
183
+ //
184
+ // `parseGlobsFrontmatter()` deliberately COLLAPSES `globs:` and `paths:` into
185
+ // one value ("callers never see which key produced the value" — rule-loader.mjs
186
+ // module doc, #795 precedence). The parity check needs both lists separately,
187
+ // and re-deriving list parsing here would be a second frontmatter parser — the
188
+ // exact drift class that produced #840. So instead of re-parsing, the OTHER key
189
+ // is MASKED to a name the parser ignores (any key that is neither globs/paths
190
+ // nor in SCALAR_META_KEYS is dropped without error, and its indented ` - x`
191
+ // continuation lines are skipped as "another block's continuation"), and the
192
+ // SAME parser then returns the surviving key's list verbatim — flow style,
193
+ // block style, quote stripping and provenance-header tolerance included.
194
+ //
195
+ // Masking cannot make a parseable file unparseable: it renames a key, so the
196
+ // line keeps its colon. Callers reach this only for files that already parsed.
197
+ // ---------------------------------------------------------------------------
198
+ const MASKED_KEY_PREFIX = 'x-check-rules-masked-';
199
+
200
+ /**
201
+ * Returns the value of exactly ONE of the two scope keys, ignoring the other —
202
+ * `null` when that key is absent, `[]` when present but empty.
203
+ *
204
+ * @param {string} contents - raw file contents (must already parse)
205
+ * @param {'globs'|'paths'} key - the key whose list to return
206
+ * @returns {string[] | null}
207
+ */
208
+ function scopeKeyValue(contents, key) {
209
+ const other = key === 'globs' ? 'paths' : 'globs';
210
+ const masked = contents.replace(
211
+ new RegExp(`^${other}:`, 'gm'),
212
+ `${MASKED_KEY_PREFIX}${other}:`,
213
+ );
214
+ return parseGlobsFrontmatter(masked).globs;
215
+ }
216
+
217
+ /** Sorted, de-duplicated copy — glob lists are matched any-of, so neither
218
+ * order nor repetition changes which files a rule applies to. */
219
+ function normalizePatterns(list) {
220
+ return [...new Set(list)].sort();
221
+ }
222
+
223
+ /**
224
+ * Harness-parity verdict for one rule file (#1108) — see module doc (e).
225
+ *
226
+ * @param {string} contents - raw file contents (must already parse)
227
+ * @returns {{ ok: true } | { ok: false, kind: 'missing-paths'|'divergent', globs: string[], paths: string[] | null }}
228
+ */
229
+ function checkHarnessParity(contents) {
230
+ const globs = scopeKeyValue(contents, 'globs');
231
+ // Only a NON-EMPTY globs list expresses a path restriction. Absent globs →
232
+ // whatever `paths:` says is what every loader sees. Empty globs → owned by
233
+ // the dedicated empty-globs branches, which prescribe populating it.
234
+ if (!Array.isArray(globs) || globs.length === 0) return { ok: true };
235
+
236
+ const paths = scopeKeyValue(contents, 'paths');
237
+ if (paths === null) return { ok: false, kind: 'missing-paths', globs, paths };
238
+
239
+ const same =
240
+ JSON.stringify(normalizePatterns(globs)) === JSON.stringify(normalizePatterns(paths));
241
+ return same ? { ok: true } : { ok: false, kind: 'divergent', globs, paths };
242
+ }
243
+
135
244
  // ============================================================================
136
245
  // Check: .claude/rules/ rule invariants
137
246
  // ============================================================================
@@ -207,17 +316,44 @@ for (const name of mdFiles.sort()) {
207
316
  const { globs, meta } = parsed;
208
317
  const rel = `.claude/rules/${name}`;
209
318
 
319
+ // (e) HARNESS-PARITY (#1108) — cohort-independent, like the parse check
320
+ // above: `paths:` is the only scope key Claude Code's own loader reads, so a
321
+ // `globs:`-only or divergently-scoped rule loads differently there than it
322
+ // does under rule-loader.mjs. Emitted here, in file order, and recorded on
323
+ // the entry so the cohort branches below suppress their PASS line — a file
324
+ // must never both PASS and FAIL.
325
+ const parity = checkHarnessParity(contents);
326
+ if (!parity.ok && parity.kind === 'missing-paths') {
327
+ fail(
328
+ `${rel} — declares globs: (${parity.globs.length} pattern(s)) but no paths: — Claude Code's native rule ` +
329
+ 'loader reads ONLY the `paths` frontmatter field and treats a rule without it as unconditional ' +
330
+ '("rules without a paths field are loaded unconditionally and apply to all files" — ' +
331
+ 'code.claude.com/docs/en/memory § Path-specific rules). `globs:` is the Cursor field name, so this rule ' +
332
+ 'is scoped for rule-loader.mjs and Cursor but loads ALWAYS-ON in Claude Code — a silent instruction-budget ' +
333
+ 'failure (#1108). Add a paths: key carrying the same patterns as globs: (keep globs: — it stays the ' +
334
+ 'canonical form for vendored rules, validate-vendored-rules.mjs #742).',
335
+ );
336
+ } else if (!parity.ok && parity.kind === 'divergent') {
337
+ fail(
338
+ `${rel} — globs: and paths: declare DIFFERENT pattern sets (globs: ${JSON.stringify(parity.globs)} vs ` +
339
+ `paths: ${JSON.stringify(parity.paths)}) — Claude Code's native loader scopes on paths:, while ` +
340
+ 'rule-loader.mjs and Cursor scope on globs: (globs: wins silently when both are present, #795). The rule ' +
341
+ 'therefore loads in different contexts on different harnesses (#1108). Make the two lists carry the same ' +
342
+ 'patterns — order and duplicates are irrelevant.',
343
+ );
344
+ }
345
+
210
346
  if (meta['auto-generated'] === true) {
211
- autoGeneratedEntries.push({ rel, globs, meta });
347
+ autoGeneratedEntries.push({ rel, globs, meta, parityOk: parity.ok });
212
348
  } else {
213
- handwrittenEntries.push({ rel, globs, meta, contents });
349
+ handwrittenEntries.push({ rel, globs, meta, contents, parityOk: parity.ok });
214
350
  }
215
351
  }
216
352
 
217
353
  // ---------------------------------------------------------------------------
218
354
  // Auto-generated branch (FA4 #697) — UNCHANGED behaviour, hard-fail.
219
355
  // ---------------------------------------------------------------------------
220
- for (const { rel, globs, meta } of autoGeneratedEntries) {
356
+ for (const { rel, globs, meta, parityOk } of autoGeneratedEntries) {
221
357
  // (a) never-always-on: must have at least one activation axis.
222
358
  // globs is one of: null (no frontmatter / no globs key), [] (key present
223
359
  // but EMPTY — the #892 QA-Defect special case, see module doc), or a
@@ -264,9 +400,11 @@ for (const { rel, globs, meta } of autoGeneratedEntries) {
264
400
  // an empty globs array, which is dead-not-passing regardless of host-class)
265
401
  // AND learning-key AND expires-at. When any failed, the FAIL line(s) above
266
402
  // already emitted.
403
+ // `parityOk` joins the conjunction for the same reason `hasEmptyGlobs` does:
404
+ // its FAIL already emitted above, and a file must never both PASS and FAIL.
267
405
  const lkOk = Object.prototype.hasOwnProperty.call(meta, 'learning-key');
268
406
  const eaOk = Object.prototype.hasOwnProperty.call(meta, 'expires-at');
269
- if (!hasEmptyGlobs && (hasGlobs || hasHostClass) && lkOk && eaOk) {
407
+ if (!hasEmptyGlobs && (hasGlobs || hasHostClass) && lkOk && eaOk && parityOk) {
270
408
  pass(`${rel} — auto-generated rule satisfies all invariants`);
271
409
  }
272
410
  }
@@ -283,7 +421,7 @@ console.log(
283
421
  '--- Check: .claude/rules/ handwritten-rule activation + review-date (warn mode, #880) ---',
284
422
  );
285
423
 
286
- for (const { rel, globs, meta, contents } of handwrittenEntries) {
424
+ for (const { rel, globs, meta, contents, parityOk } of handwrittenEntries) {
287
425
  // `globs` (the merged globs/paths value from parseGlobsFrontmatter — #795
288
426
  // alias, `globs:` wins when both are present) is one of:
289
427
  // - null → neither key present
@@ -333,7 +471,13 @@ for (const { rel, globs, meta, contents } of handwrittenEntries) {
333
471
  );
334
472
  }
335
473
 
336
- if (!hasEmptyGlobs && hasAxis && hasReviewDate) {
474
+ // `parityOk` joins the conjunction for the same reason `hasEmptyGlobs` does
475
+ // (#1108): its FAIL already emitted in the parse loop, and a file must never
476
+ // both PASS and FAIL. This is the one place a handwritten rule's PASS is
477
+ // withheld for a HARD-fail reason — the parity invariant is cohort-
478
+ // independent by construction, so it is not part of this branch's
479
+ // deliberately warn-only posture.
480
+ if (!hasEmptyGlobs && hasAxis && hasReviewDate && parityOk) {
337
481
  pass(`${rel} — handwritten rule has an activation axis and a review-date`);
338
482
  }
339
483
  }
@@ -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 (e.g. `glab repo list -g <group>`) that are not scoped to a single
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 repo list --output json` output (JSON array or JSONL).
67
- * Returns array of { id, path, name, visibility, createdAt }.
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
- const data = JSON.parse(trimmed);
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
- // Try line-by-line JSONL
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
- 'repo', 'list', '-g', group, '--output', 'json', '--per-page', '100',
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 repo list failed for group '${group}': ${stderr.trim()}\n`,
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 parseRepoList(stdout);
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 (typeof entry.path !== 'string' || !entry.path) {
38
- dieFn(1, `${prefix}.path must be a non-empty string`);
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)) {