session-orchestrator 3.19.0 → 3.21.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 (158) 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/030-wave-execution.mdc +10 -8
  5. package/CHANGELOG.md +494 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/commands/session.md +6 -2
  23. package/docs/USER-GUIDE.md +1 -1
  24. package/docs/instruction-delivery.md +350 -0
  25. package/docs/migration-v3.md +9 -6
  26. package/docs/persona-panel.md +3 -1
  27. package/docs/scope-collision-guard.md +167 -0
  28. package/docs/session-config-reference.md +1 -41
  29. package/docs/session-config-template.md +0 -23
  30. package/hooks/_lib/guard-source-loader.mjs +304 -91
  31. package/hooks/enforce-commands.mjs +216 -17
  32. package/hooks/enforce-scope.mjs +236 -12
  33. package/hooks/hooks-codex.json +1 -1
  34. package/hooks/hooks.json +11 -1
  35. package/hooks/on-session-end.mjs +52 -5
  36. package/hooks/on-session-start.mjs +7 -4
  37. package/hooks/on-stop.mjs +127 -12
  38. package/hooks/post-bash-write-verify.mjs +8 -32
  39. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  40. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  41. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  42. package/package.json +2 -2
  43. package/pi/prompts/release.md +12 -0
  44. package/scripts/autopilot.mjs +3 -1
  45. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  46. package/scripts/emit-session.mjs +45 -40
  47. package/scripts/export-hw-learnings.mjs +61 -2
  48. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  49. package/scripts/lib/backlog-scan.mjs +106 -15
  50. package/scripts/lib/build-live-signals.mjs +7 -3
  51. package/scripts/lib/ci-status-banner.mjs +207 -23
  52. package/scripts/lib/command-blocker.mjs +322 -62
  53. package/scripts/lib/git-config-drift.mjs +471 -0
  54. package/scripts/lib/hardening.mjs +9 -9
  55. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  56. package/scripts/lib/io.mjs +193 -7
  57. package/scripts/lib/learnings/affinity.mjs +434 -0
  58. package/scripts/lib/learnings/candidates.mjs +736 -0
  59. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  60. package/scripts/lib/learnings/judgment.mjs +782 -0
  61. package/scripts/lib/learnings/kebab.mjs +128 -0
  62. package/scripts/lib/learnings/select.mjs +704 -0
  63. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  64. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  65. package/scripts/lib/named-vault-resolver.mjs +105 -16
  66. package/scripts/lib/peer-cards/schema.mjs +6 -2
  67. package/scripts/lib/reconcile/emitter.mjs +107 -22
  68. package/scripts/lib/reconcile/engine.mjs +9 -15
  69. package/scripts/lib/reconcile/renderer.mjs +141 -25
  70. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  71. package/scripts/lib/reconcile/writer.mjs +134 -1
  72. package/scripts/lib/redact-spans.mjs +89 -0
  73. package/scripts/lib/scope-baseline.mjs +77 -17
  74. package/scripts/lib/scope-gate.mjs +852 -72
  75. package/scripts/lib/secret-masker.mjs +262 -0
  76. package/scripts/lib/session-close-backfill.mjs +2 -2
  77. package/scripts/lib/session-lock.mjs +34 -10
  78. package/scripts/lib/session-record-repair.mjs +551 -0
  79. package/scripts/lib/session-registry.mjs +9 -1
  80. package/scripts/lib/session-schema/serializer.mjs +54 -0
  81. package/scripts/lib/session-schema.mjs +1 -0
  82. package/scripts/lib/session-token-rollup.mjs +68 -6
  83. package/scripts/lib/soul-resolve.mjs +12 -0
  84. package/scripts/lib/state-md/mission-status.mjs +21 -12
  85. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  86. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  87. package/scripts/lib/validate/check-agents.mjs +77 -5
  88. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  89. package/scripts/lib/validate/check-commands.mjs +2 -20
  90. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  91. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  93. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  94. package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
  95. package/scripts/lib/validate/check-rules.mjs +31 -5
  96. package/scripts/lib/validate/check-skills.mjs +191 -0
  97. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  98. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  99. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  100. package/scripts/lib/validate/check-unwired-features.mjs +757 -0
  101. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  102. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  103. package/scripts/lib/validate/tier-inference.mjs +46 -8
  104. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  105. package/scripts/lib/vault-mirror/process.mjs +264 -31
  106. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  107. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  108. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  109. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  110. package/scripts/print-applicable-rules.mjs +170 -7
  111. package/scripts/print-learnings-index.mjs +501 -0
  112. package/scripts/release.mjs +616 -61
  113. package/scripts/repair-invalid-sessions.mjs +209 -0
  114. package/scripts/site-numbers.mjs +1049 -0
  115. package/scripts/sweep-expired-learnings.mjs +192 -32
  116. package/scripts/validate-plugin.mjs +82 -0
  117. package/scripts/validate-wave-scope.mjs +281 -12
  118. package/scripts/vault-mirror.mjs +26 -1
  119. package/skills/_shared/monitor-patterns.md +24 -4
  120. package/skills/_shared/state-ownership.md +17 -0
  121. package/skills/brainstorm/soul.md +47 -1
  122. package/skills/claude-md-drift-check/SKILL.md +9 -1
  123. package/skills/debug/SKILL.md +4 -1
  124. package/skills/discovery/issue-templates.md +4 -4
  125. package/skills/discovery/probes-code.md +2 -2
  126. package/skills/discovery/probes-feature.md +6 -6
  127. package/skills/discovery/probes-infra.md +2 -2
  128. package/skills/discovery/probes-session.md +5 -5
  129. package/skills/dispatcher/SKILL.md +10 -1
  130. package/skills/evolve/SKILL.md +116 -18
  131. package/skills/frontmatter-guard/SKILL.md +9 -1
  132. package/skills/gitlab-ops/SKILL.md +54 -39
  133. package/skills/gitlab-portfolio/SKILL.md +10 -1
  134. package/skills/grill/soul.md +44 -1
  135. package/skills/memory-cleanup/SKILL.md +18 -5
  136. package/skills/npm-publish/SKILL.md +22 -50
  137. package/skills/persona-panel/SKILL.md +3 -1
  138. package/skills/plan/mode-new.md +23 -5
  139. package/skills/plan/soul.md +46 -3
  140. package/skills/repo-audit/SKILL.md +10 -1
  141. package/skills/session-end/SKILL.md +45 -26
  142. package/skills/session-end/metrics-collection.md +1 -1
  143. package/skills/session-end/phase-3-6-tail.md +30 -1
  144. package/skills/session-end/plan-verification.md +1 -5
  145. package/skills/session-end/session-metrics-write.md +6 -10
  146. package/skills/session-plan/SKILL.md +2 -2
  147. package/skills/session-plan/wave-template.md +1 -1
  148. package/skills/session-start/SKILL.md +15 -1
  149. package/skills/session-start/soul.md +41 -1
  150. package/skills/spinout/SKILL.md +5 -1
  151. package/skills/sunset-review/SKILL.md +11 -1
  152. package/skills/tmux-layout/SKILL.md +7 -2
  153. package/skills/vault-mirror/SKILL.md +10 -1
  154. package/skills/vault-sync/SKILL.md +10 -1
  155. package/skills/vault-sync/validator.mjs +55 -6
  156. package/skills/wave-executor/SKILL.md +1 -5
  157. package/skills/wave-executor/wave-loop.md +77 -82
  158. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -9,6 +9,8 @@
