@specship/specship-win32-arm64 0.21.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/lib/dist/bin/specship.js +302 -4
  2. package/lib/dist/bin/specship.js.map +1 -1
  3. package/lib/dist/commands/specship/jira.md +74 -0
  4. package/lib/dist/commands/specship/spec.md +61 -1
  5. package/lib/dist/extraction/index.d.ts +6 -0
  6. package/lib/dist/extraction/index.d.ts.map +1 -1
  7. package/lib/dist/extraction/index.js.map +1 -1
  8. package/lib/dist/extraction/specs/markdown-spec-extractor.d.ts.map +1 -1
  9. package/lib/dist/extraction/specs/markdown-spec-extractor.js +8 -1
  10. package/lib/dist/extraction/specs/markdown-spec-extractor.js.map +1 -1
  11. package/lib/dist/index.d.ts +7 -0
  12. package/lib/dist/index.d.ts.map +1 -1
  13. package/lib/dist/index.js +45 -18
  14. package/lib/dist/index.js.map +1 -1
  15. package/lib/dist/jira/auto-publish.d.ts +67 -0
  16. package/lib/dist/jira/auto-publish.d.ts.map +1 -0
  17. package/lib/dist/jira/auto-publish.js +232 -0
  18. package/lib/dist/jira/auto-publish.js.map +1 -0
  19. package/lib/dist/jira/board-first.d.ts +64 -0
  20. package/lib/dist/jira/board-first.d.ts.map +1 -0
  21. package/lib/dist/jira/board-first.js +91 -0
  22. package/lib/dist/jira/board-first.js.map +1 -0
  23. package/lib/dist/jira/client.d.ts +80 -1
  24. package/lib/dist/jira/client.d.ts.map +1 -1
  25. package/lib/dist/jira/client.js +213 -1
  26. package/lib/dist/jira/client.js.map +1 -1
  27. package/lib/dist/jira/config.d.ts +1 -1
  28. package/lib/dist/jira/config.d.ts.map +1 -1
  29. package/lib/dist/jira/config.js +14 -3
  30. package/lib/dist/jira/config.js.map +1 -1
  31. package/lib/dist/jira/coverage.d.ts +105 -0
  32. package/lib/dist/jira/coverage.d.ts.map +1 -0
  33. package/lib/dist/jira/coverage.js +160 -0
  34. package/lib/dist/jira/coverage.js.map +1 -0
  35. package/lib/dist/jira/index.d.ts +1 -0
  36. package/lib/dist/jira/index.d.ts.map +1 -1
  37. package/lib/dist/jira/index.js +1 -0
  38. package/lib/dist/jira/index.js.map +1 -1
  39. package/lib/dist/jira/milestone-comment.d.ts +95 -0
  40. package/lib/dist/jira/milestone-comment.d.ts.map +1 -0
  41. package/lib/dist/jira/milestone-comment.js +271 -0
  42. package/lib/dist/jira/milestone-comment.js.map +1 -0
  43. package/lib/dist/jira/publish.d.ts +69 -2
  44. package/lib/dist/jira/publish.d.ts.map +1 -1
  45. package/lib/dist/jira/publish.js +0 -0
  46. package/lib/dist/jira/publish.js.map +1 -1
  47. package/lib/dist/jira/published-specs.d.ts +23 -0
  48. package/lib/dist/jira/published-specs.d.ts.map +1 -0
  49. package/lib/dist/jira/published-specs.js +106 -0
  50. package/lib/dist/jira/published-specs.js.map +1 -0
  51. package/lib/dist/jira/reconcile.d.ts +81 -0
  52. package/lib/dist/jira/reconcile.d.ts.map +1 -0
  53. package/lib/dist/jira/reconcile.js +111 -0
  54. package/lib/dist/jira/reconcile.js.map +1 -0
  55. package/lib/dist/jira/regression-pack.d.ts +547 -0
  56. package/lib/dist/jira/regression-pack.d.ts.map +1 -0
  57. package/lib/dist/jira/regression-pack.js +986 -0
  58. package/lib/dist/jira/regression-pack.js.map +1 -0
  59. package/lib/dist/jira/repo-config.d.ts +59 -0
  60. package/lib/dist/jira/repo-config.d.ts.map +1 -0
  61. package/lib/dist/jira/repo-config.js +222 -0
  62. package/lib/dist/jira/repo-config.js.map +1 -0
  63. package/lib/dist/jira/spec-amend.d.ts +46 -0
  64. package/lib/dist/jira/spec-amend.d.ts.map +1 -0
  65. package/lib/dist/jira/spec-amend.js +232 -0
  66. package/lib/dist/jira/spec-amend.js.map +1 -0
  67. package/lib/dist/jira/spec-writer.d.ts +22 -0
  68. package/lib/dist/jira/spec-writer.d.ts.map +1 -1
  69. package/lib/dist/jira/spec-writer.js +113 -0
  70. package/lib/dist/jira/spec-writer.js.map +1 -1
  71. package/lib/dist/jira/types.d.ts +62 -0
  72. package/lib/dist/jira/types.d.ts.map +1 -1
  73. package/lib/dist/jira/types.js +15 -1
  74. package/lib/dist/jira/types.js.map +1 -1
  75. package/lib/dist/mcp/jira-tools.d.ts +102 -0
  76. package/lib/dist/mcp/jira-tools.d.ts.map +1 -1
  77. package/lib/dist/mcp/jira-tools.js +807 -50
  78. package/lib/dist/mcp/jira-tools.js.map +1 -1
  79. package/lib/dist/mcp/server-instructions.d.ts +1 -1
  80. package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
  81. package/lib/dist/mcp/server-instructions.js +4 -0
  82. package/lib/dist/mcp/server-instructions.js.map +1 -1
  83. package/lib/dist/mcp/spec-tools.d.ts.map +1 -1
  84. package/lib/dist/mcp/spec-tools.js +7 -1
  85. package/lib/dist/mcp/spec-tools.js.map +1 -1
  86. package/lib/dist/mcp/tools.d.ts +3 -0
  87. package/lib/dist/mcp/tools.d.ts.map +1 -1
  88. package/lib/dist/mcp/tools.js +50 -0
  89. package/lib/dist/mcp/tools.js.map +1 -1
  90. package/lib/dist/resolution/spec-link-resolver.d.ts +2 -0
  91. package/lib/dist/resolution/spec-link-resolver.d.ts.map +1 -1
  92. package/lib/dist/resolution/spec-link-resolver.js +11 -3
  93. package/lib/dist/resolution/spec-link-resolver.js.map +1 -1
  94. package/lib/node_modules/.package-lock.json +1 -1
  95. package/lib/package.json +1 -1
  96. package/package.json +1 -1
@@ -51,6 +51,8 @@ var __importStar = (this && this.__importStar) || (function () {
51
51
  Object.defineProperty(exports, "__esModule", { value: true });
52
52
  exports.jiraToolDefinitions = void 0;
53
53
  exports.handleSpecshipJiraIssues = handleSpecshipJiraIssues;
54
+ exports.handleSpecshipJiraAnchor = handleSpecshipJiraAnchor;
55
+ exports.handleSpecshipJiraEpics = handleSpecshipJiraEpics;
54
56
  exports.handleSpecshipJiraIssue = handleSpecshipJiraIssue;
55
57
  exports.handleSpecshipJiraTransition = handleSpecshipJiraTransition;
56
58
  exports.validateConfiguredTransitions = validateConfiguredTransitions;
@@ -61,17 +63,26 @@ exports.deriveWorkState = deriveWorkState;
61
63
  exports.handleSpecshipJiraTrack = handleSpecshipJiraTrack;
62
64
  exports.handleSpecshipJiraPublish = handleSpecshipJiraPublish;
63
65
  exports.handleSpecshipJiraAddTask = handleSpecshipJiraAddTask;
66
+ exports.handleSpecshipJiraRegressionPack = handleSpecshipJiraRegressionPack;
67
+ exports.handleSpecshipJiraRegressionRecord = handleSpecshipJiraRegressionRecord;
68
+ exports.handleSpecshipJiraCoverage = handleSpecshipJiraCoverage;
69
+ exports.handleSpecshipJiraReconcile = handleSpecshipJiraReconcile;
64
70
  const fs = __importStar(require("fs"));
65
71
  const path = __importStar(require("path"));
66
72
  const crypto_1 = require("crypto");
67
73
  const child_process_1 = require("child_process");
68
74
  const detect_1 = require("../taskship/detect");
69
75
  const config_1 = require("../jira/config");
76
+ const repo_config_1 = require("../jira/repo-config");
70
77
  const client_1 = require("../jira/client");
78
+ const board_first_1 = require("../jira/board-first");
71
79
  const types_1 = require("../jira/types");
72
80
  const spec_writer_1 = require("../jira/spec-writer");
81
+ const regression_pack_1 = require("../jira/regression-pack");
73
82
  const spec_generator_1 = require("../jira/spec-generator");
74
83
  const publish_1 = require("../jira/publish");
84
+ const published_specs_1 = require("../jira/published-specs");
85
+ const milestone_comment_1 = require("../jira/milestone-comment");
75
86
  const pull_request_1 = require("../jira/pull-request");
76
87
  function textResult(text) {
77
88
  return { content: [{ type: 'text', text }] };
@@ -147,7 +158,7 @@ async function pushJiraStartStatus(key, make) {
147
158
  *
148
159
  * SECURITY: only the public PR URL + issue key are sent (REQ-JIRA-009).
149
160
  */
150
- async function pushJiraReviewStatus(key, prUrl, make) {
161
+ async function pushJiraReviewStatus(key, prUrl, make, specId) {
151
162
  let ctx;
152
163
  try {
153
164
  ctx = make();
@@ -171,9 +182,29 @@ async function pushJiraReviewStatus(key, prUrl, make) {
171
182
  notes.push(`couldn't transition ${key} (${errMsg(err)})`);
172
183
  }
173
184
  // The PR-link comment is posted regardless of the transition outcome (A3).
185
+ // Milestone: pr_raised (REQ-JIRATEAM-003.A1/A2). The dispatcher itself is
186
+ // idempotent (single comment per issue+specId), watermarked, and
187
+ // soft-fails to a local note — so a JIRA hiccup here never undoes the
188
+ // raised PR. Fall back to a plain addComment only for fake clients that
189
+ // don't implement the milestone shape.
190
+ const maybe = ctx.client;
191
+ const canMilestone = typeof maybe.listCommentsDetailed === 'function' &&
192
+ typeof maybe.updateComment === 'function' &&
193
+ typeof maybe.addComment === 'function';
174
194
  try {
175
- await ctx.client.addComment(key, `SpecShip raised a pull request: ${prUrl}`);
176
- notes.push(`commented the PR link on ${key}`);
195
+ if (canMilestone) {
196
+ const r = await (0, milestone_comment_1.postMilestoneComment)(maybe, key, 'pr_raised', { specId: specId ?? key, prUrl });
197
+ if (r.status === 'soft_failed') {
198
+ notes.push(`couldn't comment the PR link on ${key} (${r.reason ?? 'unknown'})`);
199
+ }
200
+ else {
201
+ notes.push(`commented the PR link on ${key}`);
202
+ }
203
+ }
204
+ else {
205
+ await ctx.client.addComment(key, `SpecShip raised a pull request: ${prUrl}`);
206
+ notes.push(`commented the PR link on ${key}`);
207
+ }
177
208
  }
178
209
  catch (err) {
179
210
  notes.push(`couldn't comment the PR link on ${key} (${errMsg(err)})`);
@@ -273,6 +304,36 @@ exports.jiraToolDefinitions = [
273
304
  },
274
305
  },
275
306
  },
307
+ {
308
+ name: 'specship_jira_coverage',
309
+ description: 'Show a sprint coverage report joining the bound JIRA project\'s active ' +
310
+ '(or named) sprint to spec truth — every issue in the sprint with its ' +
311
+ 'repo-side state (unspecced / specced / implemented / verified / drifted / ' +
312
+ 'broken) and rollup totals. Read-only over JIRA by default; pass ' +
313
+ '`post: true` with `issue_key` to upsert the report as a single ' +
314
+ 'watermarked comment on that issue (edited in place on re-post).',
315
+ inputSchema: {
316
+ type: 'object',
317
+ properties: {
318
+ project: {
319
+ type: 'string',
320
+ description: 'JIRA project key to scope the sprint read (e.g., "PROJ"). Optional when a default project is configured.',
321
+ },
322
+ sprint: {
323
+ type: 'string',
324
+ description: 'Optional named sprint. Omit to use the project\'s currently open sprint(s).',
325
+ },
326
+ post: {
327
+ type: 'boolean',
328
+ description: 'Post the report as a single watermarked JIRA comment (edit-in-place). Defaults to false — never posts unless explicitly requested.',
329
+ },
330
+ issue_key: {
331
+ type: 'string',
332
+ description: 'When `post` is true, the anchor issue (e.g., an epic key) the coverage comment lands on. Required with `post`.',
333
+ },
334
+ },
335
+ },
336
+ },
276
337
  {
277
338
  name: 'specship_jira_publish',
278
339
  description: 'Publish an authored spec to JIRA as a Story whose Sub-tasks mirror the ' +
@@ -295,6 +356,50 @@ exports.jiraToolDefinitions = [
295
356
  required: ['spec_id'],
296
357
  },
297
358
  },
359
+ {
360
+ name: 'specship_jira_reconcile',
361
+ description: "Detect JIRA-side edits to published specs and (only with explicit user " +
362
+ 'confirmation) fold them back into the spec (REQ-JIRATEAM-005). Two modes: ' +
363
+ '`mode: "preview"` (default) — read-only; enumerates every published spec ' +
364
+ "whose LIVE JIRA issue diverges from what publish wrote (edited summary/" +
365
+ 'description, or a Sub-task added in JIRA with no matching acceptance ' +
366
+ 'criterion) and returns a proposed spec amendment. `mode: "apply"` — ' +
367
+ 'writes the previewed amendment to the spec file and re-publishes so the ' +
368
+ 'fingerprint refreshes. **Preview first, then apply only after the user ' +
369
+ 'has explicitly confirmed the exact diff in conversation.** Apply refuses ' +
370
+ "unless `expected_live_fingerprint` matches the issue's current live " +
371
+ 'fingerprint — a value the caller MUST have just seen returned by a ' +
372
+ 'preview call. No preview → no apply.',
373
+ inputSchema: {
374
+ type: 'object',
375
+ properties: {
376
+ mode: {
377
+ type: 'string',
378
+ enum: ['preview', 'apply'],
379
+ description: 'preview (default) returns divergences without touching any file; apply writes ' +
380
+ 'the amendment after preview + explicit user confirmation.',
381
+ },
382
+ issue_key: {
383
+ type: 'string',
384
+ description: 'Optional: narrow preview to a single issue key (e.g., "PROJ-123"). Required for apply.',
385
+ },
386
+ accept_content: {
387
+ type: 'boolean',
388
+ description: 'apply only: fold the edited summary/description back into the spec.',
389
+ },
390
+ accept_subtasks: {
391
+ type: 'array',
392
+ items: { type: 'string' },
393
+ description: 'apply only: JIRA Sub-task keys whose proposed acceptance criteria the user has confirmed.',
394
+ },
395
+ expected_live_fingerprint: {
396
+ type: 'string',
397
+ description: "apply only: the issue's live fingerprint from the preview the user just approved. " +
398
+ 'apply refuses when this does not match the current live fingerprint (preview-first gate).',
399
+ },
400
+ },
401
+ },
402
+ },
298
403
  {
299
404
  name: 'specship_jira_transition',
300
405
  description: 'Transition a JIRA issue to a target workflow state (e.g., "In Progress", ' +
@@ -317,6 +422,42 @@ exports.jiraToolDefinitions = [
317
422
  required: ['key'],
318
423
  },
319
424
  },
425
+ {
426
+ name: 'specship_jira_anchor',
427
+ description: 'Resolve the board-first work anchor for the current repo (REQ-JIRATEAM-007). ' +
428
+ 'Returns "unbound" when the repo has no JIRA binding (callers proceed unchanged), ' +
429
+ 'the anchored issue when one is explicit/picked, or refuses (with the epic-scoped ' +
430
+ 'pickable list) when a bound repo has no anchor yet. Read-only: never writes.',
431
+ inputSchema: {
432
+ type: 'object',
433
+ properties: {
434
+ issue_key: {
435
+ type: 'string',
436
+ description: 'Optional: an explicit issue key to anchor on (e.g., "PROJ-123"). Skips the pick step.',
437
+ },
438
+ picked_issue_key: {
439
+ type: 'string',
440
+ description: 'Optional: the key just picked from a specship_jira_issues list — routed as the anchor.',
441
+ },
442
+ },
443
+ },
444
+ },
445
+ {
446
+ name: 'specship_jira_epics',
447
+ description: 'List the open epics for a JIRA project — powers the /specship:jira menu ' +
448
+ "epic-picker (REQ-JIRATEAM-008). With no `project`, uses the repo's bound " +
449
+ 'project (specship.config.json → jira.projectKey); errors clearly if neither ' +
450
+ 'is set. Read-only: never writes; ordered most-recently-updated first.',
451
+ inputSchema: {
452
+ type: 'object',
453
+ properties: {
454
+ project: {
455
+ type: 'string',
456
+ description: "Optional project key (e.g., \"PROJ\"). Omit to use the repo's bound project.",
457
+ },
458
+ },
459
+ },
460
+ },
320
461
  {
321
462
  name: 'specship_jira_add_task',
322
463
  description: 'Add a task you identified mid-implementation under its epic/story. If ' +
@@ -355,6 +496,69 @@ exports.jiraToolDefinitions = [
355
496
  required: ['parent', 'title'],
356
497
  },
357
498
  },
499
+ {
500
+ name: 'specship_jira_regression_record',
501
+ description: 'Record a regression pack case result on its JIRA issue (REQ-JIRAREG-005): ' +
502
+ 'watermarked comment upserted per (case, runId), pass → Done + validates-link ' +
503
+ 'verified, fail → In Review + broken + a triage hint, unexecuted → comment only ' +
504
+ '(no transition, no link-state churn). Idempotent per (case, runId).',
505
+ inputSchema: {
506
+ type: 'object',
507
+ properties: {
508
+ case: { type: 'string', description: 'JIRA case issue key (e.g., "PROJ-42").' },
509
+ criterion: {
510
+ type: 'string',
511
+ description: 'Source acceptance criterion id (e.g., "REQ-FOO-001.A2").',
512
+ },
513
+ result: {
514
+ type: 'string',
515
+ enum: ['passed', 'failed', 'unexecuted'],
516
+ description: 'Outcome of the pack case.',
517
+ },
518
+ executor: {
519
+ type: 'string',
520
+ enum: ['human', 'agent'],
521
+ description: 'Who ran the case. Defaults to "human".',
522
+ },
523
+ harness: {
524
+ type: 'string',
525
+ description: 'Optional behaviour harness id (e.g., "behaviour-e2e").',
526
+ },
527
+ run: {
528
+ type: 'string',
529
+ description: 'Stable run identifier — one comment per (case, run) so re-records edit in place.',
530
+ },
531
+ log_ref: { type: 'string', description: 'Optional log pointer for evidence.' },
532
+ error: { type: 'string', description: 'Optional error text (fail path).' },
533
+ },
534
+ required: ['case', 'criterion', 'result'],
535
+ },
536
+ },
537
+ {
538
+ name: 'specship_jira_regression_pack',
539
+ description: 'Generate or refresh the SpecShip Regression Pack in the bound JIRA ' +
540
+ 'project (REQ-JIRAREG-001): one watermarked epic → one domain-area ' +
541
+ 'Story → one Sub-task per acceptance criterion of every implemented ' +
542
+ '(or verified) requirement. Idempotent — a re-run with nothing changed ' +
543
+ 'performs zero JIRA writes; a criterion added, removed, or edited yields ' +
544
+ 'exactly the matching create / update. Pass dry_run: true to preview the ' +
545
+ 'plan without touching JIRA.',
546
+ inputSchema: {
547
+ type: 'object',
548
+ properties: {
549
+ project: {
550
+ type: 'string',
551
+ description: 'Optional JIRA project key (e.g., "PROJ"). Defaults to the ' +
552
+ 'configured publish project when omitted.',
553
+ },
554
+ dry_run: {
555
+ type: 'boolean',
556
+ description: 'When true, build the plan and report counts without creating or ' +
557
+ 'updating any JIRA issue. Default: false.',
558
+ },
559
+ },
560
+ },
561
+ },
358
562
  ];
359
563
  /** Escape a value so it can't break the markdown table layout. */
360
564
  function cell(s) {
@@ -381,6 +585,8 @@ function formatIssues(issues, opts) {
381
585
  const notes = [];
382
586
  if (opts?.project)
383
587
  notes.push(`Filtered to project ${cell(opts.project)}.`);
588
+ if (opts?.epicKey)
589
+ notes.push(`Scoped to epic ${cell(opts.epicKey)}.`);
384
590
  if (issues.length >= client_1.MAX_ISSUE_RESULTS) {
385
591
  notes.push(`Showing the ${client_1.MAX_ISSUE_RESULTS} most recently updated.`);
386
592
  }
@@ -442,17 +648,36 @@ async function handleSpecshipJiraIssues(args) {
442
648
  const notConfigured = notConfiguredResult();
443
649
  if (notConfigured)
444
650
  return notConfigured;
445
- const project = typeof args.project === 'string' && args.project.trim()
651
+ const explicitProject = typeof args.project === 'string' && args.project.trim()
446
652
  ? args.project.trim()
447
653
  : undefined;
448
654
  // Sprint filter (TASKSHIP-BRIDGE-DOC, REQ-TASKSHIP-001): only "active" is a
449
655
  // recognized value; anything else is ignored (falls back to all issues).
450
656
  const sprint = args.sprint === 'active' ? 'active' : undefined;
657
+ // Board-first (REQ-JIRATEAM-007.A1): with a repo binding and no explicit
658
+ // overrides, scope to the bound project's epic so the same tool that
659
+ // powers the pick flow surfaces the anchor-eligible open work.
660
+ let epicKey;
661
+ let project = explicitProject;
662
+ if (!explicitProject) {
663
+ try {
664
+ const { binding } = (0, repo_config_1.loadRepoJiraBinding)(process.cwd());
665
+ if (binding) {
666
+ project = binding.projectKey;
667
+ if (binding.epicKey)
668
+ epicKey = binding.epicKey;
669
+ }
670
+ }
671
+ catch {
672
+ // A malformed repo config already surfaces via other paths; here we
673
+ // just skip the scoping rather than blocking a listing call.
674
+ }
675
+ }
451
676
  try {
452
677
  const creds = (0, config_1.resolveJiraCredentials)();
453
678
  const client = new client_1.JiraClient(creds);
454
- const result = await client.listMyIssues({ project, sprint });
455
- return textResult(formatIssues(result.issues, { project }));
679
+ const result = await client.listMyIssues({ project, sprint, epicKey });
680
+ return textResult(formatIssues(result.issues, { project, epicKey }));
456
681
  }
457
682
  catch (err) {
458
683
  // JiraError messages are credential-free by construction (REQ-JIRA-009).
@@ -463,6 +688,106 @@ async function handleSpecshipJiraIssues(args) {
463
688
  return errorResult('Failed to list JIRA issues.');
464
689
  }
465
690
  }
691
+ /**
692
+ * Handle `specship_jira_anchor` (REQ-JIRATEAM-007). Thin wrapper over
693
+ * `resolveWorkAnchor` so slash-commands and external agents can query the
694
+ * board-first gate without duplicating logic. Always returns a plain-text
695
+ * result — anchored → an `Anchor:` line naming the key; refused → the
696
+ * canonical human refusal (with the pickable list when available); unbound
697
+ * → a one-liner so callers no-op cleanly.
698
+ */
699
+ async function handleSpecshipJiraAnchor(args) {
700
+ const explicitIssueKey = typeof args.issue_key === 'string' && args.issue_key.trim()
701
+ ? args.issue_key.trim()
702
+ : undefined;
703
+ const pickedIssueKey = typeof args.picked_issue_key === 'string' && args.picked_issue_key.trim()
704
+ ? args.picked_issue_key.trim()
705
+ : undefined;
706
+ try {
707
+ // A resolve that needs to call JIRA (explicit/picked key, or the pickable
708
+ // list on refusal) will hit `resolveJiraCredentials` inside the default
709
+ // makeClient — gate up-front so an unconfigured caller still gets the
710
+ // canonical pointer, not a stack.
711
+ if (explicitIssueKey || pickedIssueKey) {
712
+ const notConfigured = notConfiguredResult();
713
+ if (notConfigured)
714
+ return notConfigured;
715
+ }
716
+ const res = await (0, board_first_1.resolveWorkAnchor)({
717
+ cwd: process.cwd(),
718
+ explicitIssueKey,
719
+ pickedIssueKey,
720
+ });
721
+ if (res.status === 'unbound') {
722
+ return textResult('Anchor: unbound — repo has no JIRA binding; work-creating flows proceed unchanged.');
723
+ }
724
+ if (res.status === 'anchored') {
725
+ return textResult(`Anchor: ${res.anchor.issueKey} — ${res.anchor.summary} (source: ${res.anchor.source})`);
726
+ }
727
+ return errorResult((0, board_first_1.formatRefusal)(res));
728
+ }
729
+ catch (err) {
730
+ if (err instanceof types_1.JiraError)
731
+ return errorResult(err.message);
732
+ return errorResult('Failed to resolve JIRA anchor.');
733
+ }
734
+ }
735
+ /**
736
+ * Handle `specship_jira_epics` (REQ-JIRATEAM-008). Lists open epics for a
737
+ * project — the picker source for the `/specship:jira` menu's "choose epic"
738
+ * step. With no `project`, falls back to the repo binding's `projectKey` so
739
+ * on a bound repo the caller doesn't have to duplicate the lookup.
740
+ */
741
+ async function handleSpecshipJiraEpics(args) {
742
+ const notConfigured = notConfiguredResult();
743
+ if (notConfigured)
744
+ return notConfigured;
745
+ let project = typeof args.project === 'string' && args.project.trim()
746
+ ? args.project.trim()
747
+ : undefined;
748
+ if (!project) {
749
+ try {
750
+ const { binding } = (0, repo_config_1.loadRepoJiraBinding)(process.cwd());
751
+ if (binding)
752
+ project = binding.projectKey;
753
+ }
754
+ catch (err) {
755
+ if (err instanceof types_1.JiraError)
756
+ return errorResult(err.message);
757
+ }
758
+ }
759
+ if (!project) {
760
+ return errorResult('No JIRA project. Pass `project`, or bind one with ' +
761
+ '`specship jira bind --project <KEY>` (writes specship.config.json).');
762
+ }
763
+ try {
764
+ const creds = (0, config_1.resolveJiraCredentials)();
765
+ const client = new client_1.JiraClient(creds);
766
+ const epics = await client.listEpics(project);
767
+ return textResult(formatEpics(epics, project));
768
+ }
769
+ catch (err) {
770
+ if (err instanceof types_1.JiraError)
771
+ return errorResult(err.message);
772
+ return errorResult('Failed to list JIRA epics.');
773
+ }
774
+ }
775
+ function formatEpics(epics, project) {
776
+ if (epics.length === 0) {
777
+ return (`No open epics in project ${cell(project)}.\n\n` +
778
+ '> Note: only epics whose status category is not "Done" are listed.');
779
+ }
780
+ const table = [
781
+ '| Key | Summary | Status |',
782
+ '| --- | --- | --- |',
783
+ ...epics.map((e) => `| ${cell(e.key)} | ${cell(e.summary)} | ${cell(e.status)} |`),
784
+ ].join('\n');
785
+ const notes = [`Project ${cell(project)}, ordered most-recently-updated first.`];
786
+ if (epics.length >= client_1.MAX_ISSUE_RESULTS) {
787
+ notes.push(`Showing the ${client_1.MAX_ISSUE_RESULTS} most recently updated.`);
788
+ }
789
+ return `${table}\n\n> Note: ${notes.join(' ')}`;
790
+ }
466
791
  /**
467
792
  * Handle `specship_jira_issue` (REQ-JIRA-003). Fetches a single issue by key
468
793
  * and renders its full detail. Independent of the code graph — talks only to
@@ -751,7 +1076,7 @@ async function handleJiraRunCompletion(run, deps) {
751
1076
  // Only on a raised PR — a failed raise never advances the issue (below),
752
1077
  // matching REQ-JIRA-005.A2 (leave it in-progress). Never throws.
753
1078
  const make = deps.makeJiraClient ?? defaultMakeJiraClient;
754
- const note = await pushJiraReviewStatus(issueKey, outcome.url, make);
1079
+ const note = await pushJiraReviewStatus(issueKey, outcome.url, make, jira.specId);
755
1080
  if (note)
756
1081
  log(note);
757
1082
  }
@@ -963,7 +1288,7 @@ async function handleSpecshipJiraTrack(args, deps) {
963
1288
  }
964
1289
  // Published specs are tracked too (REQ-JIRAPUB-008) — they may predate any
965
1290
  // workflow run, so the empty-view short-circuit must consider both sources.
966
- const published = deps.projectRoot ? enumeratePublishedSpecs(deps.projectRoot) : [];
1291
+ const published = deps.projectRoot ? (0, published_specs_1.enumeratePublishedSpecs)(deps.projectRoot) : [];
967
1292
  if (picked.length === 0 && published.length === 0) {
968
1293
  return textResult(formatTrack([]));
969
1294
  }
@@ -1030,10 +1355,24 @@ async function handleSpecshipJiraTrack(args, deps) {
1030
1355
  });
1031
1356
  }
1032
1357
  if (pub.fingerprint && live) {
1033
- const liveFp = (0, publish_1.issueContentFingerprint)(live.summary, live.description ?? '');
1034
- if (liveFp !== pub.fingerprint) {
1358
+ // Delegate the divergence judgement to the reconcile module so the
1359
+ // track view + the reconcile tool never disagree about "what counts as
1360
+ // an edit". Sub-task diffs need the spec's acceptance criteria, which
1361
+ // this row doesn't have — track only reports the content flag; a full
1362
+ // preview lives in `specship_jira_reconcile`.
1363
+ const { diffIssueVsSpec } = await Promise.resolve().then(() => __importStar(require('../jira/reconcile')));
1364
+ const specView = {
1365
+ specRelPath: pub.specRelPath,
1366
+ requirementId: pub.issueKey,
1367
+ title: pub.title,
1368
+ body: '',
1369
+ acceptance: [],
1370
+ };
1371
+ const report = diffIssueVsSpec(live, specView, pub.fingerprint);
1372
+ if (report.content) {
1035
1373
  divergences.push(`⚠ ${pub.issueKey} was edited in JIRA after publish (spec: ${pub.specRelPath}) — ` +
1036
- 're-publish with specship_jira_publish to refresh, or fold the JIRA edit back into the spec.');
1374
+ 'run specship_jira_reconcile to preview the JIRA-side changes, or ' +
1375
+ 're-publish with specship_jira_publish to refresh the fingerprint.');
1037
1376
  }
1038
1377
  }
1039
1378
  }
@@ -1041,45 +1380,6 @@ async function handleSpecshipJiraTrack(args, deps) {
1041
1380
  const table = formatTrack(rows);
1042
1381
  return textResult(divergences.length > 0 ? `${table}\n\n${divergences.join('\n')}` : table);
1043
1382
  }
1044
- /**
1045
- * Enumerate the specs under `<projectRoot>/specs/` whose frontmatter carries a
1046
- * `jira_issue:` key (REQ-JIRAPUB-008). Best-effort filesystem scan — unreadable
1047
- * files are skipped, and an absent specs/ dir yields an empty list.
1048
- */
1049
- function enumeratePublishedSpecs(projectRoot) {
1050
- const specsDir = path.join(projectRoot, 'specs');
1051
- let entries = [];
1052
- try {
1053
- entries = fs.readdirSync(specsDir);
1054
- }
1055
- catch {
1056
- return [];
1057
- }
1058
- const out = [];
1059
- for (const name of entries) {
1060
- if (!name.toLowerCase().endsWith('.md'))
1061
- continue;
1062
- const full = path.join(specsDir, name);
1063
- try {
1064
- if (!fs.statSync(full).isFile())
1065
- continue;
1066
- const content = fs.readFileSync(full, 'utf8');
1067
- const issueKey = (0, publish_1.readFrontmatterValue)(content, 'jira_issue');
1068
- if (!issueKey)
1069
- continue;
1070
- out.push({
1071
- issueKey,
1072
- title: readSpecTitle(full, issueKey),
1073
- specRelPath: path.join('specs', name),
1074
- fingerprint: (0, publish_1.readFrontmatterValue)(content, 'jira_fingerprint'),
1075
- });
1076
- }
1077
- catch {
1078
- continue;
1079
- }
1080
- }
1081
- return out;
1082
- }
1083
1383
  /**
1084
1384
  * Handle `specship_jira_publish` (REQ-JIRAPUB-001, REQ-JIRAPUB-002): publish a
1085
1385
  * requirement spec as a Story + Sub-tasks, then write the JIRA identity back
@@ -1264,4 +1564,461 @@ async function handleSpecshipJiraAddTask(args, deps) {
1264
1564
  return errorResult('Failed to create the task in JIRA.');
1265
1565
  }
1266
1566
  }
1567
+ /**
1568
+ * Handle `specship_jira_regression_pack` (REQ-JIRAREG-001). Builds the model
1569
+ * from the loaded spec set, then upserts it against JIRA idempotently. A
1570
+ * dry-run planning path returns the counts without any JIRA write.
1571
+ */
1572
+ async function handleSpecshipJiraRegressionPack(args, deps) {
1573
+ const notConfigured = notConfiguredResult();
1574
+ if (notConfigured)
1575
+ return notConfigured;
1576
+ const dryRun = args.dry_run === true;
1577
+ const baseSq = deps.specQueries;
1578
+ const sq = {
1579
+ getAllSpecs: () => baseSq.getAllSpecs(),
1580
+ getSpecsByParent: (pid) => baseSq.getSpecsByParent(pid),
1581
+ getLinksBySpec: (sid) => baseSq.getLinksBySpec(sid),
1582
+ getSpecById: (id) => baseSq.getSpecById(id),
1583
+ ...(deps.getLinkedNodesForReq ? { getLinkedNodesForReq: deps.getLinkedNodesForReq } : {}),
1584
+ ...(deps.getNeighbourNodes ? { getNeighbourNodes: deps.getNeighbourNodes } : {}),
1585
+ };
1586
+ const model = (0, regression_pack_1.buildRegressionPack)(sq);
1587
+ if (model.cases.length === 0) {
1588
+ return textResult('No implemented (or verified) requirements found — the regression pack ' +
1589
+ 'has zero cases. Implement a spec and link its code before generating.');
1590
+ }
1591
+ try {
1592
+ const creds = (0, config_1.resolveJiraCredentials)();
1593
+ const projectKey = (typeof args.project === 'string' && args.project.trim()
1594
+ ? args.project.trim()
1595
+ : undefined) ?? creds.project;
1596
+ if (!projectKey) {
1597
+ return textResult('No JIRA project is configured. Pass `project: "PROJ"` or save a ' +
1598
+ 'default with `specship jira configure --project <Key>`.');
1599
+ }
1600
+ const client = deps.makeJiraClient
1601
+ ? deps.makeJiraClient()
1602
+ : new client_1.JiraClient(creds);
1603
+ const ctx = { projectKey, dryRun };
1604
+ const result = await (0, regression_pack_1.upsertRegressionPack)(client, model, ctx);
1605
+ // Back-link written cases into their spec files (REQ-JIRAREG-001.A3) —
1606
+ // spec-side traceability. Best-effort per case; a failure notes and
1607
+ // moves on so a partial back-link never blocks the pack write itself.
1608
+ const backlinkNotes = [];
1609
+ if (!dryRun) {
1610
+ const specPathById = {};
1611
+ for (const c of model.cases)
1612
+ specPathById[c.criterionId] = c.specPath;
1613
+ for (const [criterionId, issueKey] of Object.entries(result.caseKeysByCriterion)) {
1614
+ const rel = specPathById[criterionId];
1615
+ if (!rel)
1616
+ continue;
1617
+ const absPath = path.isAbsolute(rel) ? rel : path.join(deps.projectRoot, rel);
1618
+ const out = (0, spec_writer_1.writeRegressionCaseKeys)(absPath, criterionId, issueKey);
1619
+ if (!out.ok && out.detail)
1620
+ backlinkNotes.push(` · ${criterionId}: ${out.detail}`);
1621
+ }
1622
+ }
1623
+ const lines = [];
1624
+ lines.push(`${dryRun ? 'Dry-run plan for' : 'Regression Pack upserted in'} ${projectKey}:`);
1625
+ lines.push(`- Epic: ${result.epicKey ?? '(none)'}${result.epicCreated ? ' (created)' : ''}`);
1626
+ lines.push(`- Stories: ${result.storiesCreated} created, ${result.storiesUpdated} updated, ${result.storiesSkipped} skipped`);
1627
+ lines.push(`- Cases: ${result.casesCreated} created, ${result.casesUpdated} updated, ${result.casesSkipped} skipped (of ${model.cases.length})`);
1628
+ const xrefTotal = result.crossRefsCreated + result.crossRefsUpdated + result.crossRefsSkipped;
1629
+ if (xrefTotal > 0) {
1630
+ lines.push(`- Cross-refs: ${result.crossRefsCreated} created, ${result.crossRefsUpdated} updated, ${result.crossRefsSkipped} skipped`);
1631
+ }
1632
+ if (result.casesObsoleted > 0) {
1633
+ lines.push(`- Obsoleted: ${result.casesObsoleted} case${result.casesObsoleted === 1 ? '' : 's'} (${result.obsoletedCaseKeys.join(', ')}) — kept for run history, labelled specship-reg-obsolete.`);
1634
+ }
1635
+ if (result.orphanedEpicKeys.length > 0) {
1636
+ lines.push(`- Extra pack-epics found (single-epic invariant): ${result.orphanedEpicKeys.join(', ')} — merge or delete them manually.`);
1637
+ }
1638
+ if (backlinkNotes.length > 0) {
1639
+ lines.push('- Back-link notes:');
1640
+ lines.push(...backlinkNotes);
1641
+ }
1642
+ const gapReport = (0, regression_pack_1.renderDomainGapReport)(model.domainGaps);
1643
+ if (gapReport) {
1644
+ lines.push('');
1645
+ lines.push(gapReport);
1646
+ }
1647
+ const rephraseReport = (0, regression_pack_1.renderRephraseReport)(model.rephraseFlags);
1648
+ if (rephraseReport) {
1649
+ lines.push('');
1650
+ lines.push(rephraseReport);
1651
+ }
1652
+ return textResult(lines.join('\n'));
1653
+ }
1654
+ catch (err) {
1655
+ if (err instanceof types_1.JiraError)
1656
+ return errorResult(err.message);
1657
+ return errorResult(`Regression pack failed: ${err instanceof Error ? err.message : String(err)}`);
1658
+ }
1659
+ }
1660
+ /** Handle `specship_jira_regression_record` (REQ-JIRAREG-005). */
1661
+ async function handleSpecshipJiraRegressionRecord(args, deps) {
1662
+ const notConfigured = notConfiguredResult();
1663
+ if (notConfigured)
1664
+ return notConfigured;
1665
+ const caseKey = typeof args.case === 'string' ? args.case.trim() : '';
1666
+ const criterionId = typeof args.criterion === 'string' ? args.criterion.trim() : '';
1667
+ const status = typeof args.result === 'string' ? args.result.trim() : '';
1668
+ if (!caseKey || !criterionId || !status) {
1669
+ return errorResult('specship_jira_regression_record requires `case`, `criterion`, and `result`.');
1670
+ }
1671
+ const validStatuses = ['passed', 'failed', 'unexecuted'];
1672
+ if (!validStatuses.includes(status)) {
1673
+ return errorResult(`Unsupported result "${status}" — expected one of: ${validStatuses.join(', ')}.`);
1674
+ }
1675
+ const result = {
1676
+ caseKey,
1677
+ criterionId,
1678
+ status: status,
1679
+ executor: args.executor === 'agent' ? 'agent' : 'human',
1680
+ ...(typeof args.harness === 'string' && args.harness.trim()
1681
+ ? { harness: args.harness.trim() }
1682
+ : {}),
1683
+ ...(typeof args.run === 'string' && args.run.trim() ? { runId: args.run.trim() } : {}),
1684
+ ...(typeof args.log_ref === 'string' || typeof args.error === 'string'
1685
+ ? {
1686
+ evidence: {
1687
+ ...(typeof args.log_ref === 'string' ? { logRef: args.log_ref } : {}),
1688
+ ...(typeof args.error === 'string' ? { error: args.error } : {}),
1689
+ },
1690
+ }
1691
+ : {}),
1692
+ };
1693
+ try {
1694
+ const creds = (0, config_1.resolveJiraCredentials)();
1695
+ const client = deps.makeJiraClient
1696
+ ? deps.makeJiraClient()
1697
+ : new client_1.JiraClient(creds);
1698
+ const outcome = await (0, regression_pack_1.recordRunResult)({ client, specQueries: deps.specQueries }, result);
1699
+ const lines = [];
1700
+ lines.push(`${caseKey} recorded ${outcome.status} — comment ${outcome.comment}, transition ${outcome.transition}, validates-link ${outcome.linked ? 'updated' : 'untouched'}.`);
1701
+ if (outcome.triageHint)
1702
+ lines.push(outcome.triageHint);
1703
+ return textResult(lines.join('\n'));
1704
+ }
1705
+ catch (err) {
1706
+ if (err instanceof types_1.JiraError)
1707
+ return errorResult(err.message);
1708
+ return errorResult(`Regression record failed: ${err instanceof Error ? err.message : String(err)}`);
1709
+ }
1710
+ }
1711
+ /**
1712
+ * Handle `specship_jira_coverage` (REQ-JIRATEAM-004). Builds the sprint
1713
+ * coverage report, renders markdown, and optionally upserts a single
1714
+ * watermarked JIRA comment (A3) — never transitions or edits any issue (A4).
1715
+ *
1716
+ * SECURITY: no credential is echoed (REQ-JIRA-009); only public issue keys,
1717
+ * titles, and statuses appear in output.
1718
+ */
1719
+ async function handleSpecshipJiraCoverage(args, deps) {
1720
+ const notConfigured = notConfiguredResult();
1721
+ if (notConfigured)
1722
+ return notConfigured;
1723
+ const { buildCoverageReport, formatCoverageMarkdown, COVERAGE_COMMENT_WATERMARK } = await Promise.resolve().then(() => __importStar(require('../jira/coverage')));
1724
+ const { upsertWatermarkedComment } = await Promise.resolve().then(() => __importStar(require('../jira/publish')));
1725
+ const post = args.post === true;
1726
+ const issueKeyArg = typeof args.issue_key === 'string' && args.issue_key.trim()
1727
+ ? args.issue_key.trim()
1728
+ : undefined;
1729
+ if (post && !issueKeyArg) {
1730
+ return textResult('Posting the coverage report needs an anchor issue — pass "issue_key" (e.g., the epic key) alongside "post": true.');
1731
+ }
1732
+ const sprint = typeof args.sprint === 'string' && args.sprint.trim()
1733
+ ? args.sprint.trim()
1734
+ : undefined;
1735
+ let creds;
1736
+ try {
1737
+ creds = (0, config_1.resolveJiraCredentials)();
1738
+ }
1739
+ catch (err) {
1740
+ return errorResult(err instanceof Error ? err.message : String(err));
1741
+ }
1742
+ const project = (typeof args.project === 'string' && args.project.trim()
1743
+ ? args.project.trim()
1744
+ : undefined) ?? creds.project;
1745
+ if (!project) {
1746
+ return textResult('No JIRA project configured. Pass "project", or configure a default with `specship jira configure --project <KEY>`.');
1747
+ }
1748
+ const client = deps.makeJiraClient
1749
+ ? deps.makeJiraClient()
1750
+ : new client_1.JiraClient(creds);
1751
+ try {
1752
+ const report = await buildCoverageReport({
1753
+ client,
1754
+ projectRoot: deps.projectRoot,
1755
+ specQueries: deps.specQueries,
1756
+ project,
1757
+ sprint,
1758
+ });
1759
+ const markdown = formatCoverageMarkdown(report);
1760
+ if (!post)
1761
+ return textResult(markdown);
1762
+ // Post path (A3): the only write this handler makes — a single watermarked
1763
+ // comment, upserted in place on re-post. Never a transition/edit (A4).
1764
+ const commentClient = client;
1765
+ const result = await upsertWatermarkedComment(commentClient, issueKeyArg, COVERAGE_COMMENT_WATERMARK, markdown);
1766
+ const verb = result.action === 'updated' ? 'Updated existing' : 'Posted new';
1767
+ return textResult(`${markdown}\n\n> ${verb} coverage comment on ${issueKeyArg}.`);
1768
+ }
1769
+ catch (err) {
1770
+ if (err instanceof types_1.JiraError)
1771
+ return errorResult(err.message);
1772
+ return errorResult(`Failed to build the coverage report: ${err instanceof Error ? err.message : String(err)}`);
1773
+ }
1774
+ }
1775
+ /**
1776
+ * Load the requirement view a diff needs — id, title, body, acceptance — from
1777
+ * the SpecQueries handle. `requirementId` is the frontmatter/heading id (which
1778
+ * for a published spec is `reqIdForIssue(issueKey)`).
1779
+ */
1780
+ function loadSpecView(sq, requirementId, specRelPath) {
1781
+ const spec = sq.getSpecById?.(requirementId);
1782
+ if (!spec || spec.kind !== 'requirement')
1783
+ return null;
1784
+ const acceptance = (sq.getSpecsByParent?.(requirementId) ?? [])
1785
+ .filter((c) => c.kind === 'acceptance')
1786
+ .map((c) => ({ id: c.id, text: (c.title || c.body || '').trim() }))
1787
+ .filter((c) => c.text.length > 0);
1788
+ return {
1789
+ specRelPath,
1790
+ requirementId,
1791
+ title: spec.title,
1792
+ body: spec.body ?? '',
1793
+ acceptance,
1794
+ };
1795
+ }
1796
+ /** Format one report as a human-readable diff block. */
1797
+ function formatReconcileReport(report) {
1798
+ const lines = [];
1799
+ lines.push(`### ${report.issueKey} — ${report.specRelPath}`);
1800
+ if (report.content) {
1801
+ lines.push('');
1802
+ lines.push('**Content divergence (edited in JIRA after publish)**');
1803
+ lines.push('');
1804
+ lines.push(`- Live summary: ${report.content.liveSummary}`);
1805
+ lines.push(`- Live fingerprint: \`${report.content.liveFingerprint}\``);
1806
+ lines.push(`- Stored fingerprint: \`${report.content.storedFingerprint}\``);
1807
+ lines.push('');
1808
+ lines.push('_Live description:_');
1809
+ lines.push('');
1810
+ lines.push(report.content.liveDescription || '_(empty)_');
1811
+ }
1812
+ if (report.subtasks.length > 0) {
1813
+ lines.push('');
1814
+ lines.push('**Sub-tasks added in JIRA (proposed new acceptance criteria)**');
1815
+ lines.push('');
1816
+ lines.push('| Sub-task | Proposed id | Proposed criterion |');
1817
+ lines.push('| --- | --- | --- |');
1818
+ for (const d of report.subtasks) {
1819
+ lines.push(`| ${cell(d.subtaskKey)} | ${cell(d.proposedCriterionId)} | ${cell(d.proposedCriterionText)} |`);
1820
+ }
1821
+ }
1822
+ return lines.join('\n');
1823
+ }
1824
+ /**
1825
+ * Handle `specship_jira_reconcile` (REQ-JIRATEAM-005).
1826
+ *
1827
+ * Two modes:
1828
+ * - preview (default, A3): enumerate every published spec, live-read the
1829
+ * issue, diff, and return the proposed spec amendment. NO writes.
1830
+ * - apply (A4): fold the previewed diff into the spec file and re-publish so
1831
+ * the fingerprint refreshes. Preview-gated: apply refuses unless
1832
+ * `expected_live_fingerprint` matches the issue's current live fingerprint,
1833
+ * which the caller MUST have just received from a preview call. This
1834
+ * encodes the "preview first, then explicit user confirmation" discipline.
1835
+ */
1836
+ async function handleSpecshipJiraReconcile(args, deps) {
1837
+ const notConfigured = notConfiguredResult();
1838
+ if (notConfigured)
1839
+ return notConfigured;
1840
+ const { diffIssueVsSpec, reportHasDivergence } = await Promise.resolve().then(() => __importStar(require('../jira/reconcile')));
1841
+ const { appendAcceptanceCriterion, applyContentAmendment, amendSpecFile, } = await Promise.resolve().then(() => __importStar(require('../jira/spec-amend')));
1842
+ const mode = args.mode === 'apply' ? 'apply' : 'preview';
1843
+ const issueKeyArg = typeof args.issue_key === 'string' && args.issue_key.trim()
1844
+ ? args.issue_key.trim()
1845
+ : undefined;
1846
+ const published = (0, published_specs_1.enumeratePublishedSpecs)(deps.projectRoot);
1847
+ const scope = issueKeyArg
1848
+ ? published.filter((p) => p.issueKey === issueKeyArg)
1849
+ : published;
1850
+ if (scope.length === 0) {
1851
+ return textResult(issueKeyArg
1852
+ ? `No published spec found for ${issueKeyArg} — reconcile only applies to specs with a jira_issue: key.`
1853
+ : 'No published specs found — nothing to reconcile.');
1854
+ }
1855
+ let client;
1856
+ try {
1857
+ client = deps.makeJiraClient
1858
+ ? deps.makeJiraClient()
1859
+ : new client_1.JiraClient((0, config_1.resolveJiraCredentials)());
1860
+ }
1861
+ catch (err) {
1862
+ return errorResult(err instanceof Error ? err.message : String(err));
1863
+ }
1864
+ const sq = deps.specQueries;
1865
+ if (mode === 'apply') {
1866
+ if (!issueKeyArg) {
1867
+ return errorResult('apply mode needs "issue_key". Run preview first, then apply the confirmed diff for one issue.');
1868
+ }
1869
+ const pub = scope[0];
1870
+ let live;
1871
+ try {
1872
+ live = (await client.getIssue(issueKeyArg)).issue;
1873
+ }
1874
+ catch (err) {
1875
+ return errorResult(err instanceof Error ? err.message : String(err));
1876
+ }
1877
+ // Preview-first gate (A3): apply refuses unless the caller-supplied
1878
+ // `expected_live_fingerprint` matches the issue's current live
1879
+ // fingerprint. That value only exists if the caller just saw it in a
1880
+ // preview and the user confirmed it — no preview → no apply.
1881
+ const expectedFp = typeof args.expected_live_fingerprint === 'string'
1882
+ ? args.expected_live_fingerprint.trim()
1883
+ : '';
1884
+ const liveFp = (0, publish_1.issueContentFingerprint)(live.summary ?? '', live.description ?? '');
1885
+ if (!expectedFp || expectedFp !== liveFp) {
1886
+ return errorResult('apply refused: no matching preview. Run mode:"preview" for ' +
1887
+ `${issueKeyArg}, show the user the diff, and re-call apply with the ` +
1888
+ `expected_live_fingerprint from that preview (live fingerprint: ${liveFp}).`);
1889
+ }
1890
+ const requirementId = (0, spec_generator_1.reqIdForIssue)(issueKeyArg);
1891
+ const specView = loadSpecView(sq, requirementId, pub.specRelPath);
1892
+ if (!specView) {
1893
+ return errorResult(`Spec ${requirementId} not found in the index — run specship sync first.`);
1894
+ }
1895
+ const report = diffIssueVsSpec(live, specView, pub.fingerprint);
1896
+ const acceptContent = args.accept_content === true;
1897
+ const acceptSubtasks = Array.isArray(args.accept_subtasks)
1898
+ ? args.accept_subtasks.filter((k) => typeof k === 'string')
1899
+ : [];
1900
+ if (!acceptContent && acceptSubtasks.length === 0) {
1901
+ return errorResult('apply refused: nothing to accept. Pass accept_content: true and/or ' +
1902
+ 'accept_subtasks: [<Sub-task keys>] with the confirmed proposals.');
1903
+ }
1904
+ const notes = [];
1905
+ if (acceptContent) {
1906
+ if (!report.content) {
1907
+ notes.push('accept_content: true but no content divergence — skipped.');
1908
+ }
1909
+ else {
1910
+ const out = amendSpecFile(deps.projectRoot, pub.absPath, (src) => applyContentAmendment(src, requirementId, live.summary ?? '', live.description ?? ''));
1911
+ notes.push(out.detail);
1912
+ }
1913
+ }
1914
+ for (const stKey of acceptSubtasks) {
1915
+ const div = report.subtasks.find((d) => d.subtaskKey === stKey);
1916
+ if (!div) {
1917
+ notes.push(`${stKey}: no matching Sub-task divergence — skipped.`);
1918
+ continue;
1919
+ }
1920
+ const out = amendSpecFile(deps.projectRoot, pub.absPath, (src) => appendAcceptanceCriterion(src, requirementId, div.proposedCriterionId, div.proposedCriterionText));
1921
+ notes.push(`${stKey} → ${div.proposedCriterionId}: ${out.detail}`);
1922
+ }
1923
+ // Re-publish so the fingerprint refreshes and the next preview reports
1924
+ // no divergence. The publish source is built from what we JUST ACCEPTED
1925
+ // — the live summary/description (when accepted) and the appended
1926
+ // criteria — NOT from the SpecQueries index, which lags the file
1927
+ // write until the caller runs `specship sync`.
1928
+ try {
1929
+ const publishClient = deps.makePublishClient
1930
+ ? deps.makePublishClient()
1931
+ : client;
1932
+ const effectiveTitle = acceptContent ? live.summary ?? '' : specView.title;
1933
+ const effectiveBody = acceptContent ? live.description ?? '' : specView.body;
1934
+ const effectiveAcceptance = [
1935
+ ...specView.acceptance,
1936
+ ...report.subtasks
1937
+ .filter((d) => acceptSubtasks.includes(d.subtaskKey))
1938
+ .map((d) => ({ id: d.proposedCriterionId, text: d.proposedCriterionText })),
1939
+ ];
1940
+ const creds = (0, config_1.resolveJiraCredentials)();
1941
+ const projectKey = creds.project;
1942
+ if (projectKey) {
1943
+ const result = await (0, publish_1.publishSpecToJira)(publishClient, {
1944
+ specId: requirementId,
1945
+ title: effectiveTitle,
1946
+ body: effectiveBody,
1947
+ specRelPath: pub.specRelPath,
1948
+ acceptance: effectiveAcceptance,
1949
+ }, { projectKey }, issueKeyArg);
1950
+ (0, publish_1.writeBackJiraIdentity)(pub.absPath, issueKeyArg, {
1951
+ fingerprint: result.fingerprint,
1952
+ reId: null,
1953
+ renameTo: null,
1954
+ });
1955
+ notes.push(`re-published ${issueKeyArg} and refreshed the fingerprint.`);
1956
+ }
1957
+ else {
1958
+ notes.push('skipped re-publish: no default JIRA project configured — pass project via specship_jira_publish to refresh the fingerprint.');
1959
+ }
1960
+ }
1961
+ catch (err) {
1962
+ notes.push(`re-publish failed (spec amended on disk): ${err instanceof Error ? err.message : String(err)}`);
1963
+ }
1964
+ return textResult([
1965
+ `Applied ${issueKeyArg} → ${pub.specRelPath}:`,
1966
+ ...notes.map((n) => `- ${n}`),
1967
+ '',
1968
+ 'Run `specship sync` so the index reflects the spec edits.',
1969
+ ].join('\n'));
1970
+ }
1971
+ // preview mode (default): read + diff every published spec in scope.
1972
+ const blocks = [];
1973
+ const clean = [];
1974
+ for (const pub of scope) {
1975
+ let live;
1976
+ try {
1977
+ live = (await client.getIssue(pub.issueKey)).issue;
1978
+ }
1979
+ catch (err) {
1980
+ blocks.push(`### ${pub.issueKey} — ${pub.specRelPath}\n\n_JIRA read failed: ${err instanceof Error ? err.message : String(err)}_`);
1981
+ continue;
1982
+ }
1983
+ const requirementId = (0, spec_generator_1.reqIdForIssue)(pub.issueKey);
1984
+ const specView = loadSpecView(sq, requirementId, pub.specRelPath);
1985
+ if (!specView) {
1986
+ blocks.push(`### ${pub.issueKey} — ${pub.specRelPath}\n\n_Spec ${requirementId} not in the index — run specship sync._`);
1987
+ continue;
1988
+ }
1989
+ const report = diffIssueVsSpec(live, specView, pub.fingerprint);
1990
+ if (!reportHasDivergence(report)) {
1991
+ clean.push(pub.issueKey);
1992
+ continue;
1993
+ }
1994
+ const liveFp = (0, publish_1.issueContentFingerprint)(live.summary ?? '', live.description ?? '');
1995
+ const machine = '```json\n' +
1996
+ JSON.stringify({
1997
+ issue_key: pub.issueKey,
1998
+ spec_rel_path: pub.specRelPath,
1999
+ expected_live_fingerprint: liveFp,
2000
+ content: report.content
2001
+ ? { live_summary: report.content.liveSummary }
2002
+ : null,
2003
+ subtasks: report.subtasks.map((d) => ({
2004
+ subtask_key: d.subtaskKey,
2005
+ proposed_criterion_id: d.proposedCriterionId,
2006
+ proposed_criterion_text: d.proposedCriterionText,
2007
+ })),
2008
+ }, null, 2) +
2009
+ '\n```';
2010
+ blocks.push(`${formatReconcileReport(report)}\n\n${machine}`);
2011
+ }
2012
+ const header = blocks.length === 0
2013
+ ? 'No divergences — every published spec matches its JIRA issue.'
2014
+ : `${blocks.length} issue${blocks.length === 1 ? '' : 's'} diverged.`;
2015
+ const footer = blocks.length > 0
2016
+ ? '\n\n> Preview only — no file was modified. Show the diff to the user; only ' +
2017
+ 'after they explicitly confirm, call again with mode:"apply", issue_key, the ' +
2018
+ 'confirmed accept_content / accept_subtasks, and expected_live_fingerprint ' +
2019
+ 'from the JSON block above.'
2020
+ : '';
2021
+ const cleanNote = clean.length > 0 ? `\n\nIn sync: ${clean.join(', ')}.` : '';
2022
+ return textResult(`${header}\n\n${blocks.join('\n\n')}${cleanNote}${footer}`);
2023
+ }
1267
2024
  //# sourceMappingURL=jira-tools.js.map