@opengsd/gsd-core 1.20.0 → 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 (154) 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 +265 -161
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/new-milestone.md +2 -2
  11. package/commands/gsd/new-project.md +2 -2
  12. package/gsd-core/bin/gsd-tools.cjs +554 -823
  13. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  14. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  15. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  16. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  17. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  18. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  20. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  21. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  22. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  23. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  24. package/gsd-core/bin/lib/commands.cjs +99 -2
  25. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  26. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  27. package/gsd-core/bin/lib/config.cjs +139 -101
  28. package/gsd-core/bin/lib/configuration.cjs +41 -2
  29. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  30. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  31. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  32. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  33. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  34. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  35. package/gsd-core/bin/lib/graphify.cjs +19 -2
  36. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  37. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  38. package/gsd-core/bin/lib/init.cjs +20 -18
  39. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  40. package/gsd-core/bin/lib/install-engine.cjs +20 -28
  41. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  42. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  43. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  44. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  45. package/gsd-core/bin/lib/io.cjs +6 -0
  46. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  47. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  48. package/gsd-core/bin/lib/milestone.cjs +8 -6
  49. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  50. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  51. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  52. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  53. package/gsd-core/bin/lib/phase.cjs +170 -45
  54. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  55. package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
  56. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  57. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  58. package/gsd-core/bin/lib/project-context.cjs +439 -0
  59. package/gsd-core/bin/lib/project-root.cjs +9 -230
  60. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  61. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
  63. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  64. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  65. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
  66. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  67. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  69. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  71. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  72. package/gsd-core/bin/lib/state-transition.cjs +103 -46
  73. package/gsd-core/bin/lib/state.cjs +448 -63
  74. package/gsd-core/bin/lib/surface.cjs +4 -7
  75. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  76. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  77. package/gsd-core/bin/lib/verify.cjs +49 -6
  78. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  79. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  80. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  81. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  82. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  83. package/gsd-core/references/checkpoints.md +1 -1
  84. package/gsd-core/references/planner-guidance.md +11 -0
  85. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  86. package/gsd-core/references/planner-reversibility.md +2 -0
  87. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  88. package/gsd-core/workflows/autonomous.md +4 -4
  89. package/gsd-core/workflows/code-review-fix.md +5 -7
  90. package/gsd-core/workflows/execute-phase.md +21 -7
  91. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  92. package/gsd-core/workflows/plan-phase.md +18 -19
  93. package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
  94. package/hooks/dist/gsd-config-reload.js +2 -2
  95. package/hooks/dist/gsd-context-monitor.js +2 -1
  96. package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
  97. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  98. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  99. package/hooks/dist/gsd-statusline.js +3 -2
  100. package/hooks/dist/gsd-workflow-guard.js +2 -1
  101. package/hooks/dist/lib/config-read.js +49 -0
  102. package/hooks/dist/lib/git-cmd.js +3 -1
  103. package/hooks/dist/lib/git-head-tree.js +474 -0
  104. package/hooks/dist/lib/isolation-sentinel.js +168 -13
  105. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  106. package/hooks/gsd-agent-isolation-guard.js +4 -3
  107. package/hooks/gsd-config-reload.js +2 -2
  108. package/hooks/gsd-context-monitor.js +2 -1
  109. package/hooks/gsd-cursor-subagent-start.js +9 -6
  110. package/hooks/gsd-read-injection-scanner.js +2 -2
  111. package/hooks/gsd-secret-read-guard.js +141 -6
  112. package/hooks/gsd-statusline.js +3 -2
  113. package/hooks/gsd-workflow-guard.js +2 -1
  114. package/hooks/lib/config-read.js +49 -0
  115. package/hooks/lib/git-cmd.js +3 -1
  116. package/hooks/lib/git-head-tree.js +474 -0
  117. package/hooks/lib/isolation-sentinel.js +168 -13
  118. package/hooks/lib/secret-read-exemptions.js +161 -0
  119. package/package.json +7 -2
  120. package/pi/package-agents/gsd-executor.md +1 -1
  121. package/pi/package-agents/gsd-plan-checker.md +1 -1
  122. package/pi/package-agents/gsd-planner.md +3 -3
  123. package/pi/package-agents/gsd-verifier.md +2 -2
  124. package/pi/package-agents/manifest.json +4 -4
  125. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  126. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  127. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  128. package/scripts/changeset/cli.cjs +2 -2
  129. package/scripts/changeset/lint.cjs +28 -3
  130. package/scripts/changeset/serialize.cjs +12 -2
  131. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  132. package/scripts/ci-fix-next-rerun.cjs +149 -0
  133. package/scripts/ci-next-health.cjs +52 -9
  134. package/scripts/ci-test-scope.cjs +77 -71
  135. package/scripts/command-contract-helpers.cjs +56 -7
  136. package/scripts/docs-guard-registry.cjs +3 -0
  137. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  138. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  139. package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
  140. package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
  141. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  142. package/scripts/lint-removed-but-needed.cjs +562 -50
  143. package/scripts/merge-commit-pr.cjs +186 -0
  144. package/scripts/release-ancestry.cjs +86 -0
  145. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  146. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  147. package/scripts/release-pipeline-pr.cjs +92 -0
  148. package/scripts/require-issue-link-policy.cjs +18 -9
  149. package/scripts/sync-next-version.cjs +11 -2
  150. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  151. package/skills/gsd-autonomous/SKILL.md +1 -1
  152. package/skills/gsd-new-milestone/SKILL.md +1 -1
  153. package/skills/gsd-new-project/SKILL.md +1 -1
  154. package/vscode/package.json +1 -1