9
9
  * node scripts/validate-wave-scope.mjs <path-to-wave-scope.json>
10
10
  * cat wave-scope.json | node scripts/validate-wave-scope.mjs
11
11
  * node scripts/validate-wave-scope.mjs --assert-subset <agent-filescope.json> < wave-scope.json
12
+ * node scripts/validate-wave-scope.mjs --assert-disjoint <agent-scopes.json> < wave-scope.json
13
+ * node scripts/validate-wave-scope.mjs --union <agent-scopes.json> < wave-scope.json
12
14
  *
13
15
  * Flags:
14
16
  * --assert-subset <path> After schema validation passes, read the agent
@@ -26,21 +28,68 @@
26
28
  * the caller may pass the flag unconditionally on
27
29
  * every pre-dispatch check. A skip is announced on
28
30
  * stderr as a WARN.
31
+ * --assert-disjoint <p> #1020. Read the wave's per-agent scope SIDECAR and
32
+ * assert no file is claimed by two agents of the same
33
+ * wave. Fails (exit 1) with one message per collision.
34
+ * `knownFiles` for the glob∩glob stage comes from
35
+ * `git ls-files` — spawned HERE, in the CLI layer,
36
+ * because scripts/lib/scope-gate.mjs is hook-safe and
37
+ * must not spawn a process (see its module header).
38
+ * --union <path> #1020. QUERY MODE. Read the same sidecar, compute
39
+ * `expandTestSiblings(unionFileScopes(scopes), {role})`
40
+ * using the MANIFEST'S OWN `role`, and print the
41
+ * resulting allowedPaths array as JSON on stdout.
42
+ * Mechanical replacement for the "Collect all file
43
+ * paths … Deduplicate entries" prose in
44
+ * skills/wave-executor/wave-loop.md § Scope Manifest #3.
45
+ *
46
+ * ## SIDECAR FORMAT (both #1020 flags) — an ARRAY, never an object map
47
+ * [{ "id": "W2-C1", "files": ["scripts/a.mjs"] }, { "id": "W2-C4", "files": [...] }]
48
+ * An object keyed by agent id would swallow a DUPLICATE agent id silently, and a
49
+ * duplicated id is a real copy-paste failure mode (it hides one agent's scope
50
+ * from every per-agent check). The array form keeps both records, and
51
+ * `findScopeCollisions` reports the duplicate as its own finding.
52
+ *
53
+ * ## STDOUT CONTRACT (why --union suppresses the manifest echo)
54
+ * Without `--union` this script writes EXACTLY ONE thing to stdout: the input
55
+ * manifest, echoed back verbatim. Callers rely on that — `JSON.parse(stdout)`.
56
+ * `--union` is the first mode that has something else to say, so it is a pure
57
+ * QUERY MODE: it REPLACES the echo rather than adding to it, and stdout carries
58
+ * only the computed allowedPaths array. Mixing both on stdout would break every
59
+ * `JSON.parse(stdout)` caller; writing the union to a second sink would need a
60
+ * file argument the caller must then read back. One JSON document per run, and
61
+ * the flag decides which one.
29
62
  *
30
63
  * Exit codes:
31
- * 0 — valid (validated JSON echoed to stdout)
32
- * 1 — invalid input / validation failure (error messages written to stderr)
33
- * 2 I/O error (file not found, unreadable stdin, unreadable --assert-subset file)
64
+ * 0 — valid (validated JSON echoed to stdout; with --union: the union array)
65
+ * 1 — invalid input / validation failure (error messages written to stderr).
66
+ * A scope COLLISION is a validation finding, exactly like the #796 subset
67
+ * and #970 test-sibling violations — the collision-vs-subset distinction
68
+ * lives in the MESSAGE, not in a new exit code.
69
+ * 2 — I/O error (file not found, unreadable stdin, unreadable sidecar file)
34
70
  */
35
71
 
36
72
  import path from 'node:path';
37
73
  import { readFileSync, existsSync, statSync } from 'node:fs';
74
+ import { execFileSync } from 'node:child_process';
38
75
  import { warn } from './lib/common.mjs';
39
76
  import {
40
77
  assertFileScopeSubset,
41
78
  assertTestSiblingCoverage,
42
79
  testSiblingExpansionApplies,
43
80
  TEST_SIBLING_EXPANSION_ROLES,
81
+ findScopeCollisions,
82
+ unionFileScopes,
83
+ // #1057 — the read-only-role predicate + THE list. Imported rather than
84
+ // re-listed so the validator and hooks/enforce-scope.mjs cannot disagree about
85
+ // which roles are allowed to grant zero paths.
86
+ isReadOnlyWaveRole,
87
+ READ_ONLY_WAVE_ROLES,
88
+ // Aliased: `expandTestSiblings` is ALSO the name of the pre-existing
89
+ // boolean parameter threaded through validate()/assertSubsetOrDie for the
90
+ // #970 flag. Aliasing the import avoids shadowing that parameter rather than
91
+ // renaming it — the #970 call path stays byte-identical.
92
+ expandTestSiblings as expandScopeTestSiblings,
44
93
  } from './lib/scope-gate.mjs';
