create-quiver 0.9.0 → 0.10.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 (111) hide show
  1. package/README.md +312 -124
  2. package/README_FOR_AI.md +59 -45
  3. package/ROADMAP.md +12 -11
  4. package/docs/AI_ONBOARDING_PROMPT.md.template +120 -52
  5. package/docs/COMMANDS.md.template +41 -6
  6. package/docs/GITFLOW_PR_GUIDE.md.template +11 -0
  7. package/docs/STANDARD.md.template +1 -1
  8. package/docs/SUPPORT_MATRIX.md.template +4 -0
  9. package/docs/TROUBLESHOOTING.md.template +29 -1
  10. package/docs/WORKFLOW.md.template +1 -1
  11. package/package.json +6 -1
  12. package/package.template.json +11 -6
  13. package/scripts/check-pr-readiness.sh +1 -1
  14. package/scripts/check-scope.sh +0 -1
  15. package/scripts/check-slice-readiness.sh +3 -4
  16. package/scripts/init-docs.sh +55 -9
  17. package/specs/quiver-v19-self-install-dev-dep/EVIDENCE_REPORT.md +2 -2
  18. package/specs/quiver-v19-self-install-dev-dep/STATUS.md +4 -4
  19. package/specs/quiver-v19-self-install-dev-dep/slices/slice-01-auto-install-dev-dep/slice.json +4 -4
  20. package/specs/quiver-v20-ai-cli-orchestration/EVIDENCE_REPORT.md +23 -0
  21. package/specs/quiver-v20-ai-cli-orchestration/EXECUTION_PLAN.md +57 -0
  22. package/specs/quiver-v20-ai-cli-orchestration/SPEC.md +202 -0
  23. package/specs/quiver-v20-ai-cli-orchestration/STATUS.md +35 -0
  24. package/specs/quiver-v20-ai-cli-orchestration/pr.md +100 -0
  25. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-00-spec-foundation/CLOSURE_BRIEF.md +30 -0
  26. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-00-spec-foundation/EXECUTION_BRIEF.md +61 -0
  27. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-00-spec-foundation/slice.json +54 -0
  28. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-01-ai-provider-runner/CLOSURE_BRIEF.md +39 -0
  29. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-01-ai-provider-runner/EXECUTION_BRIEF.md +63 -0
  30. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-01-ai-provider-runner/slice.json +55 -0
  31. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-02-context-packs-token-budget/CLOSURE_BRIEF.md +40 -0
  32. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-02-context-packs-token-budget/EXECUTION_BRIEF.md +60 -0
  33. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-02-context-packs-token-budget/slice.json +54 -0
  34. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-03-ai-phase-gated-planner/CLOSURE_BRIEF.md +43 -0
  35. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-03-ai-phase-gated-planner/EXECUTION_BRIEF.md +62 -0
  36. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-03-ai-phase-gated-planner/slice.json +62 -0
  37. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-04-spec-slice-handoff-pr-generation/CLOSURE_BRIEF.md +36 -0
  38. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-04-spec-slice-handoff-pr-generation/EXECUTION_BRIEF.md +63 -0
  39. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-04-spec-slice-handoff-pr-generation/slice.json +59 -0
  40. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-05-execution-plan-parallel-worktrees/CLOSURE_BRIEF.md +32 -0
  41. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-05-execution-plan-parallel-worktrees/EXECUTION_BRIEF.md +61 -0
  42. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-05-execution-plan-parallel-worktrees/slice.json +59 -0
  43. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-06-ai-execute-slice-scope-enforcement/CLOSURE_BRIEF.md +36 -0
  44. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-06-ai-execute-slice-scope-enforcement/EXECUTION_BRIEF.md +64 -0
  45. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-06-ai-execute-slice-scope-enforcement/slice.json +65 -0
  46. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-07-github-pr-preflight/CLOSURE_BRIEF.md +36 -0
  47. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-07-github-pr-preflight/EXECUTION_BRIEF.md +66 -0
  48. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-07-github-pr-preflight/slice.json +63 -0
  49. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-08-docs-smokes-release-readiness/CLOSURE_BRIEF.md +35 -0
  50. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-08-docs-smokes-release-readiness/EXECUTION_BRIEF.md +64 -0
  51. package/specs/quiver-v20-ai-cli-orchestration/slices/slice-08-docs-smokes-release-readiness/slice.json +77 -0
  52. package/specs/quiver-v21-ai-first-layout/EVIDENCE_REPORT.md +31 -0
  53. package/specs/quiver-v21-ai-first-layout/EXECUTION_PLAN.md +185 -0
  54. package/specs/quiver-v21-ai-first-layout/SPEC.md +212 -0
  55. package/specs/quiver-v21-ai-first-layout/STATUS.md +37 -0
  56. package/specs/quiver-v21-ai-first-layout/pr.md +110 -0
  57. package/specs/quiver-v21-ai-first-layout/slices/slice-00-spec-foundation/CLOSURE_BRIEF.md +30 -0
  58. package/specs/quiver-v21-ai-first-layout/slices/slice-00-spec-foundation/EXECUTION_BRIEF.md +63 -0
  59. package/specs/quiver-v21-ai-first-layout/slices/slice-00-spec-foundation/slice.json +45 -0
  60. package/specs/quiver-v21-ai-first-layout/slices/slice-01-init-profiles-dry-run/CLOSURE_BRIEF.md +31 -0
  61. package/specs/quiver-v21-ai-first-layout/slices/slice-01-init-profiles-dry-run/EXECUTION_BRIEF.md +59 -0
  62. package/specs/quiver-v21-ai-first-layout/slices/slice-01-init-profiles-dry-run/slice.json +57 -0
  63. package/specs/quiver-v21-ai-first-layout/slices/slice-02-internal-layout-template-resolver/CLOSURE_BRIEF.md +32 -0
  64. package/specs/quiver-v21-ai-first-layout/slices/slice-02-internal-layout-template-resolver/EXECUTION_BRIEF.md +60 -0
  65. package/specs/quiver-v21-ai-first-layout/slices/slice-02-internal-layout-template-resolver/slice.json +58 -0
  66. package/specs/quiver-v21-ai-first-layout/slices/slice-03-generation-profiles-visible-contract/CLOSURE_BRIEF.md +34 -0
  67. package/specs/quiver-v21-ai-first-layout/slices/slice-03-generation-profiles-visible-contract/EXECUTION_BRIEF.md +61 -0
  68. package/specs/quiver-v21-ai-first-layout/slices/slice-03-generation-profiles-visible-contract/slice.json +64 -0
  69. package/specs/quiver-v21-ai-first-layout/slices/slice-04-analyze-scan-relocation/CLOSURE_BRIEF.md +32 -0
  70. package/specs/quiver-v21-ai-first-layout/slices/slice-04-analyze-scan-relocation/EXECUTION_BRIEF.md +58 -0
  71. package/specs/quiver-v21-ai-first-layout/slices/slice-04-analyze-scan-relocation/slice.json +64 -0
  72. package/specs/quiver-v21-ai-first-layout/slices/slice-05-empty-specs-layout-doctor/CLOSURE_BRIEF.md +32 -0
  73. package/specs/quiver-v21-ai-first-layout/slices/slice-05-empty-specs-layout-doctor/EXECUTION_BRIEF.md +60 -0
  74. package/specs/quiver-v21-ai-first-layout/slices/slice-05-empty-specs-layout-doctor/slice.json +65 -0
  75. package/specs/quiver-v21-ai-first-layout/slices/slice-06-legacy-migration-optional-assets/CLOSURE_BRIEF.md +31 -0
  76. package/specs/quiver-v21-ai-first-layout/slices/slice-06-legacy-migration-optional-assets/EXECUTION_BRIEF.md +62 -0
  77. package/specs/quiver-v21-ai-first-layout/slices/slice-06-legacy-migration-optional-assets/slice.json +66 -0
  78. package/specs/quiver-v21-ai-first-layout/slices/slice-07-docs-guidance-alignment/CLOSURE_BRIEF.md +33 -0
  79. package/specs/quiver-v21-ai-first-layout/slices/slice-07-docs-guidance-alignment/EXECUTION_BRIEF.md +61 -0
  80. package/specs/quiver-v21-ai-first-layout/slices/slice-07-docs-guidance-alignment/slice.json +67 -0
  81. package/specs/quiver-v21-ai-first-layout/slices/slice-08-smokes-release-readiness/CLOSURE_BRIEF.md +35 -0
  82. package/specs/quiver-v21-ai-first-layout/slices/slice-08-smokes-release-readiness/EXECUTION_BRIEF.md +66 -0
  83. package/specs/quiver-v21-ai-first-layout/slices/slice-08-smokes-release-readiness/slice.json +62 -0
  84. package/src/create-quiver/commands/ai.js +442 -0
  85. package/src/create-quiver/index.js +421 -84
  86. package/src/create-quiver/lib/ai/context-packs.js +158 -0
  87. package/src/create-quiver/lib/ai/execution-plan.js +254 -0
  88. package/src/create-quiver/lib/ai/executor.js +323 -0
  89. package/src/create-quiver/lib/ai/github.js +329 -0
  90. package/src/create-quiver/lib/ai/phase-gates.js +72 -0
  91. package/src/create-quiver/lib/ai/preflight.js +58 -0
  92. package/src/create-quiver/lib/ai/prompt-transport.js +81 -0
  93. package/src/create-quiver/lib/ai/prompts.js +39 -0
  94. package/src/create-quiver/lib/ai/providers.js +314 -0
  95. package/src/create-quiver/lib/ai/safety.js +151 -0
  96. package/src/create-quiver/lib/ai/spec-generator.js +314 -0
  97. package/src/create-quiver/lib/ai/spec-templates.js +715 -0
  98. package/src/create-quiver/lib/doctor.js +114 -0
  99. package/src/create-quiver/lib/git.js +21 -0
  100. package/src/create-quiver/lib/init-docs.js +286 -25
  101. package/src/create-quiver/lib/init-layout.js +426 -0
  102. package/src/create-quiver/lib/lifecycle.js +2 -2
  103. package/src/create-quiver/lib/paths.js +63 -2
  104. package/src/create-quiver/lib/project-scan.js +66 -0
  105. package/src/create-quiver/lib/readiness.js +4 -2
  106. package/src/create-quiver/lib/scope.js +125 -0
  107. package/src/create-quiver/lib/slice-graph.js +6 -0
  108. package/src/create-quiver/lib/slice.js +51 -8
  109. package/src/create-quiver/lib/state.js +18 -1
  110. package/src/create-quiver/lib/template-resolver.js +74 -0
  111. package/.claude/settings.local.json +0 -52