@@ -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',
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Generates docs/CLI-SURFACE-MANIFEST.json from the CLI surface registry
6
+ * (`src/cli-surface.cts`, compiled to `gsd-core/bin/lib/cli-surface.cjs`) and the
7
+ * command aliases (`gsd-core/bin/lib/command-aliases.cjs`). ADR-4909 D2, #6087.
8
+ *
9
+ * Usage:
10
+ * node scripts/gen-cli-surface-manifest.cjs # print the manifest to stdout
11
+ * node scripts/gen-cli-surface-manifest.cjs --write # rewrite the manifest file
12
+ * node scripts/gen-cli-surface-manifest.cjs --check # exit 1 if the manifest is missing/stale
13
+ * node scripts/gen-cli-surface-manifest.cjs --check --json # machine-readable report
14
+ * node scripts/gen-cli-surface-manifest.cjs --manifest-path <p> # test-only: target another file
15
+ */
16
+
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+
20
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
21
+
22
+ const ROOT = path.resolve(__dirname, '..');
23
+ const MANIFEST_REL = 'docs/CLI-SURFACE-MANIFEST.json';
24
+ const MANIFEST_PATH = path.join(ROOT, MANIFEST_REL);
25
+ const SURFACE_REL = 'gsd-core/bin/lib/cli-surface.cjs';
26
+ const SURFACE_PATH = path.join(ROOT, SURFACE_REL);
27
+ const ALIASES_PATH = path.join(ROOT, 'gsd-core/bin/lib/command-aliases.cjs');
28
+
29
+ const USAGE =
30
+ 'Usage: node scripts/gen-cli-surface-manifest.cjs [--write | --check] [--json] [--manifest-path <path>]';
31
+
32
+ // Each compiled module the manifest reads, beside the source it is built from. The registry
33
+ // is read through its compiled form, so a stale compile would write or check a stale manifest.
34
+ const COMPILED_PAIRS = [
35
+ { source: path.join(ROOT, 'src/cli-surface.cts'), compiled: SURFACE_PATH },
36
+ {
37
+ source: path.join(ROOT, 'src/command-arg-projection.cts'),
38
+ compiled: path.join(ROOT, 'gsd-core/bin/lib/command-arg-projection.cjs'),
39
+ },
40
+ ];
41
+
42
+ /**
43
+ * Whether the compiled file is exactly what the build would emit for the source: true or false,
44
+ * or null when typescript or the build options cannot be loaded (the caller falls back to mtime).
45
+ */
46
+ function compiledMatchesSource(source, compiled, loadTypescript) {
47
+ try {
48
+ const ts = loadTypescript();
49
+ const config = ts.readConfigFile(path.join(ROOT, 'tsconfig.build.json'), ts.sys.readFile);
50
+ if (config.error !== undefined) return null;
51
+ // parseJsonConfigFileContent resolves `extends`; 18003 (no input files) is about the include globs, not the options.
52
+ const { options, errors } = ts.parseJsonConfigFileContent(config.config, ts.sys, ROOT);
53
+ if (errors.some((e) => e.code !== 18003)) return null;
54
+ const emitted = ts.transpileModule(fs.readFileSync(source, 'utf8'), { compilerOptions: options, fileName: source });
55
+ return emitted.outputText === fs.readFileSync(compiled, 'utf8');
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Why the compiled registry cannot be trusted, or null when it is fresh: a compiled file that
63
+ * is missing, or older than the source beside it, means `npm run build:lib` has not run since
64
+ * the source changed. A source that is not on disk (an installed package) is not checked.
65
+ * Pairs are compared one to one: tsc leaves an unchanged module's output alone, so a source that
66
+ * is newer by mtime only (a checkout, a touch) is judged by content: the source is transpiled
67
+ * with the repo's own typescript and the build options of tsconfig.build.json, and equal to the
68
+ * compiled text means fresh. When typescript cannot be loaded the mtime comparison decides.
69
+ *
70
+ * @param {Array<{source: string, compiled: string}>} [pairs]
71
+ * @param {() => object} [loadTypescript]
72
+ * @returns {string | null}
73
+ */
74
+ function staleCompileProblem(pairs = COMPILED_PAIRS, loadTypescript = () => require('typescript')) {
75
+ for (const { source, compiled } of pairs) {
76
+ const rel = (p) => path.relative(ROOT, p) || p;
77
+ if (!fs.existsSync(compiled)) {
78
+ return `compiled artifact not found at ${rel(compiled)}. Run \`npm run build:lib\` first.`;
79
+ }
80
+ if (!fs.existsSync(source)) continue;
81
+ if (fs.statSync(source).mtimeMs > fs.statSync(compiled).mtimeMs) {
82
+ if (compiledMatchesSource(source, compiled, loadTypescript) === true) continue;
83
+ return (
84
+ `${rel(compiled)} is older than ${rel(source)}. Run \`npm run build:lib\` first ` +
85
+ '(if it emits nothing, delete tsconfig.build.tsbuildinfo and run it again).'
86
+ );
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function loadSurface() {
93
+ const problem = staleCompileProblem();
94
+ if (problem !== null) throw new ExitError(2, `gen-cli-surface-manifest: ${problem}`);
95
+ return require(SURFACE_PATH);
96
+ }
97
+
98
+ /** canonical -> sorted, unique aliases, across every `*_COMMAND_ALIASES` array. */
99
+ function aliasMap() {
100
+ const mod = require(ALIASES_PATH);
101
+ const collected = new Map();
102
+ for (const [key, value] of Object.entries(mod)) {
103
+ if (!key.endsWith('_COMMAND_ALIASES') || !Array.isArray(value)) continue;
104
+ for (const entry of value) {
105
+ if (!entry || typeof entry.canonical !== 'string') continue;
106
+ const set = collected.get(entry.canonical) ?? new Set();
107
+ for (const alias of entry.aliases ?? []) set.add(alias);
108
+ collected.set(entry.canonical, set);
109
+ }
110
+ }
111
+ return new Map([...collected].map(([canonical, set]) => [canonical, [...set].sort()]));
112
+ }
113
+
114
+ const sorted = (list) => [...(list ?? [])].sort();
115
+
116
+ function flagKinds(spec) {
117
+ const repeatable = new Set(spec.repeatableFlags ?? []);
118
+ const bare = new Set(spec.bareEmptyFlags ?? []);
119
+ const kinds = {};
120
+ for (const f of spec.valueFlags ?? []) {
121
+ kinds[f] = repeatable.has(f) ? 'repeatable' : bare.has(f) ? 'value-or-bare' : 'value';
122
+ }
123
+ for (const f of spec.booleanFlags ?? []) kinds[f] = 'boolean';
124
+ for (const f of spec.optionalValueFlags ?? []) kinds[f] = 'optional-value';
125
+ for (const f of spec.listFlags ?? []) kinds[f] = 'list';
126
+ for (const f of spec.multiwordFlags ?? []) kinds[f] = 'multiword';
127
+ return sortedObject(kinds);
128
+ }
129
+
130
+ function sortedObject(obj) {
131
+ const out = {};
132
+ for (const k of Object.keys(obj).sort()) out[k] = obj[k];
133
+ return out;
134
+ }
135
+
136
+ function buildVerbEntry(verb, spec, aliases) {
137
+ const entry = {
138
+ verb,
139
+ aliases: sorted(aliases.get(verb)),
140
+ positionals: spec.positionals,
141
+ flags: flagKinds(spec),
142
+ required: sorted(spec.requiredFlags),
143
+ workstreamScoped: !!spec.workstreamScoped,
144
+ };
145
+ if (spec.freePositionals) entry.freePositionals = true;
146
+ if (spec.rejectShortFlags) entry.rejectShortFlags = true;
147
+ if ((spec.eqFlags ?? []).length > 0) entry.eq = sorted(spec.eqFlags);
148
+ if ((spec.nonEmptyFlags ?? []).length > 0) entry.nonEmpty = sorted(spec.nonEmptyFlags);
149
+ if ((spec.emptyValueFlags ?? []).length > 0) entry.emptyValue = sorted(spec.emptyValueFlags);
150
+ if ((spec.emptyEqFlags ?? []).length > 0) entry.emptyEq = sorted(spec.emptyEqFlags);
151
+ if (spec.passthroughAfter) entry.passthroughAfter = spec.passthroughAfter;
152
+ if (spec.thirdParty) entry.thirdParty = true;
153
+ return entry;
154
+ }
155
+
156
+ function buildManifest() {
157
+ const { CLI_SURFACE, GLOBAL_FLAGS, UNDECLARED_VERBS } = loadSurface();
158
+ const aliases = aliasMap();
159
+ const flags = {};
160
+ for (const f of GLOBAL_FLAGS.valueFlags ?? []) flags[f] = 'value';
161
+ for (const f of GLOBAL_FLAGS.booleanFlags ?? []) flags[f] = 'boolean';
162
+ const verbs = Object.keys(CLI_SURFACE)
163
+ .sort()
164
+ .map((verb) => buildVerbEntry(verb, CLI_SURFACE[verb], aliases));
165
+ return {
166
+ schema: 1,
167
+ global: {
168
+ flags: sortedObject(flags),
169
+ eq: sorted(GLOBAL_FLAGS.eqFlags),
170
+ help: [...(GLOBAL_FLAGS.helpTokens ?? [])],
171
+ rejected: [...(GLOBAL_FLAGS.rejectedTokens ?? [])],
172
+ },
173
+ verbs,
174
+ undeclared: sorted(UNDECLARED_VERBS),
175
+ };
176
+ }
177
+
178
+ function serialize(manifest) {
179
+ return JSON.stringify(manifest, null, 2) + '\n';
180
+ }
181
+
182
+ function diffVerbs(expected, actual) {
183
+ const exp = new Map(expected.verbs.map((v) => [v.verb, JSON.stringify(v)]));
184
+ const act = new Map((actual.verbs ?? []).map((v) => [v.verb, JSON.stringify(v)]));
185
+ const added = [...exp.keys()].filter((k) => !act.has(k));
186
+ const removed = [...act.keys()].filter((k) => !exp.has(k));
187
+ const changed = [...exp.keys()].filter((k) => act.has(k) && act.get(k) !== exp.get(k));
188
+ return { added, removed, changed };
189
+ }
190
+
191
+ /**
192
+ * @param {{manifestPath?: string}} [opts]
193
+ * @returns {{ok: boolean, problems: Array<{kind: 'missing'|'stale'|'invalid-json', subject: string, message: string}>}}
194
+ */
195
+ function checkReport({ manifestPath = MANIFEST_PATH } = {}) {
196
+ const problems = [];
197
+ if (!fs.existsSync(manifestPath)) {
198
+ problems.push({
199
+ kind: 'missing',
200
+ subject: manifestPath,
201
+ message: `${manifestPath} not found. Run: node scripts/gen-cli-surface-manifest.cjs --write`,
202
+ });
203
+ return { ok: false, problems };
204
+ }
205
+ const text = fs.readFileSync(manifestPath, 'utf8');
206
+ let actual;
207
+ try {
208
+ actual = JSON.parse(text);
209
+ } catch (err) {
210
+ problems.push({
211
+ kind: 'invalid-json',
212
+ subject: manifestPath,
213
+ message: `${manifestPath} is not valid JSON (${err.message}). Run: node scripts/gen-cli-surface-manifest.cjs --write`,
214
+ });
215
+ return { ok: false, problems };
216
+ }
217
+ const expected = buildManifest();
218
+ if (serialize(expected) !== text) {
219
+ const { added, removed, changed } = diffVerbs(expected, actual);
220
+ const parts = [];
221
+ if (added.length) parts.push(`missing verbs: ${added.join(', ')}`);
222
+ if (removed.length) parts.push(`removed verbs: ${removed.join(', ')}`);
223
+ if (changed.length) parts.push(`changed verbs: ${changed.join(', ')}`);
224
+ if (!parts.length) parts.push('global/undeclared sections or formatting differ');
225
+ const subject = [...added, ...removed, ...changed][0] ?? 'global';
226
+ problems.push({
227
+ kind: 'stale',
228
+ subject,
229
+ message: `${manifestPath} is stale (${parts.join('; ')}). Run: node scripts/gen-cli-surface-manifest.cjs --write`,
230
+ });
231
+ }
232
+ return { ok: problems.length === 0, problems };
233
+ }
234
+
235
+ function parseArgs(argv) {
236
+ const opts = { write: false, check: false, json: false, manifestPath: MANIFEST_PATH };
237
+ for (let i = 0; i < argv.length; i++) {
238
+ const arg = argv[i];
239
+ if (arg === '--write') opts.write = true;
240
+ else if (arg === '--check') opts.check = true;
241
+ else if (arg === '--json') opts.json = true;
242
+ else if (arg === '--manifest-path') {
243
+ const value = argv[i + 1];
244
+ if (value === undefined) throw new ExitError(2, `--manifest-path requires a path argument.\n${USAGE}`);
245
+ opts.manifestPath = path.resolve(value);
246
+ i++;
247
+ } else {
248
+ throw new ExitError(2, `unknown argument: ${arg}\n${USAGE}`);
249
+ }
250
+ }
251
+ if (opts.write && opts.check) throw new ExitError(2, `--write and --check are mutually exclusive.\n${USAGE}`);
252
+ return opts;
253
+ }
254
+
255
+ function main() {
256
+ const opts = parseArgs(process.argv.slice(2));
257
+
258
+ if (opts.write) {
259
+ const manifest = buildManifest();
260
+ fs.mkdirSync(path.dirname(opts.manifestPath), { recursive: true });
261
+ const tmp = `${opts.manifestPath}.${process.pid}.tmp`;
262
+ fs.writeFileSync(tmp, serialize(manifest), 'utf8');
263
+ fs.renameSync(tmp, opts.manifestPath);
264
+ process.stdout.write(`Wrote ${opts.manifestPath} - ${manifest.verbs.length} verb(s).\n`);
265
+ return 0;
266
+ }
267
+
268
+ if (opts.check) {
269
+ const report = checkReport({ manifestPath: opts.manifestPath });
270
+ if (opts.json) {
271
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
272
+ } else if (report.ok) {
273
+ process.stdout.write(`${opts.manifestPath} is up to date.\n`);
274
+ } else {
275
+ for (const p of report.problems) process.stderr.write(`${p.kind}: ${p.subject}: ${p.message}\n`);
276
+ }
277
+ if (!report.ok) throw new ExitError(1);
278
+ return 0;
279
+ }
280
+
281
+ process.stdout.write(serialize(buildManifest()));
282
+ return 0;
283
+ }
284
+
285
+ if (require.main === module) runMain(main);
286
+
287
+ module.exports = { buildManifest, checkReport, serialize, staleCompileProblem, aliasMap, MANIFEST_PATH };