45
94
 
46
95
  /**
@@ -54,21 +103,53 @@ function die(msg, code = 1) {
54
103
  process.exit(code);
55
104
  }
56
105
 
106
+ /**
107
+ * Read the value operand of a value-taking flag, REFUSING one that is itself a
108
+ * flag. Used by the #1020 flags only.
109
+ *
110
+ * `--assert-subset` (#796) consumes `argv[i + 1]` BLIND, so
111
+ * `--assert-subset --assert-disjoint x.json` reads `--assert-disjoint` as its
112
+ * path value. That behaviour is deliberately left untouched — its no-value
113
+ * message is pinned byte-for-byte by
114
+ * tests/scripts/validate-wave-scope.test.mjs — but the flags added here do not
115
+ * inherit it: a swallowed flag is silent (the mode never runs, and the caller
116
+ * believes it did), whereas this refusal is loud and one line long.
117
+ *
118
+ * @param {string[]} argv
119
+ * @param {number} i - index of the FLAG token
120
+ * @param {string} flag - the flag name, for the error message
121
+ * @returns {string}
122
+ */
123
+ function flagValue(argv, i, flag) {
124
+ const value = argv[i + 1];
125
+ if (value === undefined || value.startsWith('--')) {
126
+ die(`${flag} requires a file-path argument`, 1);
127
+ }
128
+ return value;
129
+ }
130
+
57
131
  /**
58
132
  * Parse CLI flags out of argv, leaving positional args behind.
59
133
  *
60
- * Recognised: `--assert-subset <path>` (#796) and `--expand-test-siblings`
61
- * (#970). Everything else is treated as a positional argument (the
62
- * wave-scope.json file path), preserving legacy behaviour where argv[2] is the
63
- * input file.
134
+ * Recognised: `--assert-subset <path>` (#796), `--expand-test-siblings` (#970),
135
+ * `--assert-disjoint <path>` and `--union <path>` (#1020). Everything else is
136
+ * treated as a positional argument (the wave-scope.json file path), preserving
137
+ * legacy behaviour where argv[2] is the input file.
138
+ *
139
+ * The #1020 branches sit BEFORE the positional fallback, as their own `else if`
140
+ * arms: routed through the fallback instead, `--assert-disjoint` would be read
141
+ * as a wave-scope.json path and the mode would never run.
64
142
  *
65
143
  * @param {string[]} argv - full process.argv
66
- * @returns {{ assertSubset: string|null, expandTestSiblings: boolean, positionals: string[] }}
144
+ * @returns {{ assertSubset: string|null, expandTestSiblings: boolean,
145
+ * assertDisjoint: string|null, union: string|null, positionals: string[] }}
67
146
  */
68
147
  function parseArgs(argv) {
69
148
  const positionals = [];
70
149
  let assertSubset = null;
71
150
  let expandTestSiblings = false;
151
+ let assertDisjoint = null;
152
+ let union = null;
72
153
  for (let i = 2; i < argv.length; i++) {
73
154
  const a = argv[i];
74
155
  if (a === '--expand-test-siblings') {
@@ -79,11 +160,17 @@ function parseArgs(argv) {
79
160
  die('--assert-subset requires a file-path argument', 1);
80
161
  }
81
162
  i++; // consume the value
163
+ } else if (a === '--assert-disjoint') {
164
+ assertDisjoint = flagValue(argv, i, '--assert-disjoint');
165
+ i++; // consume the value
166
+ } else if (a === '--union') {
167
+ union = flagValue(argv, i, '--union');
168
+ i++; // consume the value
82
169
  } else {
83
170
  positionals.push(a);
84
171
  }
85
172
  }
86
- return { assertSubset, expandTestSiblings, positionals };
173
+ return { assertSubset, expandTestSiblings, assertDisjoint, union, positionals };
87
174
  }
88
175
 
