@specship/specship-win32-arm64 0.25.0 → 0.28.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 (274) hide show
  1. package/bin/specship.cmd +31 -1
  2. package/lib/dist/activation/cheatsheet.d.ts +17 -1
  3. package/lib/dist/activation/cheatsheet.d.ts.map +1 -1
  4. package/lib/dist/activation/cheatsheet.js +47 -3
  5. package/lib/dist/activation/cheatsheet.js.map +1 -1
  6. package/lib/dist/activation/hook-output.d.ts +80 -0
  7. package/lib/dist/activation/hook-output.d.ts.map +1 -0
  8. package/lib/dist/activation/hook-output.js +103 -0
  9. package/lib/dist/activation/hook-output.js.map +1 -0
  10. package/lib/dist/activation/search-intercept.d.ts +60 -29
  11. package/lib/dist/activation/search-intercept.d.ts.map +1 -1
  12. package/lib/dist/activation/search-intercept.js +159 -55
  13. package/lib/dist/activation/search-intercept.js.map +1 -1
  14. package/lib/dist/activation/steering.d.ts +8 -10
  15. package/lib/dist/activation/steering.d.ts.map +1 -1
  16. package/lib/dist/activation/steering.js +21 -6
  17. package/lib/dist/activation/steering.js.map +1 -1
  18. package/lib/dist/behaviour/behaviour-surface.d.ts +47 -0
  19. package/lib/dist/behaviour/behaviour-surface.d.ts.map +1 -1
  20. package/lib/dist/behaviour/behaviour-surface.js +108 -2
  21. package/lib/dist/behaviour/behaviour-surface.js.map +1 -1
  22. package/lib/dist/bin/node-version-check.d.ts +22 -7
  23. package/lib/dist/bin/node-version-check.d.ts.map +1 -1
  24. package/lib/dist/bin/node-version-check.js +39 -9
  25. package/lib/dist/bin/node-version-check.js.map +1 -1
  26. package/lib/dist/bin/specship.js +762 -81
  27. package/lib/dist/bin/specship.js.map +1 -1
  28. package/lib/dist/commands/specship/learn.md +22 -0
  29. package/lib/dist/commands/specship/spec.md +12 -0
  30. package/lib/dist/db/sqlite-adapter.d.ts +7 -5
  31. package/lib/dist/db/sqlite-adapter.d.ts.map +1 -1
  32. package/lib/dist/db/sqlite-adapter.js +11 -9
  33. package/lib/dist/db/sqlite-adapter.js.map +1 -1
  34. package/lib/dist/enforce/changed-files.d.ts +11 -0
  35. package/lib/dist/enforce/changed-files.d.ts.map +1 -1
  36. package/lib/dist/enforce/changed-files.js +42 -0
  37. package/lib/dist/enforce/changed-files.js.map +1 -1
  38. package/lib/dist/enforce/enforce.d.ts +25 -1
  39. package/lib/dist/enforce/enforce.d.ts.map +1 -1
  40. package/lib/dist/enforce/enforce.js +58 -3
  41. package/lib/dist/enforce/enforce.js.map +1 -1
  42. package/lib/dist/enforce/mutation.d.ts +46 -0
  43. package/lib/dist/enforce/mutation.d.ts.map +1 -0
  44. package/lib/dist/enforce/mutation.js +104 -0
  45. package/lib/dist/enforce/mutation.js.map +1 -0
  46. package/lib/dist/enforce/suppressions.d.ts +33 -0
  47. package/lib/dist/enforce/suppressions.d.ts.map +1 -0
  48. package/lib/dist/enforce/suppressions.js +159 -0
  49. package/lib/dist/enforce/suppressions.js.map +1 -0
  50. package/lib/dist/extraction/specs/markdown-spec-extractor.d.ts.map +1 -1
  51. package/lib/dist/extraction/specs/markdown-spec-extractor.js +16 -2
  52. package/lib/dist/extraction/specs/markdown-spec-extractor.js.map +1 -1
  53. package/lib/dist/graph/spec-coverage.d.ts +57 -1
  54. package/lib/dist/graph/spec-coverage.d.ts.map +1 -1
  55. package/lib/dist/graph/spec-coverage.js +129 -5
  56. package/lib/dist/graph/spec-coverage.js.map +1 -1
  57. package/lib/dist/health/smoke-check.js +2 -2
  58. package/lib/dist/health/smoke-check.js.map +1 -1
  59. package/lib/dist/index.d.ts +8 -3
  60. package/lib/dist/index.d.ts.map +1 -1
  61. package/lib/dist/index.js +65 -9
  62. package/lib/dist/index.js.map +1 -1
  63. package/lib/dist/installer/gemini-commands.d.ts +107 -0
  64. package/lib/dist/installer/gemini-commands.d.ts.map +1 -0
  65. package/lib/dist/installer/gemini-commands.js +264 -0
  66. package/lib/dist/installer/gemini-commands.js.map +1 -0
  67. package/lib/dist/installer/index.d.ts +23 -1
  68. package/lib/dist/installer/index.d.ts.map +1 -1
  69. package/lib/dist/installer/index.js +22 -3
  70. package/lib/dist/installer/index.js.map +1 -1
  71. package/lib/dist/installer/install-defaults.d.ts +19 -0
  72. package/lib/dist/installer/install-defaults.d.ts.map +1 -1
  73. package/lib/dist/installer/install-defaults.js +28 -0
  74. package/lib/dist/installer/install-defaults.js.map +1 -1
  75. package/lib/dist/installer/instructions-template.d.ts +22 -0
  76. package/lib/dist/installer/instructions-template.d.ts.map +1 -1
  77. package/lib/dist/installer/instructions-template.js +37 -1
  78. package/lib/dist/installer/instructions-template.js.map +1 -1
  79. package/lib/dist/installer/targets/claude.d.ts +4 -0
  80. package/lib/dist/installer/targets/claude.d.ts.map +1 -1
  81. package/lib/dist/installer/targets/claude.js +8 -25
  82. package/lib/dist/installer/targets/claude.js.map +1 -1
  83. package/lib/dist/installer/targets/gemini.d.ts +85 -12
  84. package/lib/dist/installer/targets/gemini.d.ts.map +1 -1
  85. package/lib/dist/installer/targets/gemini.js +527 -30
  86. package/lib/dist/installer/targets/gemini.js.map +1 -1
  87. package/lib/dist/installer/targets/shared.d.ts +13 -0
  88. package/lib/dist/installer/targets/shared.d.ts.map +1 -1
  89. package/lib/dist/installer/targets/shared.js +18 -0
  90. package/lib/dist/installer/targets/shared.js.map +1 -1
  91. package/lib/dist/installer/targets/types.d.ts +12 -0
  92. package/lib/dist/installer/targets/types.d.ts.map +1 -1
  93. package/lib/dist/jira/auto-publish.d.ts +71 -4
  94. package/lib/dist/jira/auto-publish.d.ts.map +1 -1
  95. package/lib/dist/jira/auto-publish.js +186 -17
  96. package/lib/dist/jira/auto-publish.js.map +1 -1
  97. package/lib/dist/jira/reconcile.d.ts +24 -1
  98. package/lib/dist/jira/reconcile.d.ts.map +1 -1
  99. package/lib/dist/jira/reconcile.js +46 -2
  100. package/lib/dist/jira/reconcile.js.map +1 -1
  101. package/lib/dist/jira/repo-config.d.ts +9 -0
  102. package/lib/dist/jira/repo-config.d.ts.map +1 -1
  103. package/lib/dist/jira/repo-config.js +11 -0
  104. package/lib/dist/jira/repo-config.js.map +1 -1
  105. package/lib/dist/jira/types.d.ts +7 -0
  106. package/lib/dist/jira/types.d.ts.map +1 -1
  107. package/lib/dist/jira/types.js.map +1 -1
  108. package/lib/dist/knowledge/enforce.d.ts +75 -0
  109. package/lib/dist/knowledge/enforce.d.ts.map +1 -0
  110. package/lib/dist/knowledge/enforce.js +250 -0
  111. package/lib/dist/knowledge/enforce.js.map +1 -0
  112. package/lib/dist/knowledge/facts.d.ts +33 -0
  113. package/lib/dist/knowledge/facts.d.ts.map +1 -0
  114. package/lib/dist/knowledge/facts.js +117 -0
  115. package/lib/dist/knowledge/facts.js.map +1 -0
  116. package/lib/dist/knowledge/proposals.d.ts +88 -0
  117. package/lib/dist/knowledge/proposals.d.ts.map +1 -0
  118. package/lib/dist/knowledge/proposals.js +360 -0
  119. package/lib/dist/knowledge/proposals.js.map +1 -0
  120. package/lib/dist/knowledge/stale.d.ts +64 -0
  121. package/lib/dist/knowledge/stale.d.ts.map +1 -0
  122. package/lib/dist/knowledge/stale.js +174 -0
  123. package/lib/dist/knowledge/stale.js.map +1 -0
  124. package/lib/dist/mcp/gemini-session.d.ts +50 -0
  125. package/lib/dist/mcp/gemini-session.d.ts.map +1 -0
  126. package/lib/dist/mcp/gemini-session.js +159 -0
  127. package/lib/dist/mcp/gemini-session.js.map +1 -0
  128. package/lib/dist/mcp/jira-tools.d.ts.map +1 -1
  129. package/lib/dist/mcp/jira-tools.js +25 -0
  130. package/lib/dist/mcp/jira-tools.js.map +1 -1
  131. package/lib/dist/mcp/model-context.d.ts +22 -9
  132. package/lib/dist/mcp/model-context.d.ts.map +1 -1
  133. package/lib/dist/mcp/model-context.js +35 -13
  134. package/lib/dist/mcp/model-context.js.map +1 -1
  135. package/lib/dist/mcp/proxy.js +1 -1
  136. package/lib/dist/mcp/proxy.js.map +1 -1
  137. package/lib/dist/mcp/server-instructions.d.ts +16 -15
  138. package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
  139. package/lib/dist/mcp/server-instructions.js +67 -1
  140. package/lib/dist/mcp/server-instructions.js.map +1 -1
  141. package/lib/dist/mcp/session.js +1 -1
  142. package/lib/dist/mcp/session.js.map +1 -1
  143. package/lib/dist/mcp/spec-tools.d.ts +9 -1
  144. package/lib/dist/mcp/spec-tools.d.ts.map +1 -1
  145. package/lib/dist/mcp/spec-tools.js +69 -21
  146. package/lib/dist/mcp/spec-tools.js.map +1 -1
  147. package/lib/dist/mcp/tools.d.ts +37 -1
  148. package/lib/dist/mcp/tools.d.ts.map +1 -1
  149. package/lib/dist/mcp/tools.js +120 -9
  150. package/lib/dist/mcp/tools.js.map +1 -1
  151. package/lib/dist/pulse/pulse.d.ts +93 -0
  152. package/lib/dist/pulse/pulse.d.ts.map +1 -0
  153. package/lib/dist/pulse/pulse.js +401 -0
  154. package/lib/dist/pulse/pulse.js.map +1 -0
  155. package/lib/dist/reflect/apply.d.ts.map +1 -1
  156. package/lib/dist/reflect/apply.js +38 -3
  157. package/lib/dist/reflect/apply.js.map +1 -1
  158. package/lib/dist/reflect/auto-sweep.d.ts +26 -0
  159. package/lib/dist/reflect/auto-sweep.d.ts.map +1 -0
  160. package/lib/dist/reflect/auto-sweep.js +90 -0
  161. package/lib/dist/reflect/auto-sweep.js.map +1 -0
  162. package/lib/dist/reflect/cli.d.ts +62 -0
  163. package/lib/dist/reflect/cli.d.ts.map +1 -0
  164. package/lib/dist/reflect/cli.js +163 -0
  165. package/lib/dist/reflect/cli.js.map +1 -0
  166. package/lib/dist/reflect/miner.d.ts.map +1 -1
  167. package/lib/dist/reflect/miner.js +119 -0
  168. package/lib/dist/reflect/miner.js.map +1 -1
  169. package/lib/dist/reflect/sweep.d.ts +1 -0
  170. package/lib/dist/reflect/sweep.d.ts.map +1 -1
  171. package/lib/dist/reflect/sweep.js +62 -13
  172. package/lib/dist/reflect/sweep.js.map +1 -1
  173. package/lib/dist/reflect/targets.d.ts +2 -0
  174. package/lib/dist/reflect/targets.d.ts.map +1 -1
  175. package/lib/dist/reflect/targets.js +33 -0
  176. package/lib/dist/reflect/targets.js.map +1 -1
  177. package/lib/dist/reflect/types.d.ts +19 -2
  178. package/lib/dist/reflect/types.d.ts.map +1 -1
  179. package/lib/dist/resolution/test-title-links.d.ts.map +1 -1
  180. package/lib/dist/resolution/test-title-links.js +3 -14
  181. package/lib/dist/resolution/test-title-links.js.map +1 -1
  182. package/lib/dist/server/routes/claude.js +15 -7
  183. package/lib/dist/server/routes/knowledge.js +118 -0
  184. package/lib/dist/server/server.js +2 -0
  185. package/lib/dist/spec-lint/lint.d.ts +1 -1
  186. package/lib/dist/spec-lint/lint.d.ts.map +1 -1
  187. package/lib/dist/spec-lint/lint.js +63 -2
  188. package/lib/dist/spec-lint/lint.js.map +1 -1
  189. package/lib/dist/spec-quality/criterion-ids.d.ts +13 -0
  190. package/lib/dist/spec-quality/criterion-ids.d.ts.map +1 -0
  191. package/lib/dist/spec-quality/criterion-ids.js +38 -0
  192. package/lib/dist/spec-quality/criterion-ids.js.map +1 -0
  193. package/lib/dist/spec-quality/evidence-kind.d.ts +54 -0
  194. package/lib/dist/spec-quality/evidence-kind.d.ts.map +1 -0
  195. package/lib/dist/spec-quality/evidence-kind.js +183 -0
  196. package/lib/dist/spec-quality/evidence-kind.js.map +1 -0
  197. package/lib/dist/spec-quality/link-persist.d.ts +65 -0
  198. package/lib/dist/spec-quality/link-persist.d.ts.map +1 -0
  199. package/lib/dist/spec-quality/link-persist.js +249 -0
  200. package/lib/dist/spec-quality/link-persist.js.map +1 -0
  201. package/lib/dist/spec-quality/plan-scope.d.ts +38 -0
  202. package/lib/dist/spec-quality/plan-scope.d.ts.map +1 -0
  203. package/lib/dist/spec-quality/plan-scope.js +129 -0
  204. package/lib/dist/spec-quality/plan-scope.js.map +1 -0
  205. package/lib/dist/spec-quality/postcheck.d.ts +85 -0
  206. package/lib/dist/spec-quality/postcheck.d.ts.map +1 -0
  207. package/lib/dist/spec-quality/postcheck.js +390 -0
  208. package/lib/dist/spec-quality/postcheck.js.map +1 -0
  209. package/lib/dist/spec-quality/readiness.d.ts +72 -0
  210. package/lib/dist/spec-quality/readiness.d.ts.map +1 -0
  211. package/lib/dist/spec-quality/readiness.js +226 -0
  212. package/lib/dist/spec-quality/readiness.js.map +1 -0
  213. package/lib/dist/workflows/agents/claude.d.ts +16 -0
  214. package/lib/dist/workflows/agents/claude.d.ts.map +1 -0
  215. package/lib/dist/workflows/agents/claude.js +82 -0
  216. package/lib/dist/workflows/agents/claude.js.map +1 -0
  217. package/lib/dist/workflows/agents/gemini.d.ts +21 -0
  218. package/lib/dist/workflows/agents/gemini.d.ts.map +1 -0
  219. package/lib/dist/workflows/agents/gemini.js +124 -0
  220. package/lib/dist/workflows/agents/gemini.js.map +1 -0
  221. package/lib/dist/workflows/agents/index.d.ts +28 -0
  222. package/lib/dist/workflows/agents/index.d.ts.map +1 -0
  223. package/lib/dist/workflows/agents/index.js +104 -0
  224. package/lib/dist/workflows/agents/index.js.map +1 -0
  225. package/lib/dist/workflows/agents/types.d.ts +55 -0
  226. package/lib/dist/workflows/agents/types.d.ts.map +1 -0
  227. package/lib/dist/workflows/agents/types.js +46 -0
  228. package/lib/dist/workflows/agents/types.js.map +1 -0
  229. package/lib/dist/workflows/batch.d.ts +63 -0
  230. package/lib/dist/workflows/batch.d.ts.map +1 -0
  231. package/lib/dist/workflows/batch.js +151 -0
  232. package/lib/dist/workflows/batch.js.map +1 -0
  233. package/lib/dist/workflows/defaults/claude-design-implement.yaml +3 -4
  234. package/lib/dist/workflows/defaults/spec-author.yaml +11 -9
  235. package/lib/dist/workflows/defaults/spec-implement-mixed.yaml +21 -436
  236. package/lib/dist/workflows/defaults/spec-implement.yaml +465 -46
  237. package/lib/dist/workflows/discovery.d.ts +13 -0
  238. package/lib/dist/workflows/discovery.d.ts.map +1 -1
  239. package/lib/dist/workflows/discovery.js +79 -17
  240. package/lib/dist/workflows/discovery.js.map +1 -1
  241. package/lib/dist/workflows/executor.d.ts +18 -0
  242. package/lib/dist/workflows/executor.d.ts.map +1 -1
  243. package/lib/dist/workflows/executor.js +130 -33
  244. package/lib/dist/workflows/executor.js.map +1 -1
  245. package/lib/dist/workflows/gc.d.ts +50 -0
  246. package/lib/dist/workflows/gc.d.ts.map +1 -0
  247. package/lib/dist/workflows/gc.js +120 -0
  248. package/lib/dist/workflows/gc.js.map +1 -0
  249. package/lib/dist/workflows/merge-queue.d.ts +64 -0
  250. package/lib/dist/workflows/merge-queue.d.ts.map +1 -0
  251. package/lib/dist/workflows/merge-queue.js +162 -0
  252. package/lib/dist/workflows/merge-queue.js.map +1 -0
  253. package/lib/dist/workflows/runners/approval.d.ts +1 -1
  254. package/lib/dist/workflows/runners/approval.d.ts.map +1 -1
  255. package/lib/dist/workflows/runners/approval.js +11 -2
  256. package/lib/dist/workflows/runners/approval.js.map +1 -1
  257. package/lib/dist/workflows/runners/bash.d.ts +15 -0
  258. package/lib/dist/workflows/runners/bash.d.ts.map +1 -1
  259. package/lib/dist/workflows/runners/bash.js +50 -1
  260. package/lib/dist/workflows/runners/bash.js.map +1 -1
  261. package/lib/dist/workflows/runners/prompt.d.ts +16 -41
  262. package/lib/dist/workflows/runners/prompt.d.ts.map +1 -1
  263. package/lib/dist/workflows/runners/prompt.js +55 -120
  264. package/lib/dist/workflows/runners/prompt.js.map +1 -1
  265. package/lib/dist/workflows/runners/types.d.ts +12 -0
  266. package/lib/dist/workflows/runners/types.d.ts.map +1 -1
  267. package/lib/dist/workflows/schemas/workflow.d.ts +17 -0
  268. package/lib/dist/workflows/schemas/workflow.d.ts.map +1 -1
  269. package/lib/dist/workflows/schemas/workflow.js +27 -0
  270. package/lib/dist/workflows/schemas/workflow.js.map +1 -1
  271. package/lib/node_modules/.package-lock.json +1 -1
  272. package/lib/package.json +2 -2
  273. package/package.json +5 -3
  274. package/node.exe +0 -4
@@ -268,9 +268,10 @@ if (nodeMajor >= 25) {
268
268
  // Enforce the supported Node floor. `engines` in package.json only *warns* on
269
269
  // install (unless engine-strict), so hard-block here to actually keep users off
270
270
  // unsupported versions. Mirrors the 25+ block above. See package.json `engines`.
271
- if (nodeMajor < node_version_check_1.MIN_NODE_MAJOR) {
271
+ const nodeFloor = (0, node_version_check_1.nodeFloorDecision)(nodeVersion);
272
+ if (nodeFloor !== 'ok') {
272
273
  process.stderr.write((0, node_version_check_1.buildNodeTooOldBanner)(nodeVersion) + '\n');
273
- if (!process.env.SPECSHIP_ALLOW_UNSAFE_NODE) {
274
+ if (nodeFloor === 'block') {
274
275
  process.exit(1);
275
276
  }
276
277
  // Override active — banner shown for visibility, continuing.
@@ -862,6 +863,45 @@ function main() {
862
863
  catch {
863
864
  /* surfacing is best-effort */
864
865
  }
866
+ // Stale external facts (REQ-KNOW-002): one line when any dependency
867
+ // moved past the version a fact was verified against. Local
868
+ // node_modules only on this path — no `npm root -g` spawn at session start.
869
+ try {
870
+ const { findStaleExternalFacts } = require('../knowledge/stale');
871
+ const stale = findStaleExternalFacts(projectPath, { globalRoot: () => null }).stale;
872
+ if (stale.length > 0) {
873
+ console.log(`⚠ ${stale.length} external fact(s) stale (dependency changed since verified) — review with: specship knowledge stale`);
874
+ }
875
+ }
876
+ catch {
877
+ /* surfacing is best-effort */
878
+ }
879
+ // Pipeline pulse (REQ-PIPETRUST-007): one line per failing item from
880
+ // the CACHED pulse only — no network on this path; silent when healthy.
881
+ try {
882
+ const { readCachedPulse, formatPulseLines } = require('../pulse/pulse');
883
+ const cached = readCachedPulse(projectPath);
884
+ if (cached) {
885
+ for (const line of formatPulseLines(cached.items))
886
+ console.log(line);
887
+ }
888
+ // Keep the cache warm without blocking the session: when it is missing
889
+ // or older than six hours, refresh it in a detached child (bounded by
890
+ // pulse's own time budget). This session prints what was cached; the
891
+ // next one sees the refreshed result.
892
+ const SIX_HOURS = 6 * 60 * 60 * 1000;
893
+ if ((!cached || Date.now() - cached.checkedAt > SIX_HOURS) && !process.env.SPECSHIP_NO_PULSE) {
894
+ const { spawn } = require('child_process');
895
+ const child = spawn(process.execPath, [process.argv[1], 'pulse', projectPath, '--refresh', '--json'], {
896
+ detached: true,
897
+ stdio: 'ignore',
898
+ });
899
+ child.unref();
900
+ }
901
+ }
902
+ catch {
903
+ /* surfacing is best-effort */
904
+ }
865
905
  };
866
906
  try {
867
907
  if (!(0, directory_1.isInitialized)(projectPath)) {
@@ -893,12 +933,32 @@ function main() {
893
933
  /* summary is best-effort */
894
934
  }
895
935
  };
936
+ // Guarded auto-learn sweep (REQ-ALEARN-004): cooldown-bounded, opt-out
937
+ // via reflect.autoSweep=false, and NEVER allowed to fail the sync (A3).
938
+ const runAutoSweep = async (instance) => {
939
+ try {
940
+ const { loadReflectConfig, shouldAutoSweep, recordAutoSweep } = await Promise.resolve().then(() => __importStar(require('../reflect/auto-sweep')));
941
+ const cfg = loadReflectConfig(projectPath);
942
+ if (!shouldAutoSweep(projectPath, Date.now(), cfg))
943
+ return;
944
+ recordAutoSweep(projectPath, Date.now());
945
+ const { notify } = instance.reflectSweep();
946
+ const { learnedNotice } = await Promise.resolve().then(() => __importStar(require('../reflect/cli')));
947
+ for (const p of notify) {
948
+ console.log(learnedNotice(p.title));
949
+ }
950
+ }
951
+ catch {
952
+ /* a sweep failure never blocks a sync (REQ-ALEARN-004.A3) */
953
+ }
954
+ };
896
955
  if (options.quiet) {
897
956
  const result = await cg.sync();
898
957
  pushDriftNotices(result);
899
958
  await pushJiraDriftComments(cg, result);
900
959
  await pushJiraAutoPublish(result);
901
960
  pushDriftSummary(cg);
961
+ await runAutoSweep(cg);
902
962
  cg.destroy();
903
963
  return;
904
964
  }
@@ -941,6 +1001,7 @@ function main() {
941
1001
  await pushJiraDriftComments(cg, result);
942
1002
  await pushJiraAutoPublish(result);
943
1003
  pushDriftSummary(cg);
1004
+ await runAutoSweep(cg);
944
1005
  clack.outro('Done');
945
1006
  cg.destroy();
946
1007
  }
@@ -1358,15 +1419,30 @@ function main() {
1358
1419
  * Run a reflection pass over the ingested Claude Code transcripts and print the
1359
1420
  * self-improvement proposals it surfaces (REQ-REFLECT-006.A1). Headless — no
1360
1421
  * dashboard required. With no usable transcript history, prints an empty state.
1422
+ *
1423
+ * Review verbs (REQ-AUDFIX-002): `specship reflect apply <hash> [--yes]`,
1424
+ * `specship reflect undo <hash>`, `specship reflect dismiss <hash>`. The first
1425
+ * positional is a verb when it is exactly apply/undo/dismiss, otherwise the
1426
+ * project path (so `specship reflect` and `specship reflect <path>` are
1427
+ * unchanged); verb mode takes its project from --path (default: cwd).
1361
1428
  */
1362
1429
  program
1363
- .command('reflect [path]')
1364
- .description('Mine ingested transcripts for self-improvement proposals (memory rules, skills, hooks)')
1430
+ .command('reflect [path-or-verb] [hash]')
1431
+ .description('Mine ingested transcripts for self-improvement proposals; review them with apply <hash> [--yes] / undo <hash> / dismiss <hash>')
1365
1432
  .option('-j, --json', 'Output as JSON')
1433
+ .option('-p, --path <path>', 'Project path (for apply/undo/dismiss; default: cwd)')
1434
+ .option('-y, --yes', 'With apply: write the change (without it, apply only previews)')
1366
1435
  .option('--capture', 'Capture a routine as a skill proposal: --title required, content on stdin (LEARN-DOC)')
1367
1436
  .option('--title <title>', 'Title for --capture')
1368
- .action(async (pathArg, options) => {
1369
- const projectPath = resolveProjectPath(pathArg);
1437
+ .option('--lane <lane>', 'Capture lane: skill (default), style (project convention → .specship/conventions.md), or domain (draft fact → specs/domain/) (ALEARN-DOC)')
1438
+ .action(async (firstArg, hashArg, options) => {
1439
+ const { isReflectVerb, runReflectVerb, REFLECT_VERBS, REFLECT_REVIEW_HINT, DISPLAY_HASH_LEN } = await Promise.resolve().then(() => __importStar(require('../reflect/cli')));
1440
+ const verb = isReflectVerb(firstArg) ? firstArg : undefined;
1441
+ if (!verb && hashArg !== undefined) {
1442
+ error(`Unknown reflect action: ${firstArg}. Valid: ${REFLECT_VERBS.join(', ')} (or a project path with no further arguments)`);
1443
+ process.exit(1);
1444
+ }
1445
+ const projectPath = resolveProjectPath(verb ? options.path : (firstArg ?? options.path));
1370
1446
  try {
1371
1447
  if (!(0, directory_1.isInitialized)(projectPath)) {
1372
1448
  error(`SpecShip not initialized in ${projectPath}`);
@@ -1374,6 +1450,17 @@ function main() {
1374
1450
  }
1375
1451
  const { default: SpecShip } = await loadSpecShip();
1376
1452
  const cg = await SpecShip.open(projectPath);
1453
+ // Review verbs (REQ-AUDFIX-002): apply / undo / dismiss by hash prefix.
1454
+ if (verb) {
1455
+ const code = runReflectVerb(cg, verb, hashArg, { yes: options.yes, json: options.json }, {
1456
+ log: (m) => console.log(m),
1457
+ error: (m) => error(m),
1458
+ });
1459
+ cg.destroy();
1460
+ if (code !== 0)
1461
+ process.exit(code);
1462
+ return;
1463
+ }
1377
1464
  // Explicit capture door (LEARN-DOC, REQ-LEARN-002): crystallize a
1378
1465
  // distilled routine into a human-gated skill proposal. Content on
1379
1466
  // stdin so multi-line routines survive shell quoting.
@@ -1390,13 +1477,18 @@ function main() {
1390
1477
  error('--capture expects the routine content on stdin (pipe or heredoc)');
1391
1478
  process.exit(1);
1392
1479
  }
1393
- const p = cg.reflectCapture({ title: options.title, content });
1480
+ const lane = options.lane;
1481
+ if (lane && !['skill', 'style', 'domain'].includes(lane)) {
1482
+ error(`Unknown --lane: ${lane}. Valid: skill, style, domain`);
1483
+ process.exit(1);
1484
+ }
1485
+ const p = cg.reflectCapture({ title: options.title, content, lane });
1394
1486
  if (options.json) {
1395
1487
  console.log(JSON.stringify(p, null, 2));
1396
1488
  }
1397
1489
  else {
1398
1490
  info(`Captured as proposal ${p.contentHash.slice(0, 12)} (${p.state}): ${p.title}`);
1399
- info('Review the open proposals with `specship reflect` — nothing lands until you apply it.');
1491
+ info(`Review the open proposals with \`specship reflect\`; apply this one with \`specship reflect apply ${p.contentHash.slice(0, DISPLAY_HASH_LEN)}\` — nothing lands until you apply it.`);
1400
1492
  }
1401
1493
  cg.destroy();
1402
1494
  return;
@@ -1407,7 +1499,9 @@ function main() {
1407
1499
  cg.destroy();
1408
1500
  return;
1409
1501
  }
1410
- if (result.empty || result.open.length === 0) {
1502
+ // Stored open proposals (incl. explicit captures) list even when this
1503
+ // pass mined nothing new — they are what apply/dismiss act on.
1504
+ if (result.open.length === 0) {
1411
1505
  info('No proposals yet — not enough signal in the ingested transcripts.');
1412
1506
  info('Tip: run the dashboard with `specship desktop --ingest` to build transcript history.');
1413
1507
  cg.destroy();
@@ -1426,13 +1520,14 @@ function main() {
1426
1520
  console.log(chalk.bold(`\nReflection proposals (${result.open.length}):\n`));
1427
1521
  for (const p of result.open) {
1428
1522
  const sev = (sevColor[p.severity] ?? chalk.white)(p.severity.toUpperCase().padEnd(5));
1429
- console.log(`${sev} ${chalk.bold(p.title)}`);
1523
+ console.log(`${sev} ${chalk.bold(p.title)} ${chalk.cyan(p.contentHash.slice(0, DISPLAY_HASH_LEN))}`);
1430
1524
  console.log(chalk.dim(` ${typeLabel[p.type] ?? p.type} → ${p.targetPath}`));
1431
1525
  console.log(chalk.dim(` ${p.evidence.detail}`));
1432
1526
  console.log(chalk.dim(` ${p.body}`));
1433
1527
  console.log();
1434
1528
  }
1435
1529
  info('These are proposals only — nothing is written until you apply one.');
1530
+ info(REFLECT_REVIEW_HINT);
1436
1531
  cg.destroy();
1437
1532
  }
1438
1533
  catch (err) {
@@ -1746,6 +1841,7 @@ function main() {
1746
1841
  .option('-p, --path <path>', 'Project path')
1747
1842
  .option('-j, --json', 'Output as JSON')
1748
1843
  .option('--line', 'Print only the one-line "N of M acceptance criteria …" summary')
1844
+ .option('--require-kinds', 'Exit non-zero when any criterion in scope lacks its required evidence kind')
1749
1845
  .action(async (specArg, options) => {
1750
1846
  const projectPath = resolveProjectPath(options.path);
1751
1847
  try {
@@ -1753,17 +1849,24 @@ function main() {
1753
1849
  error(`SpecShip not initialized in ${projectPath}`);
1754
1850
  process.exit(1);
1755
1851
  }
1756
- const { default: SpecShip, formatCoverageLine } = await loadSpecShip();
1852
+ const { default: SpecShip, formatCoverageLine, requireKindsExitCode, criteriaMissingKind } = await loadSpecShip();
1757
1853
  const cg = await SpecShip.open(projectPath);
1758
1854
  const report = cg.getSpecCoverage(specArg);
1855
+ // REQ-PIPETRUST-003.A2: --require-kinds gates on evidence kind; the
1856
+ // verdicts themselves are unchanged.
1857
+ const kindsExit = requireKindsExitCode(report, !!options.requireKinds);
1759
1858
  if (options.json) {
1760
1859
  console.log(JSON.stringify(report, null, 2));
1761
1860
  cg.destroy();
1861
+ if (kindsExit !== 0)
1862
+ process.exit(kindsExit);
1762
1863
  return;
1763
1864
  }
1764
1865
  if (options.line) {
1765
1866
  console.log(formatCoverageLine(report));
1766
1867
  cg.destroy();
1868
+ if (kindsExit !== 0)
1869
+ process.exit(kindsExit);
1767
1870
  return;
1768
1871
  }
1769
1872
  if (report.requirements.length === 0) {
@@ -1779,15 +1882,29 @@ function main() {
1779
1882
  untested: chalk.dim('·'),
1780
1883
  broken: chalk.red('✗'),
1781
1884
  };
1885
+ // Evidence kind per criterion (REQ-PIPETRUST-003.A1): required vs present,
1886
+ // flagged `needs <kind>` when the tests don't include the required kind.
1887
+ const kindNote = (c) => {
1888
+ const present = c.presentKinds.length > 0 ? c.presentKinds.join(',') : 'none';
1889
+ const base = `kind: requires ${c.requiredKind}, has ${present}`;
1890
+ return c.kindSatisfied ? chalk.dim(base) : `${chalk.dim(base)} ${chalk.yellow(`needs ${c.requiredKind}`)}`;
1891
+ };
1782
1892
  for (const req of report.requirements) {
1783
- console.log(`${mark[req.verdict]} ${chalk.bold(req.specId)} ${chalk.dim(req.verdict)} — ${req.title}`);
1893
+ console.log(`${mark[req.verdict]} ${chalk.bold(req.specId)} ${chalk.dim(req.verdict)} — ${req.title}` +
1894
+ (req.criteria.length === 0 ? ` ${kindNote(req)}` : ''));
1784
1895
  for (const c of req.criteria) {
1785
- console.log(` ${mark[c.verdict]} ${c.specId} ${chalk.dim(`${c.verdict} · implements:${c.implementsLinks.count} tests:${c.testsLinks.count}`)}`);
1896
+ console.log(` ${mark[c.verdict]} ${c.specId} ${chalk.dim(`${c.verdict} · implements:${c.implementsLinks.count} tests:${c.testsLinks.count}`)} · ${kindNote(c)}`);
1786
1897
  }
1787
1898
  }
1788
1899
  console.log();
1789
1900
  console.log(formatCoverageLine(report));
1901
+ const missing = criteriaMissingKind(report);
1902
+ if (missing.length > 0) {
1903
+ console.log(`${missing.length} criteria lack their required evidence kind.`);
1904
+ }
1790
1905
  cg.destroy();
1906
+ if (kindsExit !== 0)
1907
+ process.exit(kindsExit);
1791
1908
  }
1792
1909
  catch (err) {
1793
1910
  error(`coverage failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1822,6 +1939,147 @@ function main() {
1822
1939
  process.exit(1);
1823
1940
  }
1824
1941
  });
1942
+ /**
1943
+ * specship knowledge <action> — the knowledge layer (KNOWLEDGE-DOC).
1944
+ * stale external facts whose dependency version changed (REQ-KNOW-002/006)
1945
+ * check enforced rules → violations (REQ-KNOW-003)
1946
+ * proposals pending proposals drafted by the implement workflow (REQ-KNOW-004)
1947
+ * apply <id> move a confirmed proposal into specs/domain/ (human-only)
1948
+ * reject <id> delete a proposal
1949
+ * File-based: needs no index, so it runs in CI and inside run worktrees.
1950
+ */
1951
+ program
1952
+ .command('knowledge <action> [id]')
1953
+ .description('Knowledge layer: stale | check | proposals | apply <id> | reject <id>')
1954
+ .option('-p, --path <path>', 'Project path (defaults to cwd)')
1955
+ .option('-j, --json', 'Output as JSON')
1956
+ .action(async (action, id, options) => {
1957
+ const projectRoot = options.path ? path.resolve(options.path) : process.cwd();
1958
+ const out = (value, textOut) => console.log(options.json ? JSON.stringify(value, null, 2) : textOut);
1959
+ try {
1960
+ if (action === 'stale') {
1961
+ const { findStaleExternalFacts, formatStaleReport, exitCodeForStale } = await Promise.resolve().then(() => __importStar(require('../knowledge/stale')));
1962
+ const result = findStaleExternalFacts(projectRoot);
1963
+ out(result, formatStaleReport(result));
1964
+ process.exit(exitCodeForStale(result));
1965
+ }
1966
+ else if (action === 'check') {
1967
+ const { checkEnforcedRules, formatEnforceReport, exitCodeForEnforce } = await Promise.resolve().then(() => __importStar(require('../knowledge/enforce')));
1968
+ const result = checkEnforcedRules(projectRoot);
1969
+ out(result, formatEnforceReport(result));
1970
+ process.exit(exitCodeForEnforce(result));
1971
+ }
1972
+ else if (action === 'proposals') {
1973
+ const { listProposals, formatProposalList } = await Promise.resolve().then(() => __importStar(require('../knowledge/proposals')));
1974
+ const list = listProposals(projectRoot);
1975
+ out(list, formatProposalList(list));
1976
+ }
1977
+ else if (action === 'apply' || action === 'reject') {
1978
+ if (!id) {
1979
+ error(`knowledge ${action} needs a proposal id — see \`specship knowledge proposals\``);
1980
+ process.exit(1);
1981
+ }
1982
+ const { applyProposal, rejectProposal, ProposalRefusedError } = await Promise.resolve().then(() => __importStar(require('../knowledge/proposals')));
1983
+ if (action === 'reject') {
1984
+ rejectProposal(projectRoot, id);
1985
+ out({ rejected: id }, `Rejected proposal ${id} — nothing was written to specs/domain/.`);
1986
+ }
1987
+ else {
1988
+ try {
1989
+ const applied = applyProposal(projectRoot, id);
1990
+ out(applied, `Applied ${id} as ${applied.id} → ${applied.path}. Run \`specship sync\` to index it.`);
1991
+ }
1992
+ catch (err) {
1993
+ if (err instanceof ProposalRefusedError) {
1994
+ error(`Refused to apply ${id} — fix the proposal first:`);
1995
+ for (const f of err.findings)
1996
+ console.log(` ${f.severity} ${f.code}: ${f.message}`);
1997
+ process.exit(1);
1998
+ }
1999
+ throw err;
2000
+ }
2001
+ }
2002
+ }
2003
+ else {
2004
+ error(`unknown knowledge action "${action}" — expected stale | check | proposals | apply | reject`);
2005
+ process.exit(1);
2006
+ }
2007
+ }
2008
+ catch (err) {
2009
+ error(`knowledge ${action} failed: ${err instanceof Error ? err.message : String(err)}`);
2010
+ process.exit(1);
2011
+ }
2012
+ });
2013
+ /**
2014
+ * specship plan-scope <SPEC_ID> --plan-file <f> — small-plan auto-approval
2015
+ * check (THROUGHPUT-DOC, REQ-THRU-003). Always exits 0; the AUTO_APPROVE
2016
+ * marker is what the plan gate reads.
2017
+ */
2018
+ program
2019
+ .command('plan-scope <specId>')
2020
+ .description('Decide whether a plan may skip the plan gate (opt-in; prints AUTO_APPROVE=yes|no)')
2021
+ .requiredOption('--plan-file <file>', 'File holding the plan text')
2022
+ .option('-p, --path <path>', 'Project path (defaults to cwd)')
2023
+ .action(async (specId, options) => {
2024
+ const projectRoot = options.path ? path.resolve(options.path) : process.cwd();
2025
+ try {
2026
+ const { assessPlanScopeFromFile, formatPlanScope } = await Promise.resolve().then(() => __importStar(require('../spec-quality/plan-scope')));
2027
+ const planText = fs.readFileSync(path.resolve(options.planFile), 'utf8');
2028
+ console.log(formatPlanScope(assessPlanScopeFromFile(projectRoot, specId, planText)));
2029
+ }
2030
+ catch (err) {
2031
+ console.log(`AUTO_APPROVE=no\n- plan-scope failed: ${err instanceof Error ? err.message : String(err)}`);
2032
+ }
2033
+ });
2034
+ /**
2035
+ * specship ready <SPEC_ID> — readiness gate (PIPETRUST-DOC, REQ-PIPETRUST-004).
2036
+ * File-based (no index needed), so it runs inside a workflow worktree. Exits
2037
+ * non-zero when the requirement is not ready; prints the evidence plan.
2038
+ */
2039
+ program
2040
+ .command('ready <specId>')
2041
+ .description('Check a requirement is ready to implement and print its evidence plan (exits non-zero when not ready)')
2042
+ .option('-p, --path <path>', 'Project path (defaults to cwd)')
2043
+ .option('-j, --json', 'Output as JSON')
2044
+ .action(async (specId, options) => {
2045
+ const projectRoot = options.path ? path.resolve(options.path) : process.cwd();
2046
+ try {
2047
+ const { assessReadinessFromFile, formatEvidencePlan } = await Promise.resolve().then(() => __importStar(require('../spec-quality/readiness')));
2048
+ const result = assessReadinessFromFile(projectRoot, specId);
2049
+ console.log(options.json ? JSON.stringify(result, null, 2) : formatEvidencePlan(result));
2050
+ if (!result.ready)
2051
+ process.exit(1);
2052
+ }
2053
+ catch (err) {
2054
+ error(`ready failed: ${err instanceof Error ? err.message : String(err)}`);
2055
+ process.exit(1);
2056
+ }
2057
+ });
2058
+ /**
2059
+ * specship postcheck <SPEC_ID> [--fix] — mechanical post-implement checks
2060
+ * (PIPETRUST-DOC, REQ-PIPETRUST-005). File-based, worktree-safe.
2061
+ */
2062
+ program
2063
+ .command('postcheck <specId>')
2064
+ .description('Mechanically check an implemented requirement: verifies coverage, CHANGELOG, configured checks (exits non-zero on failure)')
2065
+ .option('-p, --path <path>', 'Project path (defaults to cwd)')
2066
+ .option('--fix', 'Run each configured check\'s fix command first')
2067
+ .option('--base <ref>', 'Base ref for the CHANGELOG check (defaults to the recorded run base, then the merge-base with the default branch)')
2068
+ .option('-j, --json', 'Output as JSON')
2069
+ .action(async (specId, options) => {
2070
+ const projectRoot = options.path ? path.resolve(options.path) : process.cwd();
2071
+ try {
2072
+ const { runPostcheck, formatPostcheck } = await Promise.resolve().then(() => __importStar(require('../spec-quality/postcheck')));
2073
+ const result = runPostcheck({ projectRoot, requirementId: specId, fix: options.fix, base: options.base });
2074
+ console.log(options.json ? JSON.stringify(result, null, 2) : formatPostcheck(result));
2075
+ if (!result.ok)
2076
+ process.exit(1);
2077
+ }
2078
+ catch (err) {
2079
+ error(`postcheck failed: ${err instanceof Error ? err.message : String(err)}`);
2080
+ process.exit(1);
2081
+ }
2082
+ });
1825
2083
  /**
1826
2084
  * specship verify --report <file>
1827
2085
  *
@@ -2021,9 +2279,12 @@ function main() {
2021
2279
  }
2022
2280
  for (const c of r.checks) {
2023
2281
  const tag = c.gating ? chalk.dim('[gating]') : chalk.dim('[advisory]');
2024
- const mark = c.passed ? chalk.green('✓') : (c.gating ? chalk.red('✗') : chalk.yellow('•'));
2282
+ // A skipped check's input was unavailable — presented as skipped,
2283
+ // never as pass or fail (REQ-QGATE-002.A3).
2284
+ const mark = c.skipped ? chalk.yellow('○') : c.passed ? chalk.green('✓') : (c.gating ? chalk.red('✗') : chalk.yellow('•'));
2025
2285
  const reach = scope ? chalk.dim(c.scoped ? ' [since]' : ' [repo-wide]') : '';
2026
- console.log(` ${mark} ${c.check.padEnd(16)} ${tag}${reach} ${c.passed ? 'pass' : `${c.findings.length} finding(s)`}`);
2286
+ const status = c.skipped ? 'skipped' : c.passed ? 'pass' : `${c.findings.length} finding(s)`;
2287
+ console.log(` ${mark} ${c.check.padEnd(16)} ${tag}${reach} ${status}`);
2027
2288
  if (!c.passed)
2028
2289
  for (const f of c.findings.slice(0, 8))
2029
2290
  console.log(chalk.dim(` ${f}`));
@@ -2037,7 +2298,7 @@ function main() {
2037
2298
  : 'Pass (no gating checks failed).');
2038
2299
  // The advisory sell (REQ-ENFORCE-004.A1): findings that would fail
2039
2300
  // a gated run end the report with the exact opt-in command.
2040
- const wouldGate = r.checks.filter((c) => !c.gating && !c.passed).map((c) => c.check);
2301
+ const wouldGate = r.checks.filter((c) => !c.gating && !c.passed && !c.skipped).map((c) => c.check);
2041
2302
  if (wouldGate.length) {
2042
2303
  const total = r.checks
2043
2304
  .filter((c) => wouldGate.includes(c.check))
@@ -2162,6 +2423,39 @@ function main() {
2162
2423
  process.exit(1);
2163
2424
  }
2164
2425
  }
2426
+ program
2427
+ .command('suppressions [path]')
2428
+ .description('Scan the change under review for newly introduced lint/type suppression comments (noqa, eslint-disable, @ts-ignore, …); exits non-zero when any were added')
2429
+ .option('--since <ref>', 'Diff base (default HEAD): scan lines added since this ref')
2430
+ .option('-j, --json', 'Output findings as JSON')
2431
+ .action(async (pathArg, options) => {
2432
+ const projectPath = resolveProjectPath(pathArg);
2433
+ // The SUPPRESSION_GATE marker is the machine-readable contract the
2434
+ // spec-implement workflows route on (REQ-QGATE-004.A3) — same doctrine as
2435
+ // VERIFY_RESULT: a scan that could not run is `skipped`, never `clean`.
2436
+ try {
2437
+ const { workingTreeDiffText } = await Promise.resolve().then(() => __importStar(require('../enforce/changed-files')));
2438
+ const { scanDisableComments, formatSuppressionFinding } = await Promise.resolve().then(() => __importStar(require('../enforce/suppressions')));
2439
+ const findings = scanDisableComments(workingTreeDiffText(projectPath, options.since ?? 'HEAD'));
2440
+ if (options.json) {
2441
+ console.log(JSON.stringify({ gate: findings.length === 0 ? 'clean' : 'failed', findings }, null, 2));
2442
+ process.exit(findings.length === 0 ? 0 : 1);
2443
+ }
2444
+ if (findings.length === 0) {
2445
+ console.log('SUPPRESSION_GATE=clean');
2446
+ return;
2447
+ }
2448
+ console.log('SUPPRESSION_GATE=failed');
2449
+ for (const f of findings)
2450
+ console.log(` ${formatSuppressionFinding(f)}`);
2451
+ console.log('Fix the code instead of silencing the rule (REQ-QGATE-004).');
2452
+ process.exit(1);
2453
+ }
2454
+ catch (err) {
2455
+ console.log('SUPPRESSION_GATE=skipped');
2456
+ console.log(`SUPPRESSION_REASON=${err instanceof Error ? err.message.split('\n')[0] : String(err)}`);
2457
+ }
2458
+ });
2165
2459
  program
2166
2460
  .command('serve')
2167
2461
  .description('Start SpecShip as an MCP server for AI assistants (stdio transport)')
@@ -2663,6 +2957,7 @@ function main() {
2663
2957
  // existing invocations — including our own offline-install scripts — behave
2664
2958
  // exactly as before.
2665
2959
  .option('-t, --target <ids>', 'Agents to wire, comma-separated: "claude" (default) | "gemini" | "claude,gemini". Also selects the snippet `--print-config` prints. Legacy: "auto"/"all" = claude, "none" = skip')
2960
+ .option('--trust', 'Gemini only (REQ-GEMINI-009): write ~/.gemini/policies/specship.toml auto-allowing SpecShip\'s read-only tools. Opt-in — an admin policy still wins and run_shell_command is unaffected')
2666
2961
  .action(async (opts) => {
2667
2962
  if (opts.printConfig) {
2668
2963
  const loc = (opts.location === 'local' ? 'local' : 'global');
@@ -2723,6 +3018,10 @@ function main() {
2723
3018
  withJira: opts.withJira === true ? true : undefined,
2724
3019
  withDesigner: opts.withDesigner === true ? true : undefined,
2725
3020
  statusLine,
3021
+ // REQ-AUDFIX-001: a local install must honor --skip-index too.
3022
+ skipIndex: opts.skipIndex === true,
3023
+ // REQ-GEMINI-009: strictly opt-in, no prompt. Claude target ignores it.
3024
+ trust: opts.trust === true ? true : undefined,
2726
3025
  yes: opts.yes,
2727
3026
  });
2728
3027
  // Index the current project (REQ-HANDSHAKE-004) before the smoke check,
@@ -2899,7 +3198,32 @@ function main() {
2899
3198
  }
2900
3199
  }
2901
3200
  const { buildCheatsheetPayload } = await Promise.resolve().then(() => __importStar(require('../activation/cheatsheet')));
2902
- const payload = buildCheatsheetPayload();
3201
+ // Learned-state extras (REQ-ALEARN-002 / REQ-ALEARN-004.A2) — both
3202
+ // best-effort: an unreadable file or DB silently yields the base
3203
+ // cheatsheet, never an error in the SessionStart hook.
3204
+ const projectPath = resolveProjectPath(undefined);
3205
+ let conventions = null;
3206
+ try {
3207
+ conventions = (await Promise.resolve().then(() => __importStar(require('fs')))).readFileSync((await Promise.resolve().then(() => __importStar(require('path')))).join(projectPath, '.specship', 'conventions.md'), 'utf-8');
3208
+ }
3209
+ catch { /* no conventions file */ }
3210
+ let openProposals = 0;
3211
+ try {
3212
+ const { DatabaseConnection, DATABASE_FILENAME } = await Promise.resolve().then(() => __importStar(require('../db')));
3213
+ const { ReflectStore } = await Promise.resolve().then(() => __importStar(require('../reflect')));
3214
+ const dbPath = (await Promise.resolve().then(() => __importStar(require('path')))).join(projectPath, '.specship', DATABASE_FILENAME);
3215
+ if ((await Promise.resolve().then(() => __importStar(require('fs')))).existsSync(dbPath)) {
3216
+ const conn = DatabaseConnection.initialize(dbPath);
3217
+ try {
3218
+ openProposals = new ReflectStore(conn.getDb()).list('open').length;
3219
+ }
3220
+ finally {
3221
+ conn.close();
3222
+ }
3223
+ }
3224
+ }
3225
+ catch { /* store unavailable → omit the line */ }
3226
+ const payload = buildCheatsheetPayload(process.env, { conventions, openProposals });
2903
3227
  if (payload)
2904
3228
  process.stdout.write(JSON.stringify(payload) + '\n');
2905
3229
  process.exit(0);
@@ -2937,21 +3261,14 @@ function main() {
2937
3261
  }
2938
3262
  catch { /* no stdin — nothing to nudge */ }
2939
3263
  const raw = Buffer.concat(chunks).toString('utf-8').trim();
2940
- let prompt = '';
2941
- if (raw) {
2942
- try {
2943
- const parsed = JSON.parse(raw);
2944
- prompt = typeof parsed?.prompt === 'string' ? parsed.prompt : raw;
2945
- }
2946
- catch {
2947
- prompt = raw; // not JSON — treat as the raw prompt text
2948
- }
2949
- }
2950
- if (shouldNudge(prompt)) {
3264
+ const { parseHookPayload, buildHookAdditionalContext, deriveHostTraits } = await Promise.resolve().then(() => __importStar(require('../activation/hook-output')));
3265
+ const payload = parseHookPayload(raw);
3266
+ const traits = deriveHostTraits(payload);
3267
+ if (shouldNudge(payload.prompt || raw)) {
2951
3268
  const additionalContext = 'This repo uses spec-driven development (SpecShip). Before any brainstorming or ' +
2952
3269
  'planning skill, FIRST invoke spec-author to author the spec under specs/ for this ' +
2953
3270
  'work — the spec is the contract; implement from it with /specship:spec implement.';
2954
- process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext } }) + '\n');
3271
+ process.stdout.write(buildHookAdditionalContext(additionalContext, payload.hookEventName, traits.fallbackPromptEvent));
2955
3272
  }
2956
3273
  process.exit(0);
2957
3274
  });
@@ -2967,11 +3284,9 @@ function main() {
2967
3284
  .command('steer-nudge')
2968
3285
  .description('Internal hook: steer flow/structure questions to specship_explore (UserPromptSubmit)')
2969
3286
  .action(async () => {
2970
- // Prefer the hook payload's cwd (Claude Code sends it on stdin) so the
2971
- // check targets the project the prompt belongs to, not wherever the hook
2972
- // process happened to spawn.
2973
- let cwd = process.cwd();
2974
- let transcriptPath = null;
3287
+ // Prefer the hook payload's cwd (Claude Code / Gemini CLI both send it
3288
+ // on stdin) so the check targets the project the prompt belongs to, not
3289
+ // wherever the hook process happened to spawn.
2975
3290
  const chunks = [];
2976
3291
  try {
2977
3292
  for await (const c of process.stdin)
@@ -2979,81 +3294,100 @@ function main() {
2979
3294
  }
2980
3295
  catch { /* no stdin — fall back to process.cwd() */ }
2981
3296
  const raw = Buffer.concat(chunks).toString('utf-8').trim();
2982
- if (raw) {
2983
- try {
2984
- const parsed = JSON.parse(raw);
2985
- if (typeof parsed?.cwd === 'string' && parsed.cwd.length > 0)
2986
- cwd = parsed.cwd;
2987
- if (typeof parsed?.transcript_path === 'string' && parsed.transcript_path.length > 0) {
2988
- transcriptPath = parsed.transcript_path;
2989
- }
2990
- }
2991
- catch { /* not JSON — keep process.cwd() */ }
2992
- }
3297
+ const { parseHookPayload, buildHookAdditionalContext, deriveHostTraits } = await Promise.resolve().then(() => __importStar(require('../activation/hook-output')));
3298
+ const payload = parseHookPayload(raw);
3299
+ const traits = deriveHostTraits(payload);
3300
+ const cwd = payload.cwd ?? process.cwd();
2993
3301
  // Record the session's model from the transcript tail (MODCTX-DOC,
2994
3302
  // REQ-MODCTX-001.A4) — the primary detection channel, present on every
2995
3303
  // default install. Fires per prompt so mid-session /model switches
2996
3304
  // track; runs even when the steering text is suppressed, but only in
2997
3305
  // initialized projects. Best-effort: never fails the hook.
2998
- if (transcriptPath && fs.existsSync(path.join(cwd, '.specship'))) {
3306
+ if (payload.transcriptPath && fs.existsSync(path.join(cwd, '.specship'))) {
2999
3307
  try {
3000
3308
  const { readModelFromTranscript, recordSessionModel } = await Promise.resolve().then(() => __importStar(require('../mcp/model-context')));
3001
- const model = readModelFromTranscript(transcriptPath);
3309
+ const model = readModelFromTranscript(payload.transcriptPath);
3002
3310
  if (model)
3003
3311
  recordSessionModel(cwd, model);
3004
3312
  }
3005
3313
  catch { /* telemetry must never break the hook */ }
3006
3314
  }
3007
3315
  const { buildSteeringNudge } = await Promise.resolve().then(() => __importStar(require('../activation/steering')));
3008
- const additionalContext = buildSteeringNudge(cwd);
3316
+ const additionalContext = buildSteeringNudge(cwd, process.env, undefined, traits.toolPrefix);
3009
3317
  if (additionalContext) {
3010
- process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext } }) + '\n');
3318
+ process.stdout.write(buildHookAdditionalContext(additionalContext, payload.hookEventName, traits.fallbackPromptEvent));
3011
3319
  }
3012
3320
  process.exit(0);
3013
3321
  });
3014
3322
  /**
3015
- * specship search-intercept (STEER-HOOK-DOC, REQ-STEER-004/005)
3323
+ * specship search-intercept (STEER-HOOK-DOC, REQ-STEER-004/005, REQ-GEMINI-011)
3016
3324
  *
3017
- * Internal PreToolUse hook command on search-shaped tools (Grep/Glob): emit
3018
- * the point-of-use redirect to specship_explore, at most once per session and
3019
- * only when the session hasn't used specship yet. STRICTLY ADVISORY — the
3020
- * output is additionalContext, never a permission decision, and the command
3021
- * exits 0 on every path (including internal errors) so the matched call
3022
- * always proceeds (REQ-STEER-004.A2).
3325
+ * Internal hook on search-shaped tools. For Claude's PreToolUse (Grep/Glob)
3326
+ * and Gemini's BeforeTool (grep_search / glob / run_shell_command), emit the
3327
+ * point-of-use redirect to specship_explore. Host-aware traits are derived
3328
+ * from the payload in `activation/hook-output.ts`; shared code never sniffs
3329
+ * event names. Advisory for Claude; Gemini also supports strict mode
3330
+ * (`SPECSHIP_GEMINI_STRICT=1`) which denies grep_search / glob — never
3331
+ * run_shell_command (REQ-GEMINI-011.A3). Exits 0 on every path.
3023
3332
  */
3024
3333
  program
3025
3334
  .command('search-intercept')
3026
- .description('Internal hook: point-of-use redirect from search tools to specship_explore (PreToolUse)')
3335
+ .description('Internal hook: point-of-use redirect from search tools to specship_explore (PreToolUse / BeforeTool)')
3027
3336
  .action(async () => {
3028
3337
  try {
3029
- let cwd = process.cwd();
3030
- let sessionId = null;
3031
- let transcriptPath = null;
3032
3338
  const chunks = [];
3033
3339
  try {
3034
3340
  for await (const c of process.stdin)
3035
3341
  chunks.push(c);
3036
3342
  }
3037
- catch { /* no stdin — fall back to process.cwd(), untracked session */ }
3038
- const raw = Buffer.concat(chunks).toString('utf-8').trim();
3039
- if (raw) {
3040
- try {
3041
- const parsed = JSON.parse(raw);
3042
- if (typeof parsed?.cwd === 'string' && parsed.cwd.length > 0)
3043
- cwd = parsed.cwd;
3044
- if (typeof parsed?.session_id === 'string' && parsed.session_id.length > 0) {
3045
- sessionId = parsed.session_id;
3046
- }
3047
- if (typeof parsed?.transcript_path === 'string' && parsed.transcript_path.length > 0) {
3048
- transcriptPath = parsed.transcript_path;
3049
- }
3343
+ catch { /* no stdin — fall through with empty payload */ }
3344
+ const raw = Buffer.concat(chunks).toString('utf-8');
3345
+ const { parseHookPayload, deriveHostTraits, buildHookAdditionalContext, buildHookDenyDecision } = await Promise.resolve().then(() => __importStar(require('../activation/hook-output')));
3346
+ const payload = parseHookPayload(raw);
3347
+ const traits = deriveHostTraits(payload);
3348
+ const cwd = payload.cwd ?? process.cwd();
3349
+ let toolName = '';
3350
+ let toolInput = {};
3351
+ try {
3352
+ const parsed = JSON.parse(raw);
3353
+ if (typeof parsed?.tool_name === 'string')
3354
+ toolName = parsed.tool_name;
3355
+ if (parsed?.tool_input && typeof parsed.tool_input === 'object' && !Array.isArray(parsed.tool_input)) {
3356
+ toolInput = parsed.tool_input;
3050
3357
  }
3051
- catch { /* not JSON — keep defaults */ }
3052
3358
  }
3359
+ catch { /* not JSON — strict path stays off, advisory still works via payload */ }
3360
+ const DENY_TOOLS = new Set(traits.denyEligibleTools);
3361
+ const shellCommand = traits.shellTool !== null && toolName === traits.shellTool && typeof toolInput.command === 'string'
3362
+ ? toolInput.command
3363
+ : null;
3364
+ const { resolveSetting } = await Promise.resolve().then(() => __importStar(require('../config/runtime-settings')));
3365
+ const strict = traits.supportsBeforeToolDeny &&
3366
+ resolveSetting('SPECSHIP_GEMINI_STRICT', cwd, process.env, os.homedir()) === '1';
3367
+ const searchPattern = (typeof toolInput.pattern === 'string' && toolInput.pattern) ||
3368
+ (typeof toolInput.query === 'string' && toolInput.query) ||
3369
+ null;
3053
3370
  const { buildSearchIntercept } = await Promise.resolve().then(() => __importStar(require('../activation/search-intercept')));
3054
- const additionalContext = buildSearchIntercept({ cwd, sessionId, transcriptPath });
3055
- if (additionalContext) {
3056
- process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext } }) + '\n');
3371
+ const result = buildSearchIntercept({
3372
+ cwd,
3373
+ sessionId: payload.sessionId,
3374
+ transcriptPath: payload.transcriptPath,
3375
+ specshipToolMarkers: traits.specshipToolMarkers,
3376
+ transcriptFormat: traits.transcriptFormat,
3377
+ strict,
3378
+ denyEligible: DENY_TOOLS.has(toolName),
3379
+ shellCommand,
3380
+ searchPattern,
3381
+ });
3382
+ if (result) {
3383
+ if ('decision' in result) {
3384
+ // Gemini BeforeToolHookOutput reads `decision` at the top level
3385
+ // (isBlockingDecision), not inside hookSpecificOutput.
3386
+ process.stdout.write(buildHookDenyDecision(result.reason));
3387
+ }
3388
+ else {
3389
+ process.stdout.write(buildHookAdditionalContext(result.additionalContext, payload.hookEventName, traits.fallbackToolEvent));
3390
+ }
3057
3391
  }
3058
3392
  }
3059
3393
  catch { /* advisory only — never block the matched call (REQ-STEER-004.A2) */ }
@@ -3155,6 +3489,75 @@ function main() {
3155
3489
  cg.close();
3156
3490
  }
3157
3491
  });
3492
+ // @implements REQ-PIPETRUST-007
3493
+ // Pipeline health: release run, npm lag, last JIRA auto-publish, merged run
3494
+ // worktrees. Network checks are time-bounded and skip offline; results are
3495
+ // cached to .specship/pulse.json for the session-start summary.
3496
+ program
3497
+ .command('pulse [path]')
3498
+ .description('Report pipeline health (release run, npm lag, JIRA auto-publish, merged run worktrees); exits non-zero on any failure.')
3499
+ .option('--refresh', 'ignore the cache and re-run every check')
3500
+ .option('--max-age <minutes>', 'serve the cached result when younger than this (default: always re-run)')
3501
+ .option('--json', 'emit JSON')
3502
+ .action(async (pathArg, options) => {
3503
+ const projectRoot = path.resolve(pathArg ?? process.cwd());
3504
+ const { computePulse } = await Promise.resolve().then(() => __importStar(require('../pulse/pulse')));
3505
+ // Resolve a run worktree back to its full run id for the purge hint
3506
+ // (best-effort; falls back to the short id from the branch name).
3507
+ let runIdForWorktree;
3508
+ let close;
3509
+ if ((0, directory_1.isInitialized)(projectRoot)) {
3510
+ try {
3511
+ const { SpecShip } = await loadSpecShip();
3512
+ const cg = await SpecShip.open(projectRoot);
3513
+ close = () => cg.close();
3514
+ const byPath = new Map();
3515
+ const sq = cg.getSpecQueries();
3516
+ for (const run of sq.getAllWorkflowRuns(500)) {
3517
+ if (!run.isolationEnvId)
3518
+ continue;
3519
+ const env = sq.getIsolationEnvById(run.isolationEnvId);
3520
+ if (env?.workingPath)
3521
+ byPath.set(path.resolve(env.workingPath), run.id);
3522
+ }
3523
+ runIdForWorktree = (p) => byPath.get(path.resolve(p));
3524
+ }
3525
+ catch {
3526
+ /* hint falls back to the short id */
3527
+ }
3528
+ }
3529
+ const maxAgeMin = options.maxAge !== undefined ? Number(options.maxAge) : NaN;
3530
+ let result;
3531
+ try {
3532
+ result = await computePulse({
3533
+ projectRoot,
3534
+ refresh: options.refresh,
3535
+ maxAgeMs: Number.isFinite(maxAgeMin) ? maxAgeMin * 60_000 : undefined,
3536
+ writeCache: fs.existsSync(path.join(projectRoot, '.specship')),
3537
+ runIdForWorktree,
3538
+ });
3539
+ }
3540
+ finally {
3541
+ close?.();
3542
+ }
3543
+ if (options.json) {
3544
+ // eslint-disable-next-line no-console
3545
+ console.log(JSON.stringify(result, null, 2));
3546
+ }
3547
+ else {
3548
+ const tag = { ok: 'ok ', fail: 'FAIL', skipped: 'skip' };
3549
+ for (const item of result.items) {
3550
+ // eslint-disable-next-line no-console
3551
+ console.log(`[${tag[item.status]}] ${item.message}${item.hint ? ` — ${item.hint}` : ''}`);
3552
+ }
3553
+ if (result.fromCache) {
3554
+ // eslint-disable-next-line no-console
3555
+ console.log(`(cached ${new Date(result.checkedAt).toISOString()}; --refresh to re-run)`);
3556
+ }
3557
+ }
3558
+ if (result.items.some((i) => i.status === 'fail'))
3559
+ process.exit(1);
3560
+ });
3158
3561
  program
3159
3562
  .command('drifted [path]')
3160
3563
  .description('List spec links in concerning states (drifted, broken, orphaned).')
@@ -3436,14 +3839,19 @@ function main() {
3436
3839
  }
3437
3840
  });
3438
3841
  program
3439
- .command('workflow <action> [arg]')
3440
- .description('Workflow engine: list | run <name> | resume <runId> | cancel <runId> | approve <runId> | reject <runId> | purge <runId> | runs')
3842
+ .command('workflow <action> [args...]')
3843
+ .description('Workflow engine: list | run <name> | run-batch <name> --specs <ids> | merge-queue <runId...> | gc | resume <runId> | cancel <runId> | approve <runId> | reject <runId> | purge <runId> | runs')
3441
3844
  .option('-i, --input <kv...>', 'workflow inputs as KEY=VALUE (repeatable)')
3442
3845
  .option('--path <projectRoot>', 'project root (default: cwd)')
3443
3846
  .option('--comment <text>', 'comment for approve/reject')
3444
3847
  .option('--reason <text>', 'reason for cancel/reject')
3848
+ .option('--specs <ids>', 'run-batch: comma-separated requirement ids')
3849
+ .option('--concurrency <n>', 'run-batch: max concurrent runs per wave (default 3)')
3850
+ .option('--dry-run', 'gc: list merged-run worktrees without removing them')
3851
+ .option('--agent <name>', 'run/run-batch: the coding agent prompt steps run on (claude | gemini; default: SPECSHIP_WORKFLOW_AGENT, workflow.agent, the launching agent, then claude)')
3445
3852
  .option('--json', 'emit JSON where applicable')
3446
- .action(async (action, arg, options) => {
3853
+ .action(async (action, args, options) => {
3854
+ const arg = args?.[0];
3447
3855
  const projectRoot = path.resolve(options.path ?? process.cwd());
3448
3856
  if (!(0, directory_1.isInitialized)(projectRoot)) {
3449
3857
  error(`SpecShip not initialized in ${projectRoot}. Run \`specship init -i\` first.`);
@@ -3451,6 +3859,7 @@ function main() {
3451
3859
  }
3452
3860
  const { SpecShip } = await loadSpecShip();
3453
3861
  const { discoverWorkflows, loadWorkflowByName } = await Promise.resolve().then(() => __importStar(require('../workflows/discovery')));
3862
+ const { resolveWorkflowAgent } = await Promise.resolve().then(() => __importStar(require('../workflows/agents')));
3454
3863
  const { WorkflowExecutor } = await Promise.resolve().then(() => __importStar(require('../workflows/executor')));
3455
3864
  const { WorktreeProvider } = await Promise.resolve().then(() => __importStar(require('../isolation/worktree')));
3456
3865
  const { handleJiraRunCompletion } = await Promise.resolve().then(() => __importStar(require('../mcp/jira-tools')));
@@ -3541,9 +3950,18 @@ function main() {
3541
3950
  inputs[inp.name] = inp.default ?? '';
3542
3951
  }
3543
3952
  }
3953
+ // Unknown agent names fail here, before a run exists (REQ-AGENTADAPT-006.A4).
3954
+ try {
3955
+ resolveWorkflowAgent({ flag: options.agent, projectRoot });
3956
+ }
3957
+ catch (err) {
3958
+ error(err instanceof Error ? err.message : String(err));
3959
+ process.exit(1);
3960
+ }
3544
3961
  const result = await executor.start(loaded.workflow, {
3545
3962
  projectRoot,
3546
3963
  inputs,
3964
+ agent: options.agent,
3547
3965
  variables: {
3548
3966
  ARTIFACTS_DIR: path.join((0, directory_1.getSpecShipDir)(projectRoot), 'artifacts'),
3549
3967
  CONTEXT: projectRoot,
@@ -3664,6 +4082,208 @@ function main() {
3664
4082
  : `Run ${arg} had no worktree — nothing to remove. Artifacts and the run record are kept.`);
3665
4083
  break;
3666
4084
  }
4085
+ case 'run-batch': {
4086
+ // REQ-THRU-001 — one run per requirement, overlapping files serialized.
4087
+ if (!arg) {
4088
+ error('workflow run-batch requires a workflow name');
4089
+ process.exit(1);
4090
+ }
4091
+ const loaded = loadWorkflowByName(projectRoot, arg);
4092
+ if (!loaded) {
4093
+ error(`Workflow "${arg}" not found. Use \`specship workflow list\` to see available workflows.`);
4094
+ process.exit(1);
4095
+ }
4096
+ const specIds = (options.specs ?? '').split(',').map((s) => s.trim()).filter(Boolean);
4097
+ if (specIds.length === 0) {
4098
+ error('workflow run-batch requires --specs <ID,ID,...>');
4099
+ process.exit(1);
4100
+ }
4101
+ const concurrency = options.concurrency !== undefined ? Number(options.concurrency) : undefined;
4102
+ if (concurrency !== undefined && (!Number.isInteger(concurrency) || concurrency < 1)) {
4103
+ error('--concurrency must be a positive integer');
4104
+ process.exit(1);
4105
+ }
4106
+ const baseInputs = {};
4107
+ for (const kv of options.input ?? []) {
4108
+ const eqIdx = kv.indexOf('=');
4109
+ if (eqIdx <= 0) {
4110
+ error(`--input must be KEY=VALUE (got "${kv}")`);
4111
+ process.exit(1);
4112
+ }
4113
+ baseInputs[kv.slice(0, eqIdx)] = kv.slice(eqIdx + 1);
4114
+ }
4115
+ for (const inp of loaded.workflow.inputs ?? []) {
4116
+ if (inp.name !== 'SPEC_ID' && !(inp.name in baseInputs)) {
4117
+ if (inp.required) {
4118
+ error(`Required input missing: --input ${inp.name}=...`);
4119
+ process.exit(1);
4120
+ }
4121
+ baseInputs[inp.name] = inp.default ?? '';
4122
+ }
4123
+ }
4124
+ try {
4125
+ resolveWorkflowAgent({ flag: options.agent, projectRoot });
4126
+ }
4127
+ catch (err) {
4128
+ error(err instanceof Error ? err.message : String(err));
4129
+ process.exit(1);
4130
+ }
4131
+ const { runBatch, TERMINAL_RUN_STATUSES, DEFAULT_BATCH_CONCURRENCY } = await Promise.resolve().then(() => __importStar(require('../workflows/batch')));
4132
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4133
+ let batch;
4134
+ try {
4135
+ batch = await runBatch({
4136
+ projectRoot,
4137
+ specIds,
4138
+ concurrency,
4139
+ startRun: async (specId) => {
4140
+ const result = await executor.start(loaded.workflow, {
4141
+ projectRoot,
4142
+ agent: options.agent,
4143
+ inputs: { ...baseInputs, SPEC_ID: specId },
4144
+ variables: {
4145
+ ARTIFACTS_DIR: path.join((0, directory_1.getSpecShipDir)(projectRoot), 'artifacts'),
4146
+ CONTEXT: projectRoot,
4147
+ },
4148
+ });
4149
+ return { runId: result.run.id, status: result.run.status };
4150
+ },
4151
+ // A run paused at an approval still holds its files: wait for a
4152
+ // terminal status (approve + resume it from another terminal).
4153
+ waitForRun: async (runId, status) => {
4154
+ let current = status;
4155
+ let announced = false;
4156
+ while (!TERMINAL_RUN_STATUSES.has(current)) {
4157
+ if (current === 'paused' && !announced) {
4158
+ // eslint-disable-next-line no-console
4159
+ console.log(` ${runId} is paused — approve it with \`specship workflow approve ${runId}\`, then \`specship workflow resume ${runId}\``);
4160
+ announced = true;
4161
+ }
4162
+ await sleep(5000);
4163
+ current = specQueries.getWorkflowRunById(runId)?.status ?? 'failed';
4164
+ }
4165
+ return current;
4166
+ },
4167
+ onPlan: (waves) => {
4168
+ if (options.json)
4169
+ return;
4170
+ // eslint-disable-next-line no-console
4171
+ console.log(`Serialization: ${waves.length} wave(s), concurrency ${concurrency ?? DEFAULT_BATCH_CONCURRENCY}`);
4172
+ waves.forEach((w, i) => {
4173
+ // eslint-disable-next-line no-console
4174
+ console.log(` wave ${i + 1}: ${w.join(', ')}`);
4175
+ });
4176
+ },
4177
+ onRunStarted: (r) => {
4178
+ if (options.json)
4179
+ return;
4180
+ // eslint-disable-next-line no-console
4181
+ console.log(` ${r.specId} → run ${r.runId} [${r.status}] (wave ${r.wave + 1})`);
4182
+ },
4183
+ });
4184
+ }
4185
+ catch (err) {
4186
+ error(err instanceof Error ? err.message : String(err));
4187
+ process.exit(1);
4188
+ }
4189
+ if (options.json) {
4190
+ // eslint-disable-next-line no-console
4191
+ console.log(JSON.stringify(batch, null, 2));
4192
+ }
4193
+ else {
4194
+ // eslint-disable-next-line no-console
4195
+ console.log('Runs:');
4196
+ for (const r of batch.runs) {
4197
+ // eslint-disable-next-line no-console
4198
+ console.log(` ${r.runId} ${r.specId} [${r.status}]`);
4199
+ }
4200
+ }
4201
+ if (batch.runs.some((r) => r.status === 'failed'))
4202
+ process.exit(1);
4203
+ break;
4204
+ }
4205
+ case 'merge-queue': {
4206
+ // REQ-THRU-002 — merge completed runs in order, re-checking each merge.
4207
+ const runIds = args ?? [];
4208
+ if (runIds.length === 0) {
4209
+ error('workflow merge-queue requires one or more runIds');
4210
+ process.exit(1);
4211
+ }
4212
+ const { runMergeQueue } = await Promise.resolve().then(() => __importStar(require('../workflows/merge-queue')));
4213
+ const result = runMergeQueue({
4214
+ projectRoot,
4215
+ runIds,
4216
+ getRun: (id) => {
4217
+ const run = specQueries.getWorkflowRunById(id);
4218
+ if (!run)
4219
+ return null;
4220
+ const env = run.isolationEnvId ? specQueries.getIsolationEnvById(run.isolationEnvId) : null;
4221
+ return { id: run.id, status: run.status, branch: env?.branchName };
4222
+ },
4223
+ });
4224
+ if (options.json) {
4225
+ // eslint-disable-next-line no-console
4226
+ console.log(JSON.stringify(result, null, 2));
4227
+ }
4228
+ else {
4229
+ for (const id of result.merged) {
4230
+ // eslint-disable-next-line no-console
4231
+ console.log(` merged ${id}`);
4232
+ }
4233
+ // eslint-disable-next-line no-console
4234
+ console.log(result.stoppedAt
4235
+ ? `Stopped at run ${result.stoppedAt} (${result.reason}): ${result.detail ?? ''}`
4236
+ : `Merge queue done — ${result.merged.length} run(s) merged.`);
4237
+ }
4238
+ if (result.stoppedAt)
4239
+ process.exit(1);
4240
+ break;
4241
+ }
4242
+ case 'gc': {
4243
+ // REQ-THRU-007 — purge worktrees of merged runs; records + artifacts kept.
4244
+ const { collectMergedRuns } = await Promise.resolve().then(() => __importStar(require('../workflows/gc')));
4245
+ const result = collectMergedRuns({
4246
+ projectRoot,
4247
+ dryRun: options.dryRun === true,
4248
+ listRuns: () => specQueries.getAllWorkflowRuns(1000).flatMap((run) => {
4249
+ if (!run.isolationEnvId)
4250
+ return [];
4251
+ const env = specQueries.getIsolationEnvById(run.isolationEnvId);
4252
+ if (!env)
4253
+ return [];
4254
+ return [{ id: run.id, status: run.status, branch: env.branchName, workingPath: env.workingPath }];
4255
+ }),
4256
+ purge: (runId) => {
4257
+ executor.purge(runId);
4258
+ },
4259
+ });
4260
+ if (options.json) {
4261
+ // eslint-disable-next-line no-console
4262
+ console.log(JSON.stringify(result, null, 2));
4263
+ }
4264
+ else if (!result.defaultBranch) {
4265
+ // eslint-disable-next-line no-console
4266
+ console.log('Default branch unknown (no origin HEAD, main or master) — nothing collected.');
4267
+ }
4268
+ else {
4269
+ const verb = result.dryRun ? 'would remove' : 'removed';
4270
+ for (const c of result.candidates) {
4271
+ // eslint-disable-next-line no-console
4272
+ console.log(` ${verb} ${c.runId} ${c.branch} (${c.workingPath})`);
4273
+ }
4274
+ for (const s of result.skipped) {
4275
+ // eslint-disable-next-line no-console
4276
+ console.log(` kept ${s.runId} — ${s.reason}`);
4277
+ }
4278
+ // eslint-disable-next-line no-console
4279
+ console.log(result.candidates.length === 0
4280
+ ? `No merged-run worktrees to collect (default branch: ${result.defaultBranch}).`
4281
+ : result.dryRun
4282
+ ? `${result.candidates.length} worktree(s) would be removed. Run without --dry-run to collect.`
4283
+ : `${result.purged.length} worktree(s) removed. Run records and artifacts are kept.`);
4284
+ }
4285
+ break;
4286
+ }
3667
4287
  case 'runs': {
3668
4288
  const runs = specQueries.getAllWorkflowRuns(50);
3669
4289
  if (options.json) {
@@ -3686,7 +4306,7 @@ function main() {
3686
4306
  break;
3687
4307
  }
3688
4308
  default:
3689
- error(`Unknown workflow action "${action}". Use: list | run | resume | cancel | approve | reject | purge | runs`);
4309
+ error(`Unknown workflow action "${action}". Use: list | run | run-batch | merge-queue | gc | resume | cancel | approve | reject | purge | runs`);
3690
4310
  process.exit(1);
3691
4311
  }
3692
4312
  }
@@ -4087,6 +4707,67 @@ function main() {
4087
4707
  cg.close();
4088
4708
  }
4089
4709
  });
4710
+ /**
4711
+ * specship jira publish --pending — the explicit, one-batch publish of every
4712
+ * requirement sync held back (REQ-THRU-006). Sync only publishes when
4713
+ * `jira.autoPublish: true` is set in specship.config.json; otherwise it
4714
+ * reports the pending count and points here.
4715
+ */
4716
+ jira
4717
+ .command('publish')
4718
+ .description('Publish specs to the bound JIRA project in one explicit batch (--pending: every requirement sync held back).')
4719
+ .option('--pending', 'publish every requirement with no JIRA issue yet or whose content changed')
4720
+ .option('--dry-run', 'list what would be published without writing to JIRA')
4721
+ .option('--path <projectRoot>', 'project root (default: cwd)')
4722
+ .action(async (options) => {
4723
+ const projectRoot = path.resolve(options.path ?? process.cwd());
4724
+ if (!options.pending) {
4725
+ error('Nothing to publish: pass --pending to publish every requirement sync held back.');
4726
+ process.exit(1);
4727
+ }
4728
+ if (!(0, directory_1.isInitialized)(projectRoot)) {
4729
+ error(`SpecShip not initialized in ${projectRoot}. Run \`specship init -i\` first.`);
4730
+ process.exit(1);
4731
+ }
4732
+ const { SpecShip } = await loadSpecShip();
4733
+ const { publishPendingSpecs, runAutoPublish, summarizeAutoPublishReport, countAutoPublishReport } = await Promise.resolve().then(() => __importStar(require('../jira/auto-publish')));
4734
+ const cg = await SpecShip.open(projectRoot);
4735
+ try {
4736
+ const deps = { specQueries: cg.getSpecQueries(), projectRoot };
4737
+ const report = options.dryRun
4738
+ ? await runAutoPublish(deps, { explicit: true, dryRun: true })
4739
+ : await publishPendingSpecs(deps);
4740
+ if (report.skipped === 'unbound') {
4741
+ error('No JIRA binding in specship.config.json — run `specship jira bind` first.');
4742
+ process.exit(1);
4743
+ }
4744
+ if (options.dryRun) {
4745
+ const pending = report.results.filter((r) => r.status === 'pending');
4746
+ // eslint-disable-next-line no-console
4747
+ console.log(`${pending.length} spec(s) would be published:`);
4748
+ // eslint-disable-next-line no-console
4749
+ for (const r of pending)
4750
+ console.log(` ${r.specId}${r.jiraKey ? ` (${r.jiraKey})` : ''}`);
4751
+ return;
4752
+ }
4753
+ const line = summarizeAutoPublishReport(report);
4754
+ // eslint-disable-next-line no-console
4755
+ if (line)
4756
+ console.log(line.replace(/^JIRA auto-publish/, 'JIRA publish'));
4757
+ for (const r of report.results) {
4758
+ if (r.status === 'failed') {
4759
+ // eslint-disable-next-line no-console
4760
+ console.log(`JIRA: ${r.specId} failed — ${r.note ?? 'unknown error'}`);
4761
+ }
4762
+ }
4763
+ if (report.skipped === 'no-credentials' || countAutoPublishReport(report).failed > 0) {
4764
+ process.exitCode = 1;
4765
+ }
4766
+ }
4767
+ finally {
4768
+ cg.close();
4769
+ }
4770
+ });
4090
4771
  /**
4091
4772
  * specship jira reconcile — detect JIRA-side edits and (with --apply) fold
4092
4773
  * them back into the spec (REQ-JIRATEAM-005). Preview is read-only and the