@@ -0,0 +1,323 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ const { buildContextPackMetadata, normalizeRole } = require('./context-packs');
5
+ const { buildProviderInvocation, runProvider } = require('./providers');
6
+ const { captureWorktreeSnapshot, validateScopeSnapshot } = require('../scope');
7
+ const { resolveSliceContext } = require('../slice');
8
+
9
+ const DEFAULT_EXECUTE_PROVIDER = 'codex';
10
+ const DEFAULT_EXECUTE_ROLE = 'executor';
11
+ const DEFAULT_EXECUTE_CONTEXT = 'slice';
12
+
13
+ function formatError(message) {
14
+ return `create-quiver: ${message}`;
15
+ }
16
+
17
+ function canonicalizeRepoRoot(repoRoot) {
18
+ try {
19
+ return fs.realpathSync(repoRoot);
20
+ } catch {
21
+ return path.resolve(repoRoot);
22
+ }
23
+ }
24
+
25
+ function readTextFile(filePath, repoRoot) {
26
+ const resolved = path.resolve(repoRoot, filePath);
27
+ if (!fs.existsSync(resolved)) {
28
+ throw new Error(formatError(`missing required file: ${path.relative(repoRoot, resolved).split(path.sep).join('/')}`));
29
+ }
30
+
31
+ return fs.readFileSync(resolved, 'utf8');
32
+ }
33
+
34
+ function normalizeTimeout(timeoutMs) {
35
+ if (timeoutMs === undefined || timeoutMs === null || timeoutMs === '') {
36
+ return undefined;
37
+ }
38
+
39
+ const parsed = Number(timeoutMs);
40
+ if (!Number.isFinite(parsed) || parsed <= 0) {
41
+ throw new Error(formatError(`invalid timeout value: ${timeoutMs}`));
42
+ }
43
+
44
+ return parsed;
45
+ }
46
+
47
+ function toRelativePath(repoRoot, absolutePath) {
48
+ return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
49
+ }
50
+
51
+ function resolveSliceJsonPath(repoRoot, sliceInput) {
52
+ const value = String(sliceInput || '').trim();
53
+ if (!value) {
54
+ throw new Error(formatError('missing required --slice path for ai execute-slice'));
55
+ }
56
+
57
+ const resolved = path.resolve(repoRoot, value);
58
+ const slicePath = fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()
59
+ ? path.join(resolved, 'slice.json')
60
+ : resolved;
61
+
62
+ if (!fs.existsSync(slicePath)) {
63
+ throw new Error(formatError(`missing slice.json at ${toRelativePath(repoRoot, slicePath)}`));
64
+ }
65
+
66
+ return slicePath;
67
+ }
68
+
69
+ function formatList(items) {
70
+ if (!Array.isArray(items) || items.length === 0) {
71
+ return ['- n/a'];
72
+ }
73
+
74
+ return items.map((item) => `- ${item}`);
75
+ }
76
+
77
+ function buildExecuteSliceContext({ repoRoot, slicePath, role, context }) {
78
+ const canonicalRepoRoot = canonicalizeRepoRoot(repoRoot);
79
+ const resolvedRole = normalizeRole(role || DEFAULT_EXECUTE_ROLE);
80
+ if (resolvedRole !== DEFAULT_EXECUTE_ROLE) {
81
+ throw new Error(formatError('ai execute-slice requires role executor'));
82
+ }
83
+
84
+ const resolvedSlicePath = resolveSliceJsonPath(canonicalRepoRoot, slicePath);
85
+ const slice = resolveSliceContext(canonicalRepoRoot, resolvedSlicePath);
86
+ const briefPath = path.join(path.dirname(slice.sliceAbs), 'EXECUTION_BRIEF.md');
87
+ const briefText = readTextFile(briefPath, canonicalRepoRoot);
88
+ const pack = buildContextPackMetadata({
89
+ role: resolvedRole,
90
+ packName: context || DEFAULT_EXECUTE_CONTEXT,
91
+ repoRoot: canonicalRepoRoot,
92
+ });
93
+ const relativeSlicePath = toRelativePath(canonicalRepoRoot, slice.sliceAbs);
94
+ const relativeBriefPath = toRelativePath(canonicalRepoRoot, briefPath);
95
+ const allowedFiles = Array.isArray(slice.files) ? slice.files.map((file) => String(file)) : [];
96
+ const acceptance = Array.isArray(slice.acceptance) ? slice.acceptance.map((item) => String(item)) : [];
97
+ const validationCommands = Array.isArray(slice.tests) ? slice.tests.map((item) => String(item)) : [];
98
+ const mustItems = Array.isArray(slice.json.must) ? slice.json.must.map((item) => String(item)) : [];
99
+ const excludedItems = Array.isArray(slice.json.not_included) ? slice.json.not_included.map((item) => String(item)) : [];
100
+
101
+ const sections = [
102
+ pack.prompt,
103
+ 'Task: execute the slice directly in the repository using only the handoff below.',
104
+ `Slice: ${slice.sliceId}`,
105
+ `Spec: ${slice.specSlug}`,
106
+ `Slice file: ${relativeSlicePath}`,
107
+ `Execution brief: ${relativeBriefPath}`,
108
+ 'Allowed files:',
109
+ ...formatList(allowedFiles),
110
+ 'Acceptance criteria:',
111
+ ...formatList(acceptance),
112
+ 'Validation commands:',
113
+ ...formatList(validationCommands),
114
+ ];
115
+
116
+ if (mustItems.length > 0) {
117
+ sections.push('Must:', ...formatList(mustItems));
118
+ }
119
+
120
+ if (excludedItems.length > 0) {
121
+ sections.push('Not included:', ...formatList(excludedItems));
122
+ }
123
+
124
+ sections.push(
125
+ 'Constraints:',
126
+ '- Do not commit automatically.',
127
+ '- Do not fix scope violations automatically.',
128
+ '- Do not run multiple executors concurrently.',
129
+ '- Stay inside the allowed files declared by slice.json.',
130
+ 'Execution brief:',
131
+ briefText.trimEnd(),
132
+ );
133
+
134
+ return {
135
+ allowedFiles,
136
+ briefPath: relativeBriefPath,
137
+ briefText,
138
+ context: pack,
139
+ prompt: sections.join('\n\n'),
140
+ slice,
141
+ validationCommands,
142
+ };
143
+ }
144
+
145
+ function formatExecuteSliceDryRunReport({ provider, role, contextPack, slice, briefPath, invocation, validationCommands, allowedFiles }) {
146
+ const lines = [
147
+ 'AI execute-slice dry-run',
148
+ `Provider: ${provider}`,
149
+ `Role: ${role}`,
150
+ `Context pack: ${contextPack}`,
151
+ `Slice: ${slice.sliceId}`,
152
+ `Spec: ${slice.specSlug}`,
153
+ `Execution brief: ${briefPath}`,
154
+ `Command: ${invocation.command} ${invocation.args.join(' ')}`,
155
+ `Timeout: ${invocation.timeoutMs}ms`,
156
+ `Prompt transport: ${invocation.promptTransport.mode}`,
157
+ `Prompt length: ${invocation.promptLength} bytes`,
158
+ 'Allowed files:',
159
+ ...formatList(allowedFiles),
160
+ 'Validation commands:',
161
+ ...formatList(validationCommands),
162
+ ];
163
+
164
+ return `${lines.join('\n')}\n`;
165
+ }
166
+
167
+ function formatExecuteSliceResult({ slice, changedFiles, scopeResult }) {
168
+ const lines = [
169
+ 'AI execute-slice completed',
170
+ `Slice: ${slice.sliceId}`,
171
+ `Spec: ${slice.specSlug}`,
172
+ `Changed files: ${changedFiles.length}`,
173
+ ];
174
+
175
+ for (const file of changedFiles) {
176
+ lines.push(`- ${file}`);
177
+ }
178
+
179
+ lines.push(`Scope validation: ${scopeResult.ok ? 'passed' : 'failed'}`);
180
+
181
+ return `${lines.join('\n')}\n`;
182
+ }
183
+
184
+ function annotateProviderError(error, scope) {
185
+ const message = error && error.message ? error.message : String(error);
186
+ const wrapped = new Error(formatError(`ai ${scope} failed: ${message}`));
187
+ wrapped.cause = error;
188
+ wrapped.code = error && error.code ? error.code : 'AI_PROVIDER_ERROR';
189
+ wrapped.details = error && error.details ? error.details : undefined;
190
+ return wrapped;
191
+ }
192
+
193
+ async function runExecuteSlice(repoRoot, options = {}) {
194
+ const provider = String(options.provider || DEFAULT_EXECUTE_PROVIDER).trim().toLowerCase();
195
+ const role = normalizeRole(options.role || DEFAULT_EXECUTE_ROLE);
196
+ const context = options.context || DEFAULT_EXECUTE_CONTEXT;
197
+ const timeoutMs = normalizeTimeout(options.timeout);
198
+
199
+ if (!options.slice) {
200
+ throw new Error(formatError('missing required --slice path for ai execute-slice'));
201
+ }
202
+
203
+ const executorContext = buildExecuteSliceContext({
204
+ repoRoot,
205
+ slicePath: options.slice,
206
+ role,
207
+ context,
208
+ });
209
+
210
+ const prompt = executorContext.prompt;
211
+ let invocation;
212
+
213
+ try {
214
+ invocation = buildProviderInvocation(provider, {
215
+ prompt,
216
+ cwd: repoRoot,
217
+ timeoutMs,
218
+ });
219
+ } catch (error) {
220
+ throw annotateProviderError(error, 'execute-slice');
221
+ }
222
+
223
+ if (options.dryRun) {
224
+ const report = {
225
+ task: 'execute-slice',
226
+ provider,
227
+ role,
228
+ contextPack: executorContext.context.packName,
229
+ slice: executorContext.slice.sliceId,
230
+ invocation,
231
+ briefPath: executorContext.briefPath,
232
+ allowedFiles: executorContext.allowedFiles,
233
+ validationCommands: executorContext.validationCommands,
234
+ };
235
+ process.stdout.write(formatExecuteSliceDryRunReport({
236
+ provider,
237
+ role,
238
+ contextPack: executorContext.context.packName,
239
+ slice: executorContext.slice,
240
+ briefPath: executorContext.briefPath,
241
+ invocation,
242
+ validationCommands: executorContext.validationCommands,
243
+ allowedFiles: executorContext.allowedFiles,
244
+ }));
245
+ return report;
246
+ }
247
+
248
+ const beforeSnapshot = captureWorktreeSnapshot(repoRoot);
249
+ if (beforeSnapshot.files.length > 0 && options.allowDirty !== true) {
250
+ throw new Error(formatError(`ai execute-slice requires a clean worktree before running. Commit or stash first: ${beforeSnapshot.files.join(', ')}`));
251
+ }
252
+
253
+ let result;
254
+ try {
255
+ result = await (options.runProviderFn || runProvider)(provider, {
256
+ prompt,
257
+ cwd: repoRoot,
258
+ timeoutMs,
259
+ dryRun: false,
260
+ probe: options.probe,
261
+ spawn: options.spawn,
262
+ tempRoot: options.tempRoot,
263
+ tempFileName: options.tempFileName,
264
+ tempFilePrefix: options.tempFilePrefix,
265
+ });
266
+ } catch (error) {
267
+ throw annotateProviderError(error, 'execute-slice');
268
+ }
269
+
270
+ if (result.stdout) {
271
+ process.stdout.write(result.stdout);
272
+ }
273
+ if (result.stderr) {
274
+ process.stderr.write(result.stderr);
275
+ }
276
+
277
+ if (!result.ok) {
278
+ throw annotateProviderError(result.error || new Error('provider run failed'), 'execute-slice');
279
+ }
280
+
281
+ const afterSnapshot = captureWorktreeSnapshot(repoRoot);
282
+ const scopeResult = validateScopeSnapshot({
283
+ allowedFiles: executorContext.allowedFiles,
284
+ beforeSnapshot,
285
+ afterSnapshot,
286
+ strict: true,
287
+ });
288
+
289
+ process.stdout.write(formatExecuteSliceResult({
290
+ slice: executorContext.slice,
291
+ changedFiles: scopeResult.changedFiles,
292
+ scopeResult,
293
+ }));
294
+
295
+ return {
296
+ task: 'execute-slice',
297
+ provider,
298
+ role,
299
+ contextPack: executorContext.context.packName,
300
+ slice: executorContext.slice.sliceId,
301
+ specSlug: executorContext.slice.specSlug,
302
+ invocation,
303
+ result,
304
+ beforeSnapshot,
305
+ afterSnapshot,
306
+ scopeResult,
307
+ };
308
+ }
309
+
310
+ module.exports = {
311
+ DEFAULT_EXECUTE_CONTEXT,
312
+ DEFAULT_EXECUTE_PROVIDER,
313
+ DEFAULT_EXECUTE_ROLE,
314
+ annotateProviderError,
315
+ buildExecuteSliceContext,
316
+ canonicalizeRepoRoot,
317
+ formatExecuteSliceDryRunReport,
318
+ formatExecuteSliceResult,
319
+ normalizeTimeout,
320
+ readTextFile,
321
+ resolveSliceJsonPath,
322
+ runExecuteSlice,
323
+ };
@@ -0,0 +1,329 @@
1
+ const fs = require('node:fs');
2
+ const os = require('node:os');
3
+ const path = require('node:path');
4
+ const { spawnSync } = require('node:child_process');
5
+
6
+ const { currentBranch, hasRemote, isCleanWorktree } = require('../git');
7
+
8
+ const DEFAULT_GH_COMMAND = 'gh';
9
+ const DEFAULT_REMOTE = 'origin';
10
+ const DEFAULT_GITFLOW_GUIDE_PATH = 'docs/GITFLOW_PR_GUIDE.md';
11
+
12
+ class GitHubPreflightError extends Error {
13
+ constructor(code, message, details = {}) {
14
+ super(message);
15
+ this.name = 'GitHubPreflightError';
16
+ this.code = code;
17
+ this.details = details;
18
+ }
19
+ }
20
+
21
+ function formatError(message) {
22
+ return `create-quiver: ${message}`;
23
+ }
24
+
25
+ function normalizeOptionalPath(filePath) {
26
+ const value = String(filePath || '').trim();
27
+ if (!value) {
28
+ return '';
29
+ }
30
+
31
+ if (value === '~') {
32
+ return os.homedir();
33
+ }
34
+
35
+ if (value.startsWith('~/') || value.startsWith('~\\')) {
36
+ return path.join(os.homedir(), value.slice(2));
37
+ }
38
+
39
+ return value;
40
+ }
41
+
42
+ function resolveConfiguredPath(repoRoot, filePath) {
43
+ const normalized = normalizeOptionalPath(filePath);
44
+ if (!normalized) {
45
+ return '';
46
+ }
47
+
48
+ if (path.isAbsolute(normalized)) {
49
+ return path.normalize(normalized);
50
+ }
51
+
52
+ return path.resolve(repoRoot, normalized);
53
+ }
54
+
55
+ function formatGhInstallGuidance() {
56
+ return [
57
+ 'GitHub CLI is not installed.',
58
+ 'macOS: brew install gh',
59
+ 'Linux: follow https://github.com/cli/cli/blob/trunk/docs/install_linux.md or use your distro package manager',
60
+ 'Windows: winget install GitHub.cli',
61
+ ].join('\n');
62
+ }
63
+
64
+ function createError(code, message, details = {}) {
65
+ return new GitHubPreflightError(code, message, details);
66
+ }
67
+
68
+ function runCommand(command, args, options = {}) {
69
+ const runner = options.runner || spawnSync;
70
+ return runner(command, args, {
71
+ cwd: options.cwd,
72
+ encoding: 'utf8',
73
+ shell: false,
74
+ stdio: ['ignore', 'pipe', 'pipe'],
75
+ });
76
+ }
77
+
78
+ function ensureGhInstalled(options = {}) {
79
+ const command = options.ghCommand || DEFAULT_GH_COMMAND;
80
+ const probeArgs = Array.isArray(options.ghProbeArgs) ? options.ghProbeArgs : ['--version'];
81
+ const result = runCommand(command, probeArgs, {
82
+ cwd: options.cwd,
83
+ runner: options.ghProbe || spawnSync,
84
+ });
85
+
86
+ if (result && result.error && result.error.code === 'ENOENT') {
87
+ throw createError('MISSING_GH_CLI', `${formatGhInstallGuidance()}\nRun gh auth login after installation.`, {
88
+ command,
89
+ probeArgs,
90
+ errorCode: result.error.code,
91
+ });
92
+ }
93
+
94
+ if (result && result.error) {
95
+ throw createError('GH_CLI_UNAVAILABLE', formatError(`GitHub CLI could not be executed. Check '${command}' and then run gh auth login.`), {
96
+ command,
97
+ probeArgs,
98
+ errorCode: result.error.code,
99
+ errorMessage: result.error.message,
100
+ });
101
+ }
102
+
103
+ if (!result || typeof result.status !== 'number' || result.status !== 0) {
104
+ const stderr = result && typeof result.stderr === 'string' ? result.stderr.trim() : '';
105
+ const stdout = result && typeof result.stdout === 'string' ? result.stdout.trim() : '';
106
+ const details = [stderr, stdout].filter(Boolean).join('\n');
107
+ throw createError(
108
+ 'GH_CLI_UNAVAILABLE',
109
+ `${formatError(`GitHub CLI probe failed for '${command} ${probeArgs.join(' ')}'. Check your gh installation and then run gh auth login.`)}${details ? `\n${details}` : ''}`,
110
+ {
111
+ command,
112
+ probeArgs,
113
+ status: result && result.status,
114
+ stderr,
115
+ stdout,
116
+ },
117
+ );
118
+ }
119
+
120
+ return {
121
+ command,
122
+ probeArgs,
123
+ stdout: result && typeof result.stdout === 'string' ? result.stdout : '',
124
+ stderr: result && typeof result.stderr === 'string' ? result.stderr : '',
125
+ status: result && typeof result.status === 'number' ? result.status : 0,
126
+ };
127
+ }
128
+
129
+ function ensureGhAuthenticated(options = {}) {
130
+ const command = options.ghCommand || DEFAULT_GH_COMMAND;
131
+ const authArgs = Array.isArray(options.ghAuthArgs) ? options.ghAuthArgs : ['auth', 'status'];
132
+ const result = runCommand(command, authArgs, {
133
+ cwd: options.cwd,
134
+ runner: options.ghAuthProbe || options.ghProbe || spawnSync,
135
+ });
136
+
137
+ if (result && result.error && result.error.code === 'ENOENT') {
138
+ throw createError('MISSING_GH_CLI', `${formatGhInstallGuidance()}\nRun gh auth login after installation.`, {
139
+ command,
140
+ authArgs,
141
+ errorCode: result.error.code,
142
+ });
143
+ }
144
+
145
+ if (typeof result.status !== 'number' || result.status !== 0) {
146
+ const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
147
+ const stdout = typeof result.stdout === 'string' ? result.stdout.trim() : '';
148
+ const details = [stderr, stdout].filter(Boolean).join('\n');
149
+ throw createError(
150
+ 'GH_NOT_AUTHENTICATED',
151
+ `${formatError('gh auth status failed. Run gh auth login and then re-run the preflight.')}${details ? `\n${details}` : ''}`,
152
+ {
153
+ command,
154
+ authArgs,
155
+ status: result.status,
156
+ stderr,
157
+ stdout,
158
+ },
159
+ );
160
+ }
161
+
162
+ return {
163
+ command,
164
+ authArgs,
165
+ stdout: typeof result.stdout === 'string' ? result.stdout : '',
166
+ stderr: typeof result.stderr === 'string' ? result.stderr : '',
167
+ status: result.status,
168
+ };
169
+ }
170
+
171
+ function ensureGitFlowGuide(repoRoot, guidePath) {
172
+ const resolved = resolveConfiguredPath(repoRoot, guidePath || DEFAULT_GITFLOW_GUIDE_PATH);
173
+ if (!resolved || !fs.existsSync(resolved)) {
174
+ throw createError(
175
+ 'MISSING_GITFLOW_GUIDE',
176
+ formatError(`missing GitFlow PR guide at ${resolved || guidePath || DEFAULT_GITFLOW_GUIDE_PATH}. Create docs/GITFLOW_PR_GUIDE.md before opening the PR.`),
177
+ {
178
+ guidePath: resolved || guidePath || DEFAULT_GITFLOW_GUIDE_PATH,
179
+ },
180
+ );
181
+ }
182
+
183
+ return resolved;
184
+ }
185
+
186
+ function ensureRemote(repoRoot, remoteName = DEFAULT_REMOTE) {
187
+ if (!hasRemote(repoRoot, remoteName)) {
188
+ throw createError(
189
+ 'MISSING_GIT_REMOTE',
190
+ formatError(`missing Git remote '${remoteName}'. Configure a remote before preparing the PR.`),
191
+ { remoteName },
192
+ );
193
+ }
194
+
195
+ return remoteName;
196
+ }
197
+
198
+ function ensureWorktreeReady(repoRoot, options = {}) {
199
+ const branchName = currentBranch(repoRoot);
200
+ if (!branchName) {
201
+ throw createError(
202
+ 'DETACHED_HEAD',
203
+ formatError('current HEAD is detached. Check out the spec branch before preparing the PR.'),
204
+ { repoRoot },
205
+ );
206
+ }
207
+
208
+ const blockedBranches = Array.isArray(options.blockedBranches) && options.blockedBranches.length > 0
209
+ ? options.blockedBranches
210
+ : ['main', 'master', 'develop'];
211
+
212
+ if (blockedBranches.includes(branchName)) {
213
+ throw createError(
214
+ 'UNSAFE_PR_BRANCH',
215
+ formatError(`current branch '${branchName}' is not a PR branch. Create or switch to the feature branch before continuing.`),
216
+ { branchName, blockedBranches },
217
+ );
218
+ }
219
+
220
+ if (!isCleanWorktree(repoRoot)) {
221
+ throw createError(
222
+ 'DIRTY_WORKTREE',
223
+ formatError(`worktree has uncommitted changes on branch '${branchName}'. Commit or stash them before preparing the PR.`),
224
+ { branchName },
225
+ );
226
+ }
227
+
228
+ return branchName;
229
+ }
230
+
231
+ function ensureIdentityFile(repoRoot, identityFile) {
232
+ const normalized = String(identityFile || '').trim();
233
+ if (!normalized) {
234
+ return '';
235
+ }
236
+
237
+ const resolved = resolveConfiguredPath(repoRoot, normalized);
238
+ if (!fs.existsSync(resolved)) {
239
+ throw createError(
240
+ 'MISSING_IDENTITY_FILE',
241
+ formatError(`missing SSH identity file at ${resolved}. Check the path you passed as identityFile.`),
242
+ {
243
+ identityFile: normalized,
244
+ resolvedIdentityFile: resolved,
245
+ },
246
+ );
247
+ }
248
+
249
+ return resolved;
250
+ }
251
+
252
+ function buildPreflightReport(repoRoot, options = {}, checks = {}) {
253
+ return {
254
+ ok: true,
255
+ repoRoot,
256
+ remote: checks.remote || options.remote || DEFAULT_REMOTE,
257
+ branchName: checks.branchName || '',
258
+ guidePath: checks.guidePath || '',
259
+ sshHostAlias: options.sshHostAlias || '',
260
+ identityFile: checks.identityFile || '',
261
+ gh: checks.gh || null,
262
+ auth: checks.auth || null,
263
+ };
264
+ }
265
+
266
+ function preflightGitHubPr(repoRoot, options = {}) {
267
+ const gh = ensureGhInstalled(options);
268
+ const auth = ensureGhAuthenticated(options);
269
+ const guidePath = ensureGitFlowGuide(repoRoot, options.gitFlowGuidePath);
270
+ const remote = ensureRemote(repoRoot, options.remote || DEFAULT_REMOTE);
271
+ const branchName = ensureWorktreeReady(repoRoot, options);
272
+ const identityFile = ensureIdentityFile(repoRoot, options.identityFile);
273
+
274
+ return buildPreflightReport(repoRoot, options, {
275
+ gh,
276
+ auth,
277
+ guidePath,
278
+ remote,
279
+ branchName,
280
+ identityFile,
281
+ });
282
+ }
283
+
284
+ function formatPreflightReport(report, options = {}) {
285
+ const mode = options.mode || 'pr';
286
+ const dryRun = options.dryRun === true;
287
+ const lines = [
288
+ `GitHub ${mode} ${dryRun ? 'dry-run' : 'preflight'}`,
289
+ `Remote: ${report.remote}`,
290
+ `Branch: ${report.branchName}`,
291
+ `GitFlow guide: ${path.relative(report.repoRoot, report.guidePath).split(path.sep).join('/')}`,
292
+ ];
293
+
294
+ if (report.sshHostAlias) {
295
+ lines.push(`SSH host alias: ${report.sshHostAlias}`);
296
+ }
297
+
298
+ if (report.identityFile) {
299
+ lines.push(`Identity file: ${report.identityFile}`);
300
+ }
301
+
302
+ lines.push('Checks: gh, gh auth status, git remote, worktree branch, GitFlow guide, SSH identity file');
303
+
304
+ if (dryRun) {
305
+ lines.push('No PR will be created in dry-run mode.');
306
+ } else {
307
+ lines.push('PR creation is not performed in this slice.');
308
+ }
309
+
310
+ return `${lines.join('\n')}\n`;
311
+ }
312
+
313
+ module.exports = {
314
+ DEFAULT_GH_COMMAND,
315
+ DEFAULT_GITFLOW_GUIDE_PATH,
316
+ DEFAULT_REMOTE,
317
+ GitHubPreflightError,
318
+ buildPreflightReport,
319
+ ensureGhAuthenticated,
320
+ ensureGhInstalled,
321
+ ensureGitFlowGuide,
322
+ ensureIdentityFile,
323
+ ensureRemote,
324
+ ensureWorktreeReady,
325
+ formatGhInstallGuidance,
326
+ formatPreflightReport,
327
+ preflightGitHubPr,
328
+ resolveConfiguredPath,
329
+ };