89
176
  /**
@@ -259,6 +346,27 @@ function validateAllowedPaths(obj, errors, warnings) {
259
346
  errors.push(`allowedPaths must be an array, got type: ${ap === null ? 'null' : typeof ap}`);
260
347
  return;
261
348
  }
349
+ // #1057 — an empty union under a WRITABLE role. WARN, never error, and the
350
+ // distinction is measured rather than stylistic: `skills/wave-executor/wave-loop.md`
351
+ // § Scope Manifest deliberately feeds a skeleton with `"allowedPaths": []`
352
+ // through THIS validator in `--assert-disjoint` and `--union` mode, BEFORE the
353
+ // union exists to be written. An error would break the documented procedure
354
+ // that produces the very field it complains about.
355
+ //
356
+ // Named ceiling (BV-004): the warning therefore also fires on that legitimate
357
+ // skeleton run — one stderr line on a happy path, accepted because the
358
+ // alternative is a mode-conditional warning, i.e. a second place that has to
359
+ // enumerate the modes correctly. Revisit if a third empty-skeleton mode lands.
360
+ if (ap.length === 0 && typeof obj.role === 'string' && obj.role.trim().length > 0
361
+ && !isReadOnlyWaveRole(obj.role)) {
362
+ warnings.push(
363
+ `allowedPaths is empty for role "${obj.role}" — every write in this wave will be DENIED by ` +
364
+ `hooks/enforce-scope.mjs. Empty is intentional only for a read-only role ` +
365
+ `(${READ_ONLY_WAVE_ROLES.join(', ')}); for a writable role it usually means the coordinator's ` +
366
+ `--union step did not complete. Expected while validating the pre-union skeleton; otherwise ` +
367
+ `re-run --union and rewrite the manifest.`,
368
+ );
369
+ }
262
370
  for (const entry of ap) {
263
371
  if (typeof entry !== 'string' || entry.length === 0) {
264
372
  errors.push('allowedPaths contains empty string');
@@ -414,13 +522,157 @@ function assertSubsetOrDie(obj, fileScopePath, expandTestSiblings = false) {
414
522
  }
415
523
  }
416
524
 
525
+ /**
526
+ * Read + shape-check the per-agent scope SIDECAR shared by `--assert-disjoint`
527
+ * and `--union` (#1020). Exits on any defect; returns the records on success.
528
+ *
529
+ * Exit codes mirror {@link assertSubsetOrDie} exactly: 2 for I/O (missing, not a
530
+ * regular file, unreadable), 1 for every content defect.
531
+ *
532
+ * ## Why the shape check is STRICTER than the library's tolerance
533
+ * `findScopeCollisions` / `unionFileScopes` are fail-closed and never throw:
534
+ * they SKIP a member that is not an object, and treat a missing `files` as `[]`.
535
+ * That is right for a hook-hot-path primitive and wrong for a CLI. A sidecar
536
+ * that spells the key `file:` instead of `files:` would then contribute nothing
537
+ * and both modes would report success on a scope that silently vanished — a
538
+ * path the operator NAMED and the tool did not honour. The absent-input guard
539
+ * belongs in the CLI layer (recorded learning, conf 0.80: a tolerant reader
540
+ * cannot carry a CLI's absent-input guard), so `files` is REQUIRED here.
541
+ *
542
+ * `id` is deliberately NOT required: scope-gate's `normalizeAgentScopes` runs a
543
+ * record with no usable id as `<unnamed#i>` rather than dropping it, precisely
544
+ * because an unreviewed scope is the one that collides. Requiring it here would
545
+ * reject exactly the input that contract was written to keep.
546
+ *
547
+ * @param {string} sidecarPath
548
+ * @param {string} flag - the flag name, for error messages
549
+ * @returns {Array<{id?: string, files: string[]}>}
550
+ */
551
+ function readAgentScopesOrDie(sidecarPath, flag) {
552
+ if (!existsSync(sidecarPath) || !statSync(sidecarPath).isFile()) {
553
+ die(`Cannot read ${flag} file: ${sidecarPath}`, 2);
554
+ }
555
+ let raw;
556
+ try {
557
+ raw = readFileSync(sidecarPath, 'utf8');
558
+ } catch (err) {
559
+ die(`Cannot read ${flag} file ${sidecarPath}: ${err.message}`, 2);
560
+ }
561
+ let parsed;
562
+ try {
563
+ parsed = JSON.parse(raw);
564
+ } catch {
565
+ die(`${flag} file is not valid JSON: ${sidecarPath}`, 1);
566
+ }
567
+ if (!Array.isArray(parsed)) {
568
+ const t = parsed === null ? 'null' : typeof parsed;
569
+ die(
570
+ `${flag} file must be a JSON array of {id, files} records, got type: ${t} — an object map would silently swallow a duplicate agent id`,
571
+ 1,
572
+ );
573
+ }
574
+ for (let i = 0; i < parsed.length; i++) {
575
+ const rec = parsed[i];
576
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) {
577
+ die(`${flag} file entry #${i} must be an object with a "files" array`, 1);
578
+ }
579
+ if (!Array.isArray(rec.files) || !rec.files.every((f) => typeof f === 'string')) {
580
+ die(`${flag} file entry #${i} ("${rec.id ?? '<unnamed>'}") must have a "files" string array`, 1);
581
+ }
582
+ }
583
+ return parsed;
584
+ }
585
+
586
+ /**
587
+ * The repo's tracked files, for {@link findScopeCollisions}' glob∩glob witness
588
+ * stage. Spawned HERE and injected as a parameter because
589
+ * `scripts/lib/scope-gate.mjs` is hook-safe (pure, sync, no I/O, no spawn) and
590
+ * `hooks/enforce-scope.mjs` reaches it on a hot path.
591
+ *
592
+ * An unavailable git (not a repo, git missing, huge output) is NOT an error:
593
+ * the library documents `knownFiles` as optional — stage 3a simply has fewer
594
+ * witnesses and the prefix fallback of stage 3b carries the load. Silent by
595
+ * design: a WARN here would print on the success path of a mode whose contract
596
+ * is "quiet when clean".
597
+ *
598
+ * @returns {string[]}
599
+ */
600
+ function knownRepoFiles() {
601
+ const opts = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 };
602
+ try {
603
+ const root = execFileSync('git', ['rev-parse', '--show-toplevel'], opts).trim();
604
+ if (!root) return [];
605
+ // -z: NUL-separated. Without it git QUOTES paths containing non-ASCII or
606
+ // special characters, and a quoted path would never match a scope entry.
607
+ return execFileSync('git', ['ls-files', '-z'], { ...opts, cwd: root })
608
+ .split('\0')
609
+ .filter((f) => f.length > 0);
610
+ } catch {
611
+ return [];
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Assert that no file is claimed by two agents of the SAME wave (#1020).
617
+ * Exits 1 with one message per collision (plus one per duplicate agent id);
618
+ * returns silently when the wave is clean.
619
+ *
620
+ * @param {string} sidecarPath
621
+ */
622
+ function assertDisjointOrDie(sidecarPath) {
623
+ const agentScopes = readAgentScopesOrDie(sidecarPath, '--assert-disjoint');
624
+ const { ok, collisions, duplicateIds } = findScopeCollisions(agentScopes, {
625
+ knownFiles: knownRepoFiles(),
626
+ });
627
+ if (ok) return;
628
+
629
+ // Duplicate ids FIRST: they are a malformed plan, and a reader who fixes them
630
+ // may well change which collisions remain.
631
+ for (const id of duplicateIds) {
632
+ process.stderr.write(
633
+ `ERROR: duplicate agent id in ${sidecarPath}: "${id}" — ids must be unique per wave; a copy-paste duplicate hides one agent's scope from every per-agent check\n`,
634
+ );
635
+ }
636
+ for (const c of collisions) {
637
+ process.stderr.write(
638
+ `ERROR: wave scope collision (${c.kind}): agents "${c.a}" and "${c.b}" both claim [${c.evidence.join(', ')}]\n`,
639
+ );
640
+ }
641
+ process.stderr.write(
642
+ `ERROR: ${collisions.length} scope collision(s), ${duplicateIds.length} duplicate id(s) — every file must belong to exactly ONE agent per wave (#1020; .claude/rules/parallel-sessions.md § Decision Tree)\n`,
643
+ );
644
+ process.exit(1);
645
+ }
646
+
647
+ /**
648
+ * QUERY MODE (#1020): print `expandTestSiblings(unionFileScopes(scopes), {role})`
649
+ * as JSON on stdout, using the MANIFEST'S own role. Replaces the manifest echo —
650
+ * see the STDOUT CONTRACT note in the file header.
651
+ *
652
+ * @param {Record<string, unknown>} obj - the already schema-validated wave-scope object
653
+ * @param {string} sidecarPath
654
+ */
655
+ function emitUnion(obj, sidecarPath) {
656
+ const agentScopes = readAgentScopesOrDie(sidecarPath, '--union');
657
+ const allowedPaths = expandScopeTestSiblings(unionFileScopes(agentScopes), { role: obj.role });
658
+ process.stdout.write(`${JSON.stringify(allowedPaths, null, 2)}\n`);
659
+ }
660
+
417
661
  /**
418
662
  * Main validation entry point. Reads input, validates, exits with appropriate code.
419
663
  * @param {string} input - raw JSON string
420
664
  * @param {string|null} [assertSubsetPath] - optional agent fileScope file for the #796 subset assertion
421
665
  * @param {boolean} [expandTestSiblings] - opt-in #970 test-sibling coverage assertion
666
+ * @param {string|null} [assertDisjointPath] - optional per-agent scope sidecar for the #1020 collision check
667
+ * @param {string|null} [unionPath] - optional per-agent scope sidecar for the #1020 union query mode
422
668
  */
423
- function validate(input, assertSubsetPath = null, expandTestSiblings = false) {
669
+ function validate(
670
+ input,
671
+ assertSubsetPath = null,
672
+ expandTestSiblings = false,
673
+ assertDisjointPath = null,
674
+ unionPath = null,
675
+ ) {
424
676
  const obj = parseJson(input);
425
677
  const errors = [];
426
678
  const warnings = [];
@@ -446,9 +698,26 @@ function validate(input, assertSubsetPath = null, expandTestSiblings = false) {
446
698
  assertSubsetOrDie(obj, assertSubsetPath, expandTestSiblings);
447
699
  }
448
700
 
701
+ // #1020 — collision check runs AFTER the #796/#970 assertions, for the same
702
+ // reason #970 runs after #796 (see assertSubsetOrDie): a manifest that
703
+ // violates BOTH the subset relation and disjointness must keep the older,
704
+ // byte-pinned subset message. Only ever ADDS a failure mode.
705
+ if (assertDisjointPath) {
706
+ assertDisjointOrDie(assertDisjointPath);
707
+ }
708
+
709
+ // #1020 QUERY MODE — replaces the echo below; see the STDOUT CONTRACT note in
710
+ // the file header. Last, so every assertion above still gates it.
711
+ if (unionPath) {
712
+ emitUnion(obj, unionPath);
713
+ return;
714
+ }
715
+
449
716
  // Echo validated JSON to stdout (trailing newline normalised)
450
717
  process.stdout.write(input.endsWith('\n') ? input : input + '\n');
451
718
  }
452
719
 
453
- const { assertSubset, expandTestSiblings, positionals } = parseArgs(process.argv);
454
- validate(readInput(positionals[0]), assertSubset, expandTestSiblings);
720
+ const { assertSubset, expandTestSiblings, assertDisjoint, union, positionals } = parseArgs(
721
+ process.argv,
722
+ );
723
+ validate(readInput(positionals[0]), assertSubset, expandTestSiblings, assertDisjoint, union);
@@ -55,7 +55,8 @@ import { createInterface } from 'node:readline';
55
55
  import { createReadStream } from 'node:fs';
56
56
  import { pathToFileURL } from 'node:url';
57
57
 
58
- import { processLearning, processSession } from './lib/vault-mirror/process.mjs';
58
+ import { processLearning, processSession, getMaskerStats } from './lib/vault-mirror/process.mjs';
59
+ import { emitEvent } from './lib/events.mjs';
59
60
  import { autoCommitVaultMirror } from './lib/vault-mirror/auto-commit.mjs';
60
61
  import { parseColumnFlags, CliFlagError } from './lib/cli-flags.mjs';
61
62
  import { resolveRepoNamespace } from './lib/vault-mirror/namespace.mjs';
@@ -403,6 +404,30 @@ async function main() {
403
404
  }
404
405
  }
405
406
 
407
+ // ── Masking telemetry (#1025) ───────────────────────────────────────────────
408
+ //
409
+ // Emitted UNCONDITIONALLY, exactly once per channel run, and HERE — at the end
410
+ // of the run rather than at the lazy build site inside process.mjs. The build
411
+ // site is only reached once a record is actually processed, so a run over an
412
+ // empty/fully-skipped source would emit nothing and "the masker never ran" would
413
+ // be indistinguishable from "this channel has no masker wired". Placed BEFORE
414
+ // the --strict-schema abort so a failing run still reports its masking posture.
415
+ //
416
+ // Counts only — never a needle, never a prefix of one, never masked text.
417
+ // Best-effort: a telemetry write must never be the reason a mirror run fails.
418
+ try {
419
+ const maskerStats = getMaskerStats();
420
+ await emitEvent('orchestrator.secret_masker.applied', {
421
+ channel: 'vault-mirror',
422
+ needle_count: maskerStats.needleCount,
423
+ records: maskerStats.records,
424
+ hits: maskerStats.hits,
425
+ dry_run: dryRun,
426
+ });
427
+ } catch {
428
+ // Silent no-op — see the note above.
429
+ }
430
+
406
431
  // --strict-schema: abort with exit 1 when any entry was skipped-invalid.
407
432
  // Useful in CI to catch producer-side schema drift early (issue #249).
408
433
  if (strictSchema && skippedInvalidCount > 0) {
@@ -36,11 +36,11 @@ the GitHub mirror's main-branch SHA so the operator can confirm parity.
36
36
  ```bash
37
37
  prev=""
38
38
  while true; do
39
- s=$(glab ci status --pipeline-id LATEST --output json 2>/dev/null || echo '[]')
40
- cur=$(jq -r '.[] | select(.status!="running" and .status!="pending") | "\(.name): \(.status)"' <<<"$s" 2>/dev/null | sort)
39
+ s=$(glab ci status -R <OWNER>/<REPO> --output json 2>/dev/null || echo '{"jobs":[]}')
40
+ cur=$(jq -r '.jobs[] | select(.status!="running" and .status!="pending") | "\(.name): \(.status)"' <<<"$s" 2>/dev/null | sort)
41
41
  comm -13 <(echo "$prev") <(echo "$cur")
42
42
  prev=$cur
43
- jq -e 'all(.status=="success" or .status=="failed" or .status=="canceled" or .status=="skipped")' <<<"$s" >/dev/null 2>&1 && {
43
+ jq -e '(.jobs|length) > 0 and (.jobs | all(.status=="success" or .status=="failed" or .status=="canceled" or .status=="skipped"))' <<<"$s" >/dev/null 2>&1 && {
44
44
  sha=$(gh api repos/Kanevry/session-orchestrator/commits/main --jq '.sha' 2>/dev/null || echo "(mirror unreachable)")
45
45
  echo "GitHub mirror main: $sha"
46
46
  break
@@ -55,8 +55,28 @@ done
55
55
  SHA — silence at the end means glab JSON parsing failed (the `||` fallbacks
56
56
  prevent the whole loop from dying).
57
57
 
58
+ **Probed 2026-08-14 (glab 1.91.0), three corrections — #1022.** The snippet
59
+ above was silence-is-not-success in its own right until that date, and each
60
+ half failed into the next one's fallback:
61
+
62
+ - `--pipeline-id LATEST` is not a glab flag on any `ci` subcommand
63
+ (`ERROR Unknown flag`, exit 1), so `$s` was always the `||` fallback. There is
64
+ no replacement — the argument-less form already selects the current branch's
65
+ pipeline. Avoid `--branch=<name>` here: it pins a snapshot taken when the
66
+ monitor was armed.
67
+ - The payload is an OBJECT (`{"jobs":[…],"pipeline":{…}}`), so `.[]` raised
68
+ `Cannot index array with string "status"` (jq exit 5) on every real response.
69
+ The accessor is `.jobs[]`.
70
+ - `all(…)` over the empty fallback returns **true**, so the loop broke and
71
+ printed the mirror SHA on its FIRST iteration whenever glab hiccuped — a
72
+ transient network error read as "pipeline finished". The `(.jobs|length) > 0`
73
+ guard is what makes the fallback non-terminal; verified by running the
74
+ terminal test against `{"jobs":[]}` (exit 1 = keep watching) versus the old
75
+ form against `[]` (exit 0 = break).
76
+
58
77
  **GitHub-mirror equivalent.** When the pipeline is GitHub-Actions-native (PR
59
- checks rather than a GitLab pipeline), use `gh pr checks <pr> --watch --fail-fast`
78
+ checks rather than a GitLab pipeline), use
79
+ `gh pr checks -R <OWNER>/<REPO> <pr> --watch --fail-fast`
60
80
  as the `command` source — it streams each check transition and exits non-zero on
61
81
  the first failure, so the terminal state is never silent.
62
82
 
@@ -219,3 +219,20 @@ Net: `pid` (field notes above) stays forensic-only; `last_heartbeat` freshness i
219
219
  ### Schema v1 → v2 backward-compat
220
220
 
221
221
  Readers (e.g., `readLock()` in `session-lock.mjs`, `discoverActiveSessions()`) MUST tolerate absent `last_heartbeat` and `semantic_session_id` fields (v1 locks written before Epic #583). When `last_heartbeat` is absent, fall back to TTL-based expiry from `started_at`. When `semantic_session_id` is absent, treat as unknown.
222
+
223
+ #### Schema v1 Sunset — evaluated 2026-08-15, tolerance RETAINED (#595)
224
+
225
+ The 90-day sunset window from Epic #583 (target 2026-08-25) came due and the removal was evaluated against the live fleet. **Verdict: keep the three reader tolerances; the blocker is not v1 data, it is a second production copy of the rule.**
226
+
227
+ **Precondition — zero v1 artefacts on disk (measured 2026-08-15, this host):**
228
+
229
+ - `find ~/Projects ~/.claude ~/.config /tmp/claude-501 -name 'session.lock' -not -path '*/node_modules/*'` → **12 files, 12/12 carry a non-empty `last_heartbeat`** (0 v1).
230
+ - `~/.config/session-orchestrator/sessions/active/*.json` → **3 entries, 3/3 carry the `mode` key** (0 v1).
231
+ - The only co-installed older plugin build (`~/.claude/plugins/cache/session-orchestrator/session-orchestrator/3.13.0`) already writes `last_heartbeat` (`session-lock.mjs:195`) and `mode` (`session-registry.mjs:209`) — **no v1 writer remains on this host.**
232
+
233
+ **Why the branches stay anyway:**
234
+
235
+ 1. **`parseLock()` / `isLockLive()` — the rule is duplicated.** `scripts/lib/harness-audit/categories/category4.mjs` `lockIsLive()` inlines the same `last_heartbeat ?? started_at` fallback, and `tests/lib/lock-ttl-parity.test.mjs` asserts the mirror and the SSOT return identical verdicts *for a v1 lock*. Dropping it in `session-lock.mjs` alone breaks that parity by construction. A measured removal attempt turned **18 tests red across 4 files** (`session-discovery` 9, `session-discovery-fallback` 6, `lock-ttl-parity` 1, `on-session-start` 2) — all outside the lock/registry module pair, all seeding v1-shaped fixtures.
236
+ 2. **`_validEntry()` optional `mode` — removal is a net safety LOSS.** Rejecting a mode-less registry entry drops a **live peer** from `readRegistry()`, making it invisible to the exclusivity matrix. An absent `mode` already degrades to the `parallel-ok` bucket, so strictening buys no detection and costs peer visibility — the wrong direction under `.claude/rules/development.md` § Guard & Threshold Design.
237
+
238
+ **What a real sunset needs (co-change set, one atomic MR):** `scripts/lib/session-lock.mjs` + `scripts/lib/harness-audit/categories/category4.mjs` (the mirror) + fixture updates in `tests/lib/session-discovery.test.mjs`, `tests/lib/session-discovery-fallback.test.mjs`, `tests/lib/lock-ttl-parity.test.mjs`, `tests/hooks/on-session-start.test.mjs`. Deleting the *mirror* in favour of importing the SSOT is the durable fix — the duplication, not the v1 data, is what keeps this class alive. The registry item should be closed as won't-do per point 2.
@@ -4,7 +4,7 @@
4
4
 
5
5
  You are the Design Facilitator — a thinking partner who shapes vague feature ideas into approved designs through Socratic questioning. You don't implement; you clarify. You don't collect wishes; you narrow the design space until one path is clearly better than the others.
6
6
 
7
- You respond in {{owner.language}} when that matches the user's language. You meet people at their abstraction level — product language with stakeholders, technical language with engineers.
7
+ You answer in the operator's language: `owner.language` in `~/.config/session-orchestrator/owner.yaml`, falling back to `en` when that file is missing, unreadable, or the key is absent — and following the operator's own language the moment he writes in another one. You meet people at their abstraction level — product language with stakeholders, technical language with engineers.
8
8
 
9
9
  ## Communication Principles
10
10
 
@@ -23,6 +23,52 @@ Three well-chosen AUQ rounds beat five meandering ones. When you have enough sig
23
23
  ### Confirm understanding before advancing
24
24
  After Phase 1, summarize your understanding in 1-2 plain-text sentences before running the first dialogue round. After Phase 2, surface the running summary between rounds. Catching a misunderstanding at round 2 costs one turn; catching it at Phase 4 costs a rewrite.
25
25
 
26
+ ### Length is a dial, not taste
27
+ How much you say around each question is set by the operator, not chosen by mood — see § Output Levels. "Less is more" is the discipline; the budget is the number that makes it checkable.
28
+
29
+ ## Output Levels
30
+
31
+ The active level is `efficiency.output-level` in `~/.config/session-orchestrator/owner.yaml`. If that file is missing, unreadable, or the key is absent, the level is `full`. Apply the matching block below for the whole dialogue.
32
+
33
+ **How to read a budget.** A *round* is every chat line you author between one answered question and your next `AskUserQuestion` call — the running summary, the trade-off framing, the one sentence of recommendation reasoning. A budget is a ceiling, not a target: under is fine, over is a defect. You meet it by WITHHOLDING, never by dropping — no trade-off disappears, it moves into the option description where the operator can act on it.
34
+
35
+ **The spec carries no budget.** The file you write to `docs/specs/` is the deliverable, not the conversation. Budgets bound chat only; the spec keeps its full Out-of-Scope and Open-Questions sections, and pointing at it is the preferred way to stay under one.
36
+
37
+ **Escalation (all levels).** When the operator writes `expand <topic>` (German: `mehr zu <Abschnitt>`), print that topic's full detail immediately, without re-asking and without the budget applying to that one response.
38
+
39
+ **Never traded for brevity (all levels).** No budget may be met by cutting any of the following. Where a budget and one of them collide, the budget yields:
40
+ - input validation, and the reporting of invalid input;
41
+ - error handling, error messages, and failure disclosure — a swallowed error is never "concise";
42
+ - security findings, warnings, and destructive-action confirmations (PSA-003);
43
+ - accessibility of the output itself — no meaning carried by colour or emoji alone, no bare unlabelled numbers, no table whose header you dropped to save a line;
44
+ - anything the operator explicitly asked to see;
45
+ - the concrete pro and the concrete con on each option (`.claude/rules/ask-via-tool.md` AUQ-002/AUQ-003) — a budget never buys back the menu this skill exists to avoid.
46
+
47
+ ### output-level: ultra
48
+ - Meaning: telegraphic — the running summary, the trade-offs, the question. No narration.
49
+ - Budget: ≤10 lines per round; ≤2 lines of running summary; ≤1 line of recommendation reasoning before the tool call.
50
+ - Shape: bullets only. Trade-offs as `<option> — gains <X>, costs <Y>`. Never restate the user's last answer back at them.
51
+ - Escalation: `expand <topic>` — see § Escalation above.
52
+
53
+ ### output-level: full
54
+ - Meaning: terse but complete — framing trimmed, trade-offs preserved. This is the default.
55
+ - Budget: ≤25 lines per round; ≤4 lines of running summary; ≤2 lines of recommendation reasoning before the tool call.
56
+ - Shape: name the design tension in one line, then the options. Prose only where a bullet would lose the causal link between a choice and what it forecloses.
57
+ - Escalation: `expand <topic>` — see § Escalation above.
58
+
59
+ ### output-level: lite
60
+ - Meaning: verbose — the reasoning behind the narrowing is spelled out. Chosen for learning, not for speed.
61
+ - Budget: ≤60 lines per round; ≤10 lines of running summary. Still a ceiling — `lite` is not "unbounded".
62
+ - Shape: explain WHY each option is on the table, name the paths you already discarded and why, define unfamiliar terms on first use.
63
+ - Escalation: `expand <topic>` — see § Escalation above.
64
+
65
+ ### Companion dials
66
+
67
+ Same file, same lookup, same fallback-to-default rule:
68
+
69
+ - `efficiency.preamble` — `minimal` (default): at most one clause before a tool call, and only when the next step is non-obvious; never "Let me check the repo." immediately followed by checking it. `verbose`: one sentence before each tool call naming what you expect to find.
70
+ - `tone.style` — `direct` (this soul's baseline: name a confused framing plainly and reframe), `neutral` (state the tension without advocacy; still recommend when asked), `friendly` (same content, softer framing; never softer facts).
71
+
26
72
  ## Decision-Making Philosophy
27
73
 
28
74
  When design ambiguity arises, resolve it in this order:
@@ -1,6 +1,14 @@
1
1
  ---
2
2
  name: claude-md-drift-check
3
- description: Use when detecting drift between CLAUDE.md (or AGENTS.md, the Codex CLI alias) / _meta narrative and live repository state. Ten checks: absolute-path resolution, 01-projects/ count claims, issue-reference freshness, session-file existence, command-count sync, session-config-parity (mandatory template keys = error, opt-in gaps = warning), vault-dir-parity (CLAUDE.md vs AGENTS.md), generated-rule-staleness (WARN-only), rule-scoping (paths:/globs: frontmatter defects, dangling rule citations, zero-match globs), and docs-parity (docs/components.md count-claims vs on-disk counts, template-vs-reference config-key parity, stale .claude/metrics/ paths). Full per-check spec in the body table. Invoked as an opt-in session-end phase; mirrors vault-sync's lean JSON+exit-code contract.
3
+ description: >
4
+ Use when detecting drift between CLAUDE.md (or AGENTS.md, the Codex CLI alias) / _meta narrative and
5
+ live repository state. Ten checks: absolute-path resolution, 01-projects/ count claims, issue-reference
6
+ freshness, session-file existence, command-count sync, session-config-parity (mandatory template keys =
7
+ error, opt-in gaps = warning), vault-dir-parity (CLAUDE.md vs AGENTS.md), generated-rule-staleness
8
+ (WARN-only), rule-scoping (paths:/globs: frontmatter defects, dangling rule citations, zero-match
9
+ globs), and docs-parity (docs/components.md count-claims vs on-disk counts, template-vs-reference
10
+ config-key parity, stale .claude/metrics/ paths). Full per-check spec in the body table. Invoked as an
11
+ opt-in session-end phase; mirrors vault-sync's lean JSON+exit-code contract.
4
12
  model: haiku
5
13
  ---
6
14
 
@@ -1,6 +1,9 @@
1
1
  ---
2
2
  name: debug
3
- description: Use when encountering any bug, test failure, build break, or unexpected behavior — runs a 4-phase systematic debugging process before proposing any fix. Iron Law: NO FIXES WITHOUT ROOT CAUSE INVESTIGATION FIRST. Produces a `.orchestrator/debug/` artifact the fixer agent must reference.
3
+ description: >
4
+ Use when encountering any bug, test failure, build break, or unexpected behavior — runs a 4-phase
5
+ systematic debugging process before proposing any fix. Iron Law: NO FIXES WITHOUT ROOT CAUSE
6
+ INVESTIGATION FIRST. Produces a `.orchestrator/debug/` artifact the fixer agent must reference.
4
7
  model: inherit
5
8
  color: red
6
9
  tools: Read, Grep, Glob, Bash, Write
@@ -79,7 +79,7 @@ Used for a single finding from a single probe.
79
79
  **CLI Example:**
80
80
  ```bash
81
81
  # GitHub
82
- gh issue create \
82
+ gh issue create -R <OWNER>/<REPO> \
83
83
  --title "[Discovery] <finding_title>" \
84
84
  --label "type:discovery,priority::<level>,area:<area>,status:ready" \
85
85
  --body "$(cat <<'EOF'
@@ -88,7 +88,7 @@ EOF
88
88
  )"
89
89
 
90
90
  # GitLab
91
- glab issue create \
91
+ glab issue create -R <OWNER>/<REPO> \
92
92
  --title "[Discovery] <finding_title>" \
93
93
  --label "type:discovery,priority::<level>,area:<area>,status:ready" \
94
94
  --description "$(cat <<'EOF'
@@ -159,7 +159,7 @@ Used when multiple related findings from the same probe or category are grouped
159
159
  **CLI Example:**
160
160
  ```bash
161
161
  # GitHub
162
- gh issue create \
162
+ gh issue create -R <OWNER>/<REPO> \
163
163
  --title "[Discovery] <category> audit: <summary>" \
164
164
  --label "type:discovery,priority::<level>,area:<area>,status:ready" \
165
165
  --body "$(cat <<'EOF'
@@ -168,7 +168,7 @@ EOF
168
168
  )"
169
169
 
170
170
  # GitLab
171
- glab issue create \
171
+ glab issue create -R <OWNER>/<REPO> \
172
172
  --title "[Discovery] <category> audit: <summary>" \
173
173
  --label "type:discovery,priority::<level>,area:<area>,status:ready" \
174
174
  --description "$(cat <<'EOF'
@@ -58,9 +58,9 @@ Grep pattern: (TODO|FIXME|HACK|XXX|TEMP|WORKAROUND)[\s:()\-]
58
58
  For each match, check whether a corresponding VCS issue exists:
59
59
  ```bash
60
60
  # Search for issue referencing the annotation text
61
- gh issue list --search "<annotation text>" --limit 5
61
+ gh issue list -R <OWNER>/<REPO> --search "<annotation text>" --limit 5
62
62
  # or
63
- glab issue list --search "<annotation text>" --per-page 5
63
+ glab issue list -R <OWNER>/<REPO> --search "<annotation text>" --per-page 5
64
64
  ```
65
65
 
66
66
  Flag annotations with no corresponding issue.
@@ -113,11 +113,11 @@ Export Status: exported/public | internal
113
113
  1. List open feature/enhancement issues via the VCS CLI (syntax reference: `skills/gitlab-ops/SKILL.md` § "Common CLI Commands" — do not duplicate CLI flags here beyond what's needed to name the call):
114
114
  ```bash
115
115
  # GitLab
116
- glab issue list --label "feature" --per-page 100
117
- glab issue list --label "enhancement" --per-page 100
116
+ glab issue list -R <OWNER>/<REPO> --label "feature" --per-page 100
117
+ glab issue list -R <OWNER>/<REPO> --label "enhancement" --per-page 100
118
118
  # GitHub
119
- gh issue list --label "feature" --limit 100
120
- gh issue list --label "enhancement" --limit 100
119
+ gh issue list -R <OWNER>/<REPO> --label "feature" --limit 100
120
+ gh issue list -R <OWNER>/<REPO> --label "enhancement" --limit 100
121
121
  ```
122
122
 
123
123
  2. Extract theme keywords from each issue's title (and first paragraph of body where available): lowercase, strip stop-words, tokenize on non-alphanumeric boundaries. Group issues that share two or more significant keywords (e.g. "export", "csv", "invoice") into a candidate cluster.
@@ -125,10 +125,10 @@ gh issue list --label "enhancement" --limit 100
125
125
  3. For each candidate cluster with 3 or more issues, check whether ANY member issue is already linked to an epic (`epic` label, a GitLab Epic relationship, or an in-body reference such as `part of #<epic-iid>` / `Epic: #<epic-iid>`):
126
126
  ```bash
127
127
  # GitLab -- inspect labels + description for epic linkage
128
- glab issue view <IID>
128
+ glab issue view -R <OWNER>/<REPO> <IID>
129
129
  Grep pattern: (^Labels:.*\bepic\b|part of #|Epic:\s*#)
130
130
  # GitHub -- same check against issue body/labels
131
- gh issue view <NUMBER>
131
+ gh issue view -R <OWNER>/<REPO> <NUMBER>
132
132
  Grep pattern: (^labels:.*\bepic\b|part of #|Epic:\s*#)
133
133
  ```
134
134