create-quiver 0.12.1 → 0.13.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 (79) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +16 -8
  3. package/README_FOR_AI.md +11 -6
  4. package/ROADMAP.md +9 -2
  5. package/docs/COMMANDS.md.template +9 -2
  6. package/package.json +2 -1
  7. package/specs/quiver-v26-0121-smoke-hardening/SPEC.md +2 -2
  8. package/specs/quiver-v26-0121-smoke-hardening/STATUS.md +5 -5
  9. package/specs/quiver-v27-reliability-ai-workflow-hardening/AUDIT_V24_V25_V26.md +67 -0
  10. package/specs/quiver-v27-reliability-ai-workflow-hardening/COMMAND_CONTRACTS.md +125 -0
  11. package/specs/quiver-v27-reliability-ai-workflow-hardening/COVERAGE_MATRIX.md +74 -0
  12. package/specs/quiver-v27-reliability-ai-workflow-hardening/EVIDENCE_REPORT.md +179 -0
  13. package/specs/quiver-v27-reliability-ai-workflow-hardening/EXECUTION_PLAN.md +71 -0
  14. package/specs/quiver-v27-reliability-ai-workflow-hardening/SPEC.md +176 -0
  15. package/specs/quiver-v27-reliability-ai-workflow-hardening/STATUS.md +37 -0
  16. package/specs/quiver-v27-reliability-ai-workflow-hardening/pr.md +132 -0
  17. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-00-docs-audit-coverage-and-contracts/CLOSURE_BRIEF.md +36 -0
  18. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-00-docs-audit-coverage-and-contracts/EXECUTION_BRIEF.md +56 -0
  19. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-00-docs-audit-coverage-and-contracts/slice.json +75 -0
  20. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-01-core-state-resolver-and-canonical-statuses/CLOSURE_BRIEF.md +37 -0
  21. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-01-core-state-resolver-and-canonical-statuses/EXECUTION_BRIEF.md +54 -0
  22. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-01-core-state-resolver-and-canonical-statuses/slice.json +79 -0
  23. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-02-json-export-contract-and-machine-output/CLOSURE_BRIEF.md +34 -0
  24. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-02-json-export-contract-and-machine-output/EXECUTION_BRIEF.md +54 -0
  25. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-02-json-export-contract-and-machine-output/slice.json +75 -0
  26. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-03-approved-plan-to-spec-create/CLOSURE_BRIEF.md +36 -0
  27. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-03-approved-plan-to-spec-create/EXECUTION_BRIEF.md +55 -0
  28. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-03-approved-plan-to-spec-create/slice.json +78 -0
  29. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-04-ai-artifact-storage-redaction-and-token-compaction/CLOSURE_BRIEF.md +31 -0
  30. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-04-ai-artifact-storage-redaction-and-token-compaction/EXECUTION_BRIEF.md +55 -0
  31. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-04-ai-artifact-storage-redaction-and-token-compaction/slice.json +77 -0
  32. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-05-worktree-lifecycle-locks-and-recovery/CLOSURE_BRIEF.md +31 -0
  33. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-05-worktree-lifecycle-locks-and-recovery/EXECUTION_BRIEF.md +55 -0
  34. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-05-worktree-lifecycle-locks-and-recovery/slice.json +84 -0
  35. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-06-validation-gates-and-scope-safety/CLOSURE_BRIEF.md +32 -0
  36. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-06-validation-gates-and-scope-safety/EXECUTION_BRIEF.md +57 -0
  37. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-06-validation-gates-and-scope-safety/slice.json +99 -0
  38. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-07-context-analysis-and-doctor-flow/CLOSURE_BRIEF.md +31 -0
  39. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-07-context-analysis-and-doctor-flow/EXECUTION_BRIEF.md +57 -0
  40. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-07-context-analysis-and-doctor-flow/slice.json +88 -0
  41. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-08-cross-platform-help-auth-and-dx/CLOSURE_BRIEF.md +31 -0
  42. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-08-cross-platform-help-auth-and-dx/EXECUTION_BRIEF.md +56 -0
  43. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-08-cross-platform-help-auth-and-dx/slice.json +85 -0
  44. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-09-fixtures-smoke-docs-and-release-readiness/CLOSURE_BRIEF.md +32 -0
  45. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-09-fixtures-smoke-docs-and-release-readiness/EXECUTION_BRIEF.md +56 -0
  46. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-09-fixtures-smoke-docs-and-release-readiness/slice.json +91 -0
  47. package/src/create-quiver/commands/ai.js +84 -9
  48. package/src/create-quiver/commands/flow.js +52 -4
  49. package/src/create-quiver/commands/graph.js +7 -7
  50. package/src/create-quiver/commands/plan.js +6 -15
  51. package/src/create-quiver/commands/spec.js +282 -0
  52. package/src/create-quiver/index.js +83 -21
  53. package/src/create-quiver/lib/agent-profiles.js +15 -3
  54. package/src/create-quiver/lib/ai/artifacts.js +318 -0
  55. package/src/create-quiver/lib/ai/execution-plan.js +9 -0
  56. package/src/create-quiver/lib/ai/executor.js +3 -2
  57. package/src/create-quiver/lib/ai/export-state.js +242 -97
  58. package/src/create-quiver/lib/ai/github.js +80 -3
  59. package/src/create-quiver/lib/ai/plan-review.js +2 -0
  60. package/src/create-quiver/lib/ai/spec-generator.js +72 -13
  61. package/src/create-quiver/lib/ai/spec-templates.js +72 -12
  62. package/src/create-quiver/lib/analyze.js +2 -2
  63. package/src/create-quiver/lib/approvals.js +14 -2
  64. package/src/create-quiver/lib/doctor.js +79 -0
  65. package/src/create-quiver/lib/git.js +40 -1
  66. package/src/create-quiver/lib/handoff.js +43 -1
  67. package/src/create-quiver/lib/init-docs.js +11 -7
  68. package/src/create-quiver/lib/init-layout.js +1 -0
  69. package/src/create-quiver/lib/lifecycle.js +52 -3
  70. package/src/create-quiver/lib/locks.js +134 -0
  71. package/src/create-quiver/lib/package-safety.js +7 -0
  72. package/src/create-quiver/lib/paths.js +74 -0
  73. package/src/create-quiver/lib/project-scan.js +74 -0
  74. package/src/create-quiver/lib/project-state-resolver.js +236 -0
  75. package/src/create-quiver/lib/readiness.js +48 -7
  76. package/src/create-quiver/lib/scope.js +2 -1
  77. package/src/create-quiver/lib/slice.js +8 -4
  78. package/src/create-quiver/lib/spec-worktrees.js +121 -38
  79. package/src/create-quiver/lib/statuses.js +115 -0
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { parseJsonWithComments } = require('./json');
4
- const { normalizeGitBashDrivePath, relativePosixPath, resolveTargetRoot, specRelativePathFromPath, toPosixPath } = require('./paths');
4
+ const { assertPathInsideRoot, normalizeGitBashDrivePath, relativePosixPath, resolveTargetRoot, specRelativePathFromPath, toPosixPath } = require('./paths');
5
5
 
6
6
  function readJson(filePath) {
7
7
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -89,9 +89,12 @@ function validateSliceMetaForStart(slice) {
89
89
  throw new Error(`create-quiver: git.branch_type invalido: "${slice.branchType}". Usa "feature", "bugfix" o "hotfix".`);
90
90
  }
91
91
 
92
- const allowedBaseBranches = allowedBaseByType[slice.branchType];
93
- if (!allowedBaseBranches.includes(slice.baseBranch)) {
94
- throw new Error(`create-quiver: git.base_branch invalido para ${slice.branchType}. Usa "${allowedBaseBranches.join('" o "')}".`);
92
+ if (!/^[A-Za-z0-9._/-]+$/.test(slice.baseBranch)
93
+ || slice.baseBranch.includes('..')
94
+ || slice.baseBranch.startsWith('/')
95
+ || slice.baseBranch.endsWith('/')
96
+ || slice.baseBranch.includes('\\')) {
97
+ throw new Error('create-quiver: git.base_branch invalido. Usa una rama base valida como "main", "develop", "master" o "release/2026".');
95
98
  }
96
99
 
97
100
  const expectedBranchName = `${slice.branchType}/${slice.ticket}-${slice.branchSlug}`;
@@ -116,6 +119,7 @@ function resolveRepoSlicePath(repoRoot, relSlicePath) {
116
119
  function resolveSliceContext(repoRoot, slicePath) {
117
120
  const canonicalRepoRoot = canonicalizePath(repoRoot);
118
121
  let absSlicePath = resolveSlicePath(slicePath);
122
+ assertPathInsideRoot(canonicalRepoRoot, absSlicePath, 'slice path');
119
123
  let relSlicePath = relativePosixPath(canonicalRepoRoot, absSlicePath);
120
124
  let parts = relSlicePath.split('/');
121
125
 
@@ -6,6 +6,8 @@ const {
6
6
  fetchRemote,
7
7
  hasLocalBranch,
8
8
  hasRemoteBranch,
9
+ isGitWorktree,
10
+ isLinkedWorktree,
9
11
  isCleanWorktree,
10
12
  lsRemoteHeads,
11
13
  mergeBaseIsAncestor,
@@ -17,6 +19,7 @@ const {
17
19
  worktreeRemove,
18
20
  } = require('./git');
19
21
  const { parseJsonWithComments } = require('./json');
22
+ const { acquireLock, releaseLock, withLockSync } = require('./locks');
20
23
  const { safeBranchName, worktreesRootForRepo } = require('./slice');
21
24
 
22
25
  function formatError(message) {
@@ -88,6 +91,47 @@ function findExistingWorktree(repoRoot, branchName) {
88
91
  return '';
89
92
  }
90
93
 
94
+ function sameRealPath(left, right) {
95
+ try {
96
+ return fs.realpathSync(left) === fs.realpathSync(right);
97
+ } catch {
98
+ return path.resolve(left) === path.resolve(right);
99
+ }
100
+ }
101
+
102
+ function recoveryForMissingWorktree(branchName, worktreePath) {
103
+ return [
104
+ `registered spec worktree is missing or stale for ${branchName}: ${worktreePath}`,
105
+ 'Recovery:',
106
+ '- Run `git worktree prune` from the main checkout, then retry `npx create-quiver spec start specs/<spec-slug>`.',
107
+ '- If the directory was moved manually, restore it or remove the stale git worktree registration intentionally.',
108
+ '- Do not create a nested replacement worktree from inside another worktree.',
109
+ ].join('\n');
110
+ }
111
+
112
+ function recoveryForNestedWorktree(branchName, existingWorktree = '') {
113
+ return [
114
+ `refusing to create a spec worktree from inside a linked worktree for ${branchName}.`,
115
+ 'Recovery:',
116
+ existingWorktree
117
+ ? `- Use the existing spec worktree: ${existingWorktree}`
118
+ : '- Return to the main checkout and rerun the command.',
119
+ '- This prevents nested .worktrees paths and conflicting persistent spec worktrees.',
120
+ ].join('\n');
121
+ }
122
+
123
+ function assertExistingWorktreeUsable(branchName, worktreePath) {
124
+ if (!worktreePath) {
125
+ return;
126
+ }
127
+ if (!fs.existsSync(worktreePath) || !isGitWorktree(worktreePath)) {
128
+ throw new Error(formatError(recoveryForMissingWorktree(branchName, worktreePath)));
129
+ }
130
+ if (!isCleanWorktree(worktreePath)) {
131
+ throw new Error(formatError(`existing spec worktree is dirty: ${worktreePath}\nRecovery:\n- Commit or stash changes inside the spec worktree.\n- Then rerun the command.`));
132
+ }
133
+ }
134
+
91
135
  function resolveBaseRef(repoRoot, preferred = '') {
92
136
  const candidates = [preferred, 'main', 'develop'].filter(Boolean);
93
137
  for (const candidate of candidates) {
@@ -133,7 +177,8 @@ function buildSpecStatus(repoRoot, specInput) {
133
177
  const pendingSlices = slices.filter((slice) => slice.status !== 'completed');
134
178
  const laterSlicesBlocked = !slice00 || slice00.status !== 'completed';
135
179
  const existingWorktree = findExistingWorktree(repoRoot, identity.branchName);
136
- const worktreeDirty = existingWorktree ? !isCleanWorktree(existingWorktree) : false;
180
+ const worktreeMissing = Boolean(existingWorktree && (!fs.existsSync(existingWorktree) || !isGitWorktree(existingWorktree)));
181
+ const worktreeDirty = existingWorktree && !worktreeMissing ? !isCleanWorktree(existingWorktree) : false;
137
182
 
138
183
  return {
139
184
  ...identity,
@@ -144,6 +189,7 @@ function buildSpecStatus(repoRoot, specInput) {
144
189
  slices,
145
190
  specDir,
146
191
  worktreeDirty,
192
+ worktreeMissing,
147
193
  };
148
194
  }
149
195
 
@@ -153,6 +199,7 @@ function formatSpecStatus(status) {
153
199
  `Spec: ${status.relativeSpecDir}`,
154
200
  `Branch: ${status.branchName}`,
155
201
  `Worktree: ${status.existingWorktree || status.worktreePath}`,
202
+ `Worktree missing/stale: ${status.worktreeMissing ? 'yes' : 'no'}`,
156
203
  `Worktree dirty: ${status.worktreeDirty ? 'yes' : 'no'}`,
157
204
  `slice-00: ${status.slice00 ? status.slice00.status : 'missing'}`,
158
205
  `Later slices blocked: ${status.laterSlicesBlocked ? 'yes' : 'no'}`,
@@ -173,61 +220,82 @@ function formatSpecStatus(status) {
173
220
  function startSpecWorktree(repoRoot, specInput, options = {}) {
174
221
  const specDir = findSpecDir(repoRoot, specInput);
175
222
  const identity = resolveSpecIdentity(repoRoot, specDir);
176
- const existingWorktree = findExistingWorktree(repoRoot, identity.branchName);
177
223
  const slices = listSpecSlices(specDir);
178
224
  const slice00 = slices.find((slice) => slice.id.startsWith('slice-00')) || null;
179
225
  const baseRef = resolveBaseRef(repoRoot, options.baseBranch);
180
226
 
181
- if (existingWorktree) {
182
- if (!isCleanWorktree(existingWorktree)) {
183
- throw new Error(formatError(`existing spec worktree is dirty: ${existingWorktree}`));
227
+ const run = () => {
228
+ const existingWorktree = findExistingWorktree(repoRoot, identity.branchName);
229
+ const currentIsLinkedWorktree = isLinkedWorktree(repoRoot);
230
+
231
+ if (existingWorktree) {
232
+ assertExistingWorktreeUsable(identity.branchName, existingWorktree);
233
+ if (currentIsLinkedWorktree && !sameRealPath(repoRoot, existingWorktree)) {
234
+ throw new Error(formatError(recoveryForNestedWorktree(identity.branchName, existingWorktree)));
235
+ }
236
+ return {
237
+ ...identity,
238
+ baseRef,
239
+ dryRun: options.dryRun === true,
240
+ reused: true,
241
+ slice00,
242
+ worktreePath: existingWorktree,
243
+ };
184
244
  }
185
- return {
186
- ...identity,
187
- baseRef,
188
- dryRun: options.dryRun === true,
189
- reused: true,
190
- slice00,
191
- worktreePath: existingWorktree,
192
- };
193
- }
194
245
 
195
- if (fs.existsSync(identity.worktreePath)) {
196
- throw new Error(formatError(`worktree path already exists and is not registered for ${identity.branchName}: ${identity.worktreePath}`));
197
- }
246
+ if (currentIsLinkedWorktree) {
247
+ throw new Error(formatError(recoveryForNestedWorktree(identity.branchName)));
248
+ }
198
249
 
199
- if (!isCleanWorktree(repoRoot)) {
200
- throw new Error(formatError('current checkout is not clean. Commit or stash before starting a spec worktree.'));
201
- }
250
+ if (fs.existsSync(identity.worktreePath)) {
251
+ throw new Error(formatError(`worktree path already exists and is not registered for ${identity.branchName}: ${identity.worktreePath}\nRecovery:\n- Inspect the path and move or remove it intentionally before rerunning.\n- Run \`git worktree list\` to verify registered worktrees.`));
252
+ }
253
+
254
+ if (!isCleanWorktree(repoRoot)) {
255
+ throw new Error(formatError('current checkout is not clean. Commit or stash before starting a spec worktree.'));
256
+ }
257
+
258
+ if (options.dryRun === true) {
259
+ return {
260
+ ...identity,
261
+ baseRef,
262
+ currentBranch: currentBranch(repoRoot),
263
+ dryRun: true,
264
+ reused: false,
265
+ slice00,
266
+ };
267
+ }
268
+
269
+ worktreePrune(repoRoot);
270
+ fs.mkdirSync(path.dirname(identity.worktreePath), { recursive: true });
271
+
272
+ if (hasLocalBranch(repoRoot, identity.branchName)) {
273
+ worktreeAdd(repoRoot, identity.worktreePath, identity.branchName);
274
+ } else {
275
+ worktreeAdd(repoRoot, identity.worktreePath, baseRef, { branch: identity.branchName });
276
+ }
202
277
 
203
- if (options.dryRun === true) {
204
278
  return {
205
279
  ...identity,
206
280
  baseRef,
207
281
  currentBranch: currentBranch(repoRoot),
208
- dryRun: true,
282
+ dryRun: false,
209
283
  reused: false,
210
284
  slice00,
211
285
  };
212
- }
213
-
214
- worktreePrune(repoRoot);
215
- fs.mkdirSync(path.dirname(identity.worktreePath), { recursive: true });
286
+ };
216
287
 
217
- if (hasLocalBranch(repoRoot, identity.branchName)) {
218
- worktreeAdd(repoRoot, identity.worktreePath, identity.branchName);
219
- } else {
220
- worktreeAdd(repoRoot, identity.worktreePath, baseRef, { branch: identity.branchName });
288
+ if (options.dryRun === true) {
289
+ return run();
221
290
  }
222
291
 
223
- return {
224
- ...identity,
225
- baseRef,
226
- currentBranch: currentBranch(repoRoot),
227
- dryRun: false,
228
- reused: false,
229
- slice00,
230
- };
292
+ return withLockSync(repoRoot, `spec-worktree-${identity.specSlug}`, {
293
+ command: 'spec start',
294
+ metadata: {
295
+ branch: identity.branchName,
296
+ spec: identity.relativeSpecDir,
297
+ },
298
+ }, run);
231
299
  }
232
300
 
233
301
  function formatSpecStartResult(result) {
@@ -245,6 +313,14 @@ function formatSpecStartResult(result) {
245
313
  function closeSpecWorktree(repoRoot, specInput, options = {}) {
246
314
  const specDir = findSpecDir(repoRoot, specInput);
247
315
  const identity = resolveSpecIdentity(repoRoot, specDir);
316
+ const lock = options.dryRun === true ? null : acquireLock(repoRoot, `spec-worktree-${identity.specSlug}`, {
317
+ command: 'spec close',
318
+ metadata: {
319
+ branch: identity.branchName,
320
+ spec: identity.relativeSpecDir,
321
+ },
322
+ });
323
+ try {
248
324
  const existingWorktree = findExistingWorktree(repoRoot, identity.branchName);
249
325
  const discard = options.discard === true;
250
326
  const dryRun = options.dryRun === true;
@@ -256,6 +332,10 @@ function closeSpecWorktree(repoRoot, specInput, options = {}) {
256
332
  throw new Error(formatError(`missing spec worktree for branch ${identity.branchName}.`));
257
333
  }
258
334
 
335
+ if (!fs.existsSync(existingWorktree) || !isGitWorktree(existingWorktree)) {
336
+ throw new Error(formatError(recoveryForMissingWorktree(identity.branchName, existingWorktree)));
337
+ }
338
+
259
339
  if (!discard && !isCleanWorktree(existingWorktree)) {
260
340
  throw new Error(formatError(`spec worktree is dirty: ${existingWorktree}. Commit or stash before closing, or pass --discard intentionally.`));
261
341
  }
@@ -310,6 +390,9 @@ function closeSpecWorktree(repoRoot, specInput, options = {}) {
310
390
  removed: true,
311
391
  worktreePath: existingWorktree,
312
392
  };
393
+ } finally {
394
+ releaseLock(lock);
395
+ }
313
396
  }
314
397
 
315
398
  function formatSpecCloseResult(result) {
@@ -0,0 +1,115 @@
1
+ const CANONICAL_STATUSES = Object.freeze({
2
+ spec: Object.freeze(['draft', 'planned', 'approved', 'in-progress', 'blocked', 'review', 'done', 'archived']),
3
+ slice: Object.freeze(['planned', 'ready', 'in-progress', 'blocked', 'review', 'completed', 'skipped']),
4
+ run: Object.freeze(['draft', 'waiting-approval', 'approved', 'running', 'blocked', 'done', 'failed']),
5
+ approval: Object.freeze(['pending', 'approved', 'rejected', 'superseded']),
6
+ agent: Object.freeze(['idle', 'planning', 'reading', 'coding', 'reviewing', 'blocked', 'waiting-approval', 'done']),
7
+ dataset: Object.freeze(['ready', 'partial', 'empty', 'error']),
8
+ });
9
+
10
+ const STATUS_ALIASES = Object.freeze({
11
+ spec: Object.freeze({
12
+ active: 'in-progress',
13
+ complete: 'done',
14
+ completed: 'done',
15
+ closed: 'done',
16
+ done: 'done',
17
+ in_progress: 'in-progress',
18
+ pending: 'planned',
19
+ }),
20
+ slice: Object.freeze({
21
+ active: 'in-progress',
22
+ cancelled: 'skipped',
23
+ canceled: 'skipped',
24
+ closed: 'completed',
25
+ complete: 'completed',
26
+ done: 'completed',
27
+ draft: 'planned',
28
+ in_progress: 'in-progress',
29
+ pending: 'planned',
30
+ }),
31
+ run: Object.freeze({
32
+ active: 'running',
33
+ closed: 'done',
34
+ complete: 'done',
35
+ completed: 'done',
36
+ in_progress: 'running',
37
+ pending: 'draft',
38
+ stale: 'draft',
39
+ }),
40
+ approval: Object.freeze({
41
+ draft: 'pending',
42
+ review: 'pending',
43
+ reviewed: 'pending',
44
+ stale: 'pending',
45
+ unapproved: 'pending',
46
+ }),
47
+ agent: Object.freeze({
48
+ active: 'coding',
49
+ complete: 'done',
50
+ completed: 'done',
51
+ in_progress: 'coding',
52
+ waiting_approval: 'waiting-approval',
53
+ }),
54
+ dataset: Object.freeze({
55
+ ok: 'ready',
56
+ warning: 'partial',
57
+ missing: 'empty',
58
+ failed: 'error',
59
+ }),
60
+ });
61
+
62
+ function normalizeStatusToken(value) {
63
+ return String(value || '')
64
+ .trim()
65
+ .toLowerCase()
66
+ .replace(/\s+/g, '-')
67
+ .replace(/_/g, '-');
68
+ }
69
+
70
+ function normalizeStatus(kind, status, fallback = 'planned') {
71
+ const family = String(kind || '').trim().toLowerCase();
72
+ const catalog = CANONICAL_STATUSES[family];
73
+ if (!catalog) {
74
+ return normalizeStatusToken(status) || normalizeStatusToken(fallback);
75
+ }
76
+
77
+ const normalized = normalizeStatusToken(status) || normalizeStatusToken(fallback);
78
+ const aliasKey = normalized.replace(/-/g, '_');
79
+ const aliases = STATUS_ALIASES[family] || {};
80
+ const canonical = aliases[normalized] || aliases[aliasKey] || normalized;
81
+
82
+ if (catalog.includes(canonical)) {
83
+ return canonical;
84
+ }
85
+
86
+ const fallbackStatus = normalizeStatusToken(fallback);
87
+ return catalog.includes(fallbackStatus) ? fallbackStatus : catalog[0];
88
+ }
89
+
90
+ function isCompletedStatus(kind, status) {
91
+ const family = String(kind || '').trim().toLowerCase();
92
+ const canonical = normalizeStatus(family, status, family === 'slice' ? 'planned' : 'draft');
93
+
94
+ if (family === 'slice') {
95
+ return canonical === 'completed';
96
+ }
97
+
98
+ if (family === 'spec' || family === 'run' || family === 'agent') {
99
+ return canonical === 'done';
100
+ }
101
+
102
+ return canonical === 'approved';
103
+ }
104
+
105
+ function isBlockedStatus(kind, status, record = {}) {
106
+ return normalizeStatus(kind, status, 'planned') === 'blocked' || Boolean(record?.blocked_reason || record?.json?.blocked_reason);
107
+ }
108
+
109
+ module.exports = {
110
+ CANONICAL_STATUSES,
111
+ isBlockedStatus,
112
+ isCompletedStatus,
113
+ normalizeStatus,
114
+ };
115
+