@opengsd/gsd-core 1.20.1 → 1.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 (135) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. package/vscode/package.json +1 -1
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // ci-fix-next-rerun.cjs — #6076: re-run ONLY the `Base branch health` job of a
5
+ // PR's latest Tests run after the `fix-next` label was added or removed.
6
+ //
7
+ // Runs from .github/workflows/fix-next-rerun.yml (pull_request_target) against
8
+ // the BASE sha's copy of this file, never the PR's. The core is dependency
9
+ // injected (`gh`, `now`, `sleep`) so every branch is tested without a network.
10
+
11
+ const { execFile } = require('node:child_process');
12
+ const { runMain } = require('./lib/cli-exit.cjs');
13
+
14
+ const BASE_HEALTH_JOB_NAME = 'Base branch health';
15
+ const TESTS_WORKFLOW_FILE = 'test.yml';
16
+ const GH_TIMEOUT_MS = 30000;
17
+ const DEFAULT_DEADLINE_MS = 85 * 60 * 1000;
18
+ const DEFAULT_POLL_MS = 30 * 1000;
19
+ const PR_NUMBER_PATTERN = /^[1-9]\d*$/;
20
+ const HEAD_SHA_PATTERN = /^[0-9a-f]{40}$/i;
21
+
22
+ /**
23
+ * @param {object} opts
24
+ * @param {(args:string[]) => Promise<string>} opts.gh stdout of `gh <args>`; throws on failure
25
+ * @param {string} opts.repo owner/repo
26
+ * @param {string} opts.headSha head sha from the label event payload
27
+ * @param {string} opts.prNumber PR number
28
+ * @param {() => number} opts.now ms clock (read once at start, once per incomplete poll)
29
+ * @param {(ms:number) => Promise<void>} opts.sleep
30
+ * @param {number} opts.deadlineMs how long to wait for the run to complete
31
+ * @param {number} opts.pollMs
32
+ * @returns {Promise<{action:'rerun'|'skip'|'error', reason:string, runId?:string, jobId?:string}>}
33
+ */
34
+ async function rerunBaseHealth({
35
+ gh, repo, headSha, prNumber, now, sleep, deadlineMs = DEFAULT_DEADLINE_MS, pollMs = DEFAULT_POLL_MS,
36
+ }) {
37
+ try {
38
+ const deadline = now() + deadlineMs;
39
+ let run;
40
+ for (;;) {
41
+ const listed = JSON.parse(await gh([
42
+ 'run', 'list', '--workflow', TESTS_WORKFLOW_FILE, '--commit', headSha,
43
+ '--event', 'pull_request', '--limit', '1', '--json', 'databaseId,status,conclusion',
44
+ ]));
45
+ if (!Array.isArray(listed) || listed.length === 0) return { action: 'skip', reason: 'no-run' };
46
+ [run] = listed;
47
+ if (run.status === 'completed') break;
48
+ if (now() >= deadline) return { action: 'error', reason: 'timeout', runId: String(run.databaseId) };
49
+ await sleep(pollMs);
50
+ }
51
+ const runId = String(run.databaseId);
52
+
53
+ // A cancelled run was superseded by a newer one, which reads the label from
54
+ // the API; re-running a job here would rejoin the Tests concurrency group
55
+ // and could cancel that newer run.
56
+ if (run.conclusion === 'cancelled') return { action: 'skip', reason: 'run-cancelled', runId };
57
+
58
+ // A run waiting for approval has not executed; there is nothing to re-run.
59
+ if (run.conclusion === 'action_required') return { action: 'skip', reason: 'run-awaiting-approval', runId };
60
+
61
+ const readHead = async () => (await gh(['api', `repos/${repo}/pulls/${prNumber}`, '--jq', '.head.sha'])).trim();
62
+ if ((await readHead()) !== headSha) return { action: 'skip', reason: 'head-moved', runId };
63
+
64
+ const view = JSON.parse(await gh(['run', 'view', runId, '--json', 'jobs']));
65
+ const jobs = view && Array.isArray(view.jobs) ? view.jobs : [];
66
+ const job = jobs.find((j) => j && j.name === BASE_HEALTH_JOB_NAME);
67
+ if (!job) return { action: 'error', reason: 'no-job', runId };
68
+ const jobId = String(job.databaseId);
69
+
70
+ // Second read immediately before the re-run, to shrink the stale-run window.
71
+ if ((await readHead()) !== headSha) return { action: 'skip', reason: 'head-moved', runId };
72
+
73
+ await gh(['run', 'rerun', runId, '--job', jobId]);
74
+ return { action: 'rerun', reason: 'rerun', runId, jobId };
75
+ } catch (err) {
76
+ return { action: 'error', reason: `gh-failed: ${err && err.message ? err.message : String(err)}` };
77
+ }
78
+ }
79
+
80
+ function realGh(args) {
81
+ return new Promise((resolve, reject) => {
82
+ execFile('gh', args, { timeout: GH_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, encoding: 'utf8' }, (err, stdout) => {
83
+ if (err) reject(err);
84
+ else resolve(stdout);
85
+ });
86
+ });
87
+ }
88
+
89
+ /**
90
+ * @param {object} [deps] test seams: env, gh, now, sleep, stdout, stderr, deadlineMs, pollMs
91
+ * @returns {Promise<number>} exit code
92
+ */
93
+ async function main(deps = {}) {
94
+ const env = deps.env || process.env;
95
+ const out = deps.stdout || ((s) => process.stdout.write(s));
96
+ const errOut = deps.stderr || ((s) => process.stderr.write(s));
97
+
98
+ const prNumber = (env.PR_NUMBER || '').trim();
99
+ if (!PR_NUMBER_PATTERN.test(prNumber)) {
100
+ errOut(`::error::PR_NUMBER must be a positive integer, got ${JSON.stringify(prNumber)}\n`);
101
+ return 1;
102
+ }
103
+ const headSha = (env.HEAD_SHA || '').trim();
104
+ if (!HEAD_SHA_PATTERN.test(headSha)) {
105
+ errOut('::error::HEAD_SHA must be a 40-character hex sha\n');
106
+ return 1;
107
+ }
108
+ const repo = (env.GH_REPO || '').trim();
109
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
110
+ errOut('::error::GH_REPO must be owner/repo\n');
111
+ return 1;
112
+ }
113
+
114
+ const result = await rerunBaseHealth({
115
+ gh: deps.gh || realGh,
116
+ repo,
117
+ headSha,
118
+ prNumber,
119
+ now: deps.now || Date.now,
120
+ sleep: deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
121
+ deadlineMs: deps.deadlineMs,
122
+ pollMs: deps.pollMs,
123
+ });
124
+
125
+ if (result.action === 'error') {
126
+ errOut(`::error::fix-next re-run failed (${result.reason})${result.runId ? ` on Tests run ${result.runId}` : ''}; re-run "${BASE_HEALTH_JOB_NAME}" by hand.\n`);
127
+ return 1;
128
+ }
129
+ if (result.action === 'skip') {
130
+ out(`::notice::fix-next re-run skipped (${result.reason}); the newer or upcoming run reads the label from the API.\n`);
131
+ return 0;
132
+ }
133
+ out(`Re-ran "${BASE_HEALTH_JOB_NAME}" (job ${result.jobId}) on Tests run ${result.runId}.\n`);
134
+ return 0;
135
+ }
136
+
137
+ if (require.main === module) {
138
+ runMain(main);
139
+ }
140
+
141
+ module.exports = {
142
+ BASE_HEALTH_JOB_NAME,
143
+ TESTS_WORKFLOW_FILE,
144
+ DEFAULT_DEADLINE_MS,
145
+ DEFAULT_POLL_MS,
146
+ GH_TIMEOUT_MS,
147
+ rerunBaseHealth,
148
+ main,
149
+ };
@@ -17,7 +17,8 @@
17
17
  // with a non-success conclusion for the base branch's last push-triggered Tests
18
18
  // run, that is treated as authoritative and blocks — with one explicit, visible,
19
19
  // human-operated escape hatch (the `fix-next` label) for the PR that is itself
20
- // the fix-forward.
20
+ // the fix-forward. On a pull request the label is read from the API (#6076), so
21
+ // after it is added the fix-next-rerun workflow re-runs this check and sees it.
21
22
  //
22
23
  // Every non-success conclusion (`failure`, `cancelled`, `timed_out`,
23
24
  // `action_required`, ...) is treated as RED, not just `failure` — a cancelled or
@@ -205,11 +206,14 @@ async function evaluateQueueBypass({ headRef, baseRef, baseSha, headSha, repo, g
205
206
  * (or throwing) — injected so tests never touch the network.
206
207
  * `evaluateQueueBypass` (merge_group only) is an async function returning
207
208
  * `{authorized, reason, actor}`; it is how a queued fix-forward is recognised.
209
+ * `fetchPrLabels` (pull_request only, RED base only) is an async function
210
+ * returning the PR's current label names (or throwing, which keeps the gate
211
+ * RED with reason `label-read-failed`); when absent, `labels` decides (#6076).
208
212
  *
209
213
  * @returns {Promise<{verdict:string, payload:*, reason:string}>}
210
214
  */
211
215
  async function resolveNextHealth({
212
- fetchLatestRun, eventName, baseRef, labels, evaluateQueueBypass: queueBypass,
216
+ fetchLatestRun, eventName, baseRef, labels, fetchPrLabels, evaluateQueueBypass: queueBypass,
213
217
  } = {}) {
214
218
  if (!APPLICABLE_EVENTS.has(eventName)) {
215
219
  return { verdict: VERDICT.SKIPPED_NOT_APPLICABLE, payload: null, reason: 'not-applicable-event' };
@@ -235,10 +239,25 @@ async function resolveNextHealth({
235
239
  return { verdict: assessed.verdict, payload: evidence, reason };
236
240
  }
237
241
 
238
- // Escape hatch for the PR that is itself the fix-forward. pull_request runs
239
- // read the label from the event (`labels`). merge_group events carry no
240
- // labels, so the queue run asks the API instead (evaluateQueueBypass).
241
- const labelList = Array.isArray(labels) ? labels : [];
242
+ // Escape hatch for the PR that is itself the fix-forward. A pull_request run
243
+ // with `fetchPrLabels` reads the PR's CURRENT labels from the API (#6076), so
244
+ // a re-run sees a label added or removed after the run started; a failed read
245
+ // keeps the gate RED. Without it, the event's `labels` decide (bootstrap).
246
+ // merge_group events carry no labels, so the queue run asks the API instead
247
+ // (evaluateQueueBypass).
248
+ let labelList = Array.isArray(labels) ? labels : [];
249
+ if (eventName === 'pull_request' && typeof fetchPrLabels === 'function') {
250
+ try {
251
+ const current = await fetchPrLabels();
252
+ labelList = Array.isArray(current) ? current : [];
253
+ } catch (err) {
254
+ return {
255
+ verdict: VERDICT.RED,
256
+ payload: evidence,
257
+ reason: `${assessed.reason === 'resolved' ? 'red' : assessed.reason}; label-read-failed: ${err.message}`,
258
+ };
259
+ }
260
+ }
242
261
  if (labelList.includes(BYPASS_LABEL)) {
243
262
  return { verdict: VERDICT.BYPASSED, payload: evidence, reason: 'bypass-label' };
244
263
  }
@@ -281,6 +300,9 @@ function usage() {
281
300
  'newest settled run decides. Every other case (wrong event, no',
282
301
  'resolvable base ref, an unreadable API read) fails open (exit 0).',
283
302
  '',
303
+ 'On pull_request with PR_NUMBER set, the label is read from the API, so a',
304
+ 're-run sees the PR\'s current labels (#6076); a failed read keeps the gate RED.',
305
+ '',
284
306
  'On merge_group the label is read back from the API: the queue ref must',
285
307
  'name a PR, the group must hold exactly one commit, the PR must carry the',
286
308
  'label, and whoever applied it must have write or admin permission.',
@@ -300,7 +322,15 @@ function usage() {
300
322
  ' PR_LABELS comma-separated PR label names (pull_request events;',
301
323
  ' the caller workflow must populate this from',
302
324
  ' github.event.pull_request.labels.*.name); checked',
303
- ' for the literal "fix-next" bypass label',
325
+ ' for the literal "fix-next" bypass label; the fallback',
326
+ ' when PR_NUMBER is unset or malformed',
327
+ ' PR_NUMBER PR number (pull_request events; from',
328
+ ' github.event.pull_request.number); trimmed, then it',
329
+ ' must match /^[1-9]\\d*$/. When valid, the label is',
330
+ ' read from the API so a re-run sees the current',
331
+ ' label; a failed read keeps the gate RED. A',
332
+ ' malformed value (0, 07, -1, abc, empty) falls back',
333
+ ' to PR_LABELS',
304
334
  ' MERGE_GROUP_HEAD_REF merge-group queue branch',
305
335
  ' (github.event.merge_group.head_ref; falls back to',
306
336
  ' GITHUB_REF)',
@@ -437,8 +467,19 @@ async function main(argv = process.argv.slice(2)) {
437
467
  ghGet,
438
468
  });
439
469
 
470
+ // #6076: with a valid PR number, read the PR's current labels from the API.
471
+ const prNumber = (process.env.PR_NUMBER || '').trim();
472
+ const fetchPrLabels = /^[1-9]\d*$/.test(prNumber)
473
+ ? async () => {
474
+ const body = await ghGet(`/repos/${repo}/issues/${prNumber}/labels?per_page=100`);
475
+ return Array.isArray(body)
476
+ ? body.map((l) => l && l.name).filter((name) => typeof name === 'string')
477
+ : [];
478
+ }
479
+ : undefined;
480
+
440
481
  const result = await resolveNextHealth({
441
- fetchLatestRun, eventName, baseRef, labels, evaluateQueueBypass: evaluateQueue,
482
+ fetchLatestRun, eventName, baseRef, labels, fetchPrLabels, evaluateQueueBypass: evaluateQueue,
442
483
  });
443
484
 
444
485
  writeOutput([`verdict=${result.verdict}`]);
@@ -450,7 +491,9 @@ async function main(argv = process.argv.slice(2)) {
450
491
  `::error::the base branch "${baseRef}"'s own last Tests run is red or still re-running after a failure: ${runUrl} (${result.reason}) — `
451
492
  + 'wait for a fix-forward merge, or if THIS pull request is the fix, ask a '
452
493
  + `maintainer with write access to apply the "${BYPASS_LABEL}" label and re-queue it `
453
- + '(the queue run reads the label from the API; it must be the only PR in its merge group).\n',
494
+ + '(the queue run reads the label from the API; it must be the only PR in its merge group). '
495
+ + 'On a pull request the label is read from the API too, so after it is added the '
496
+ + 'fix-next-rerun workflow re-runs this check.\n',
454
497
  );
455
498
  return 1;
456
499
  }
@@ -6,34 +6,68 @@ const { execFileSync } = require('child_process');
6
6
  const { existsSync, readdirSync, appendFileSync, readFileSync } = require('fs');
7
7
 
8
8
  const { ExitError, runMain } = require('./lib/cli-exit.cjs');
9
- const { classifyContent, NOISY_FOR_SOURCE_REACHABILITY } = require('./gen-platform-conformance-tier.cjs');
10
-
11
- // Workflow files that are purely administrative / policy bots. Changes to these
12
- // files do NOT require the cross-platform test matrix — only a lightweight
13
- // ubuntu lane running workflow-lint tests is needed.
14
- // FAIL-SAFE: any .github/workflows/*.yml NOT listed here is treated as a
15
- // pipeline workflow and gets the full matrix. New workflow files default to full.
16
- const INERT_WORKFLOWS = new Set([
17
- 'stale.yml',
18
- 'branch-cleanup.yml',
19
- 'branch-naming.yml',
20
- 'auto-label-issues.yml',
21
- 'auto-branch.yml',
22
- 'auto-backmerge.yml',
23
- 'close-draft-prs.yml',
24
- 'dismiss-unauthorized-pr-approvals.yml',
25
- 'pr-target-validator.yml',
26
- 'pr-template-format.yml',
27
- 'require-issue-link.yml',
28
- 'changeset-required.yml',
29
- 'docs-required.yml',
30
- 'discord-changelog.yml',
31
- ]);
9
+ const { classifyUnstrippedContent, NOISY_FOR_SOURCE_REACHABILITY } = require('./gen-platform-conformance-tier.cjs');
10
+
11
+ // #6075: CI YAML (any `.github/**/*.yml|*.yaml`: workflows, composite actions,
12
+ // dependabot/labeler/issue-form config) is "inert" CI. A PR that changes only CI
13
+ // YAML never runs the matrix: it reports code_changed=true, product_changed=false,
14
+ // full_matrix=false and targets only the workflow-lint tests (the `test-inert`
15
+ // lane). Mixed with product code, the product files decide the scope.
16
+ // Anchored at the repo root: a nested `.github/` under a product prefix is not CI YAML.
17
+ const CI_YAML_RE = /^\.github\/.+\.ya?ml$/;
18
+ function isCiYaml(filePath) {
19
+ return CI_YAML_RE.test(filePath);
20
+ }
21
+
22
+ // Tests shared by both the 'workflow automation' and 'inert CI' rules.
23
+ const WORKFLOW_LINT_TESTS = [
24
+ 'tests/workflow-shell-pinning.test.cjs',
25
+ 'tests/pr-template-policy.test.cjs',
26
+ 'tests/lint-pr-check-project-dir.test.cjs',
27
+ ];
28
+
29
+ // #6075: tests that read `.github/**` YAML content (workflow contracts). A CI
30
+ // YAML edit re-runs them in the targeted lane (never full_matrix). Several are
31
+ // conformance-tier members; that does not flip full_matrix for a CI-YAML-only
32
+ // change because classify() exempts isCiYaml(file) from the windows-hint
33
+ // escalation and forces full_matrix=false when only CI YAML changed.
34
+ const WORKFLOW_CONTRACT_TESTS = [
35
+ 'tests/adr-218-release-version-validation.test.cjs',
36
+ 'tests/adr-230-pr-target-policy.test.cjs',
37
+ 'tests/ci-conformance-pr-gating.test.cjs',
38
+ 'tests/ci-docs-guard-registry.test.cjs',
39
+ 'tests/ci-durations-upload-nonfatal.test.cjs',
40
+ 'tests/ci-full-lane-sharding.test.cjs',
41
+ 'tests/ci-merge-group-wiring.test.cjs',
42
+ 'tests/ci-pr-mergeability.test.cjs',
43
+ 'tests/ci-test-job-timeout-budget.test.cjs',
44
+ 'tests/ci-test-scope.test.cjs',
45
+ 'tests/merge-commit-pr.test.cjs',
46
+ 'tests/mutation-tap-runner-wiring.test.cjs',
47
+ 'tests/mutation-workflow-base-ref.test.cjs',
48
+ 'tests/policy-release-no-npm-self-upgrade.test.cjs',
49
+ 'tests/policy-shell-pinning.test.cjs',
50
+ 'tests/release-backmerge-invariants.test.cjs',
51
+ 'tests/release-coverage-scope.test.cjs',
52
+ 'tests/release-finalize-notes-5922.test.cjs',
53
+ 'tests/release-finalize-syncs-next-version.test.cjs',
54
+ 'tests/release-hotfix-empty-cherry-pick.test.cjs',
55
+ 'tests/release-notes-real-os-5922.test.cjs',
56
+ 'tests/release-shard-lane-sharding.test.cjs',
57
+ 'tests/release-tarball-smoke-workflow.test.cjs',
58
+ 'tests/require-issue-link-policy.test.cjs',
59
+ 'tests/reviewed-or-maintainer-workflow.test.cjs',
60
+ 'tests/security-scan.security.test.cjs',
61
+ 'tests/self-test-report-workflow.test.cjs',
62
+ 'tests/workflow-token-permissions.test.cjs',
63
+ ];
32
64
 
33
65
  // Workflows that gate merges, ship the product, or run security/cross-platform
34
- // suites — these must ALWAYS get the full pipeline treatment and can never be
35
- // added to INERT_WORKFLOWS. A module-load assertion enforces this so a mistaken
36
- // or malicious addition fails CI loudly in the `changes` job on every PR.
66
+ // suites. Kept as a registry of gating workflows. Enforced by the test
67
+ // 'every protected workflow name refers to a real file' in
68
+ // tests/ci-pr-mergeability.test.cjs (existence parity) and, at module load, by
69
+ // missingProtectedWorkflows() below. Since #6075 a change to any of them is CI
70
+ // YAML and, alone, runs only the workflow-lint and workflow-contract tests.
37
71
  const PROTECTED_WORKFLOWS = new Set([
38
72
  'test.yml',
39
73
  'install-smoke.yml',
@@ -41,41 +75,16 @@ const PROTECTED_WORKFLOWS = new Set([
41
75
  'security-scan.yml',
42
76
  'release.yml',
43
77
  // #3833: the reusable gate every `pull_request` compute lane depends on —
44
- // marking it inert would let a change to the gate itself ship without ever
45
- // running the full matrix it is responsible for enforcing.
78
+ // the gate it defines is exercised by tests/ci-pr-mergeability.test.cjs.
46
79
  'pr-mergeable-preflight.yml',
47
80
  ]);
48
- for (const wf of PROTECTED_WORKFLOWS) {
49
- if (INERT_WORKFLOWS.has(wf)) {
50
- throw new Error(`ci-test-scope: protected workflow "${wf}" must not be in INERT_WORKFLOWS (it requires the full test matrix).`);
51
- }
52
- }
53
-
54
- /**
55
- * Returns true if the path is an inert (non-pipeline) workflow file.
56
- * Only `.github/workflows/<name>` where <name> is in INERT_WORKFLOWS qualifies.
57
- */
58
- function isInertCi(filePath) {
59
- if (!filePath.startsWith('.github/workflows/')) return false;
60
- const name = filePath.slice('.github/workflows/'.length);
61
- // Must be a direct child (no further slashes) and in the allowlist.
62
- return !name.includes('/') && INERT_WORKFLOWS.has(name);
63
- }
64
-
65
- // Tests shared by both the 'workflow automation' and 'inert CI' rules.
66
- const WORKFLOW_LINT_TESTS = [
67
- 'tests/workflow-shell-pinning.test.cjs',
68
- 'tests/pr-template-policy.test.cjs',
69
- 'tests/lint-pr-check-project-dir.test.cjs',
70
- ];
71
81
 
72
82
  const RULES = [
73
83
  {
74
84
  name: 'workflow automation',
75
- // Only NON-inert .github/workflows/* and all .github/rulesets/* trigger full matrix.
76
- // FAIL-SAFE: any .github/workflows/*.yml not in INERT_WORKFLOWS is treated as pipeline.
77
- match: filePath => (filePath.startsWith('.github/workflows/') && !isInertCi(filePath)) ||
78
- filePath.startsWith('.github/rulesets/'),
85
+ // .github/rulesets/* (JSON branch protection) trigger the full matrix.
86
+ // CI YAML (.github/**/*.y{a,}ml) is the 'inert CI' rule below (#6075).
87
+ match: filePath => filePath.startsWith('.github/rulesets/'),
79
88
  fullMatrix: true,
80
89
  tests: [
81
90
  ...WORKFLOW_LINT_TESTS,
@@ -84,11 +93,12 @@ const RULES = [
84
93
  },
85
94
  {
86
95
  name: 'inert CI',
87
- match: filePath => isInertCi(filePath),
96
+ match: filePath => isCiYaml(filePath),
88
97
  fullMatrix: false,
89
98
  tests: [
90
99
  ...WORKFLOW_LINT_TESTS,
91
100
  'tests/policy-lint-shallow-checkout.test.cjs',
101
+ ...WORKFLOW_CONTRACT_TESTS,
92
102
  ],
93
103
  },
94
104
  {
@@ -530,7 +540,7 @@ function reachesConformanceTierOrSeam(file, deps = {}) {
530
540
 
531
541
  if (file.startsWith('src/')) {
532
542
  const content = readFileSync(file, 'utf8');
533
- const { signals } = classifyContent(content);
543
+ const { signals } = classifyUnstrippedContent(content);
534
544
  const narrowSignals = signals.filter(signal => !NOISY_FOR_SOURCE_REACHABILITY.has(signal));
535
545
  return narrowSignals.length > 0;
536
546
  }
@@ -549,7 +559,7 @@ function classify(files, reachabilityDeps = {}) {
549
559
  const targeted = new Set();
550
560
  const reasons = [];
551
561
  let productOrPipelineChanged = false; // product/pipeline code (excludes docs)
552
- let inertCiChanged = false; // inert workflow files
562
+ let inertCiChanged = false; // CI YAML files (#6075)
553
563
  let fullMatrix = false;
554
564
 
555
565
  for (const file of files) {
@@ -571,13 +581,8 @@ function classify(files, reachabilityDeps = {}) {
571
581
  productOrPipelineChanged = true;
572
582
  }
573
583
 
574
- // Non-inert .github/workflows/* are pipeline code → full matrix.
575
- if (file.startsWith('.github/workflows/') && !isInertCi(file)) {
576
- productOrPipelineChanged = true;
577
- }
578
-
579
- // Inert workflow files set a lightweight signal.
580
- if (isInertCi(file)) {
584
+ // CI YAML sets a lightweight signal; it is never product/pipeline code.
585
+ if (isCiYaml(file)) {
581
586
  inertCiChanged = true;
582
587
  }
583
588
 
@@ -637,11 +642,13 @@ function classify(files, reachabilityDeps = {}) {
637
642
  // 14 of 16 rules, newly escalating most ordinary product-code PRs
638
643
  // (src/, agents/, commands/, hooks/, skills/, config paths). Only one
639
644
  // rule's escalation is live today: 'portability lint rules (ADR-1703)'.
640
- // Four others already had fullMatrix: true (no-op here), and 'inert
641
- // CI''s escalation is overridden downstream by the inert-CI reset.
645
+ // Four others already had fullMatrix: true (no-op here), and CI YAML
646
+ // (the 'inert CI' rule) is excluded from this escalation entirely.
642
647
  // Distinct, greppable reason so the conformance-lane coverage for it
643
648
  // is traceable per rule.
644
- if (rule.tests.some(t => isWindowsHint(t) && reachesConformanceTierOrSeam(t, reachabilityDeps))) {
649
+ // #6075: CI YAML never escalates (it is lint-only scope, also when mixed
650
+ // with product code, so the product files alone decide full_matrix).
651
+ if (!isCiYaml(file) && rule.tests.some(t => isWindowsHint(t) && reachesConformanceTierOrSeam(t, reachabilityDeps))) {
645
652
  fullMatrix = true;
646
653
  reasons.push(`${file}: ${rule.name} (windows-hint rule test, #4641)`);
647
654
  }
@@ -660,7 +667,7 @@ function classify(files, reachabilityDeps = {}) {
660
667
  ]);
661
668
  for (const f of SCOPED_LANE_EXCLUDE) { targeted.delete(f); }
662
669
 
663
- // code_changed: true when product/pipeline OR inert CI changed.
670
+ // code_changed: true when product/pipeline code OR CI YAML changed.
664
671
  // Docs-only PRs (neither flag set) get code_changed=false → full matrix skip.
665
672
  const codeChanged = productOrPipelineChanged || inertCiChanged;
666
673
 
@@ -672,7 +679,7 @@ function classify(files, reachabilityDeps = {}) {
672
679
  targetedTests.push('unit');
673
680
  }
674
681
 
675
- // Inert-CI-only: full_matrix must be false (override any RULES that fired).
682
+ // CI-YAML-only: full_matrix must be false (override any RULES that fired).
676
683
  if (inertCiChanged && !productOrPipelineChanged) {
677
684
  fullMatrix = false;
678
685
  }
@@ -732,7 +739,6 @@ module.exports = {
732
739
  RULES,
733
740
  missingRuleTestFiles,
734
741
  PROTECTED_WORKFLOWS,
735
- INERT_WORKFLOWS,
736
742
  missingProtectedWorkflows,
737
743
  classify,
738
744
  reachesConformanceTierOrSeam,
@@ -939,21 +939,70 @@ const REMEDIES = Object.freeze({
939
939
  *
940
940
  * File-derived strings (registry cells, marker tokens, workflow text) are
941
941
  * echoed into lint output that AI agents read as trusted instructions —
942
- * every interpolated field is control-char-stripped and length-capped
943
- * before it is written, so a malicious repo file cannot make the lint emit
944
- * instruction-shaped text. Implemented as a code-point filter rather than a
945
- * regex so no control-character regex literal exists to lint.
942
+ * every interpolated field has its hostile code points ESCAPED and is
943
+ * length-capped before it is written, so a malicious repo file cannot make
944
+ * the lint emit instruction-shaped text, and the reader can still trace the
945
+ * spelling as written (a dropped character would show text that does not
946
+ * occur in the file).
947
+ *
948
+ * Escape format: each hostile code point (see isEchoHostile) is rendered as
949
+ * `\u{X}` with UPPERCASE hex and no padding, e.g. U+0085 -> `\u{85}`,
950
+ * U+200B -> `\u{200B}`, tag characters (U+E0000-U+E007F) -> `\u{E0041}`. The
951
+ * escape introducer itself, a literal backslash U+005C, is escaped as `\u{5C}`
952
+ * so a spelled-out `\u{202E}` in the input can never print identically to a
953
+ * real U+202E: whenever nothing was dropped (guaranteed when the output is at
954
+ * most 190 code points, since the longest escape `\u{10FFFF}` is 10), the
955
+ * output decodes back to exactly the input, so distinct inputs have distinct
956
+ * output. Once the cap truncates, the tail is dropped and inputs differing
957
+ * only beyond it can share one output (e.g. 195 `a` with and without a
958
+ * trailing U+0085). The output is free of every hostile character.
959
+ *
960
+ * Cap: 200 code points of OUTPUT (an escape counts as its own length, never
961
+ * split), so the result is bounded however hostile the input; scanning stops
962
+ * as soon as the next unit would exceed the cap, costing O(200) matches on a
963
+ * huge input. A surrogate pair is never split.
964
+ *
965
+ * Implemented as a code-point filter rather than a regex so no
966
+ * control-character regex literal exists to lint.
946
967
  */
947
968
  function sanitizeEcho(text) {
969
+ const CAP = 200;
948
970
  let out = '';
971
+ let used = 0;
949
972
  for (const ch of String(text)) {
950
973
  const code = ch.codePointAt(0);
951
- if (code <= 0x1f || code === 0x7f) continue;
952
- out += ch;
974
+ const hostile = code === 0x5c || isEchoHostile(code);
975
+ const unit = hostile ? `\\u{${code.toString(16).toUpperCase()}}` : ch;
976
+ const len = hostile ? unit.length : 1; // escapes are ASCII: one code point per char
977
+ if (used + len > CAP) break;
978
+ out += unit;
979
+ used += len;
953
980
  }
954
- return out.length > 200 ? out.slice(0, 200) : out;
981
+ return out;
982
+ }
983
+
984
+ /**
985
+ * Code points sanitizeEcho escapes: C0 + DEL, C1 (incl. 8-bit CSI U+009B),
986
+ * line/paragraph separators, zero-width + LRM/RLM, bidi embeddings/overrides
987
+ * and isolates, the Arabic letter mark, word joiner/invisible operators, the
988
+ * Mongolian vowel separator, the soft hyphen and the BOM; all able to forge
989
+ * or hide output a reader (human or agent) trusts. Escaping U+200D makes
990
+ * joined emoji sequences visible as `\u{200D}`, which is acceptable for echoed paths.
991
+ * @param {number} code
992
+ * @returns {boolean}
993
+ */
994
+ function isEchoHostile(code) {
995
+ return ECHO_HOSTILE_RE.test(String.fromCodePoint(code));
955
996
  }
956
997
 
998
+ // Category-based: Cc controls (C0, DEL, C1), Cf format chars (zero-width, bidi,
999
+ // soft hyphen, BOM, tags U+E0000-E007F, interlinear annotation, musical
1000
+ // format), Zl/Zp separators, Cs surrogates, Co private use, Cn unassigned;
1001
+ // plus variation selectors and the invisible filler characters that are not Cf.
1002
+ // Each combining/variation/filler member is its own alternative so no
1003
+ // character class holds a combining sequence (no-misleading-character-class).
1004
+ const ECHO_HOSTILE_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}\p{Co}\p{Cn}]|[\u{FE00}-\u{FE0F}]|[\u{E0100}-\u{E01EF}]|\u{180B}|\u{180C}|\u{180D}|\u{180F}|\u{34F}|\u{115F}|\u{1160}|\u{3164}|\u{FFA0}|\u{17B4}|\u{17B5}|\u{2800}/u;
1005
+
957
1006
  module.exports = {
958
1007
  CANONICAL_TOOLS,
959
1008
  parseFrontmatter,
@@ -180,6 +180,7 @@ const DOCS_GUARD_TESTS = {
180
180
  // against the milestone-release-map module's exported enums.
181
181
  'tests/milestone-release-map.test.cjs': ['docs/CONFIGURATION.md'],
182
182
  'tests/analyze-dependencies.test.cjs': ['docs/COMMANDS.md'],
183
+ 'tests/smart-discuss-classification.test.cjs': ['docs/COMMANDS.md'],
183
184
  'tests/auto-select-attribute.test.cjs': ['docs/reference/plan-md.md'],
184
185
  'tests/autonomous-converge.test.cjs': [
185
186
  'docs/COMMANDS.md',
@@ -192,6 +193,7 @@ const DOCS_GUARD_TESTS = {
192
193
  'docs/reference/capability-manifest.md',
193
194
  ],
194
195
  'tests/claude-md.test.cjs': ['docs/COMMANDS.md'],
196
+ 'tests/claude-orchestration-4747.test.cjs': ['docs/how-to/enable-claude-orchestration-workflow-backend.md'],
195
197
  'tests/claude-orchestration.test.cjs': ['docs/explanation/claude-orchestration-capability.md'],
196
198
  'tests/command-contract.test.cjs': [
197
199
  'docs/INVENTORY.md',
@@ -309,6 +311,7 @@ const DOCS_GUARD_TESTS = {
309
311
  'docs/pt-BR/context-monitor.md',
310
312
  'docs/zh-CN/context-monitor.md',
311
313
  ],
314
+ 'tests/gen-cli-surface-manifest.test.cjs': ['docs/CLI-SURFACE-MANIFEST.json'],
312
315
  'tests/gen-state-md-docs.test.cjs': [
313
316
  'docs/reference/state-md.md',
314
317
  'docs/ja-JP/reference/state-md.md',