create-quiver 0.14.0 → 0.14.1
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.
- package/CHANGELOG.md +11 -0
- package/README.md +183 -518
- package/README_FOR_AI.md +32 -26
- package/ROADMAP.md +5 -0
- package/assets/quiver-wordmark.svg +22 -0
- package/docs/AI_CONTEXT.md.template +2 -0
- package/docs/AI_ONBOARDING_PROMPT.md.template +9 -1
- package/docs/CLI_UX_GUIDE.md +125 -0
- package/docs/COMMANDS.md.template +16 -3
- package/docs/GITFLOW_PR_GUIDE.md +70 -0
- package/docs/getting-started/linux.md +84 -0
- package/docs/getting-started/macos.md +85 -0
- package/docs/getting-started/windows-git-bash-wsl.md +78 -0
- package/docs/getting-started/windows-powershell.md +96 -0
- package/docs/reference/commands.md +94 -0
- package/docs/workflows/existing-project.md +131 -0
- package/docs/workflows/full-ai-spec-to-pr.md +311 -0
- package/docs/workflows/legacy-quiver-project.md +102 -0
- package/docs/workflows/new-project.md +76 -0
- package/package.json +5 -1
- package/specs/quiver-v29-planner-prepare-context-cli-ux/EVIDENCE_REPORT.md +163 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/EXECUTION_PLAN.md +72 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/SPEC.md +173 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/STATUS.md +34 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/pr.md +95 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-00-cli-ux-spec-foundation/CLOSURE_BRIEF.md +32 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-00-cli-ux-spec-foundation/EXECUTION_BRIEF.md +45 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-00-cli-ux-spec-foundation/slice.json +58 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-01-cli-ux-primitives-theme/CLOSURE_BRIEF.md +33 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-01-cli-ux-primitives-theme/EXECUTION_BRIEF.md +49 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-01-cli-ux-primitives-theme/slice.json +75 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-02-planner-context-proposal-contract/CLOSURE_BRIEF.md +32 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-02-planner-context-proposal-contract/EXECUTION_BRIEF.md +47 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-02-planner-context-proposal-contract/slice.json +71 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-03-prepare-context-planner-review-flow/CLOSURE_BRIEF.md +33 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-03-prepare-context-planner-review-flow/EXECUTION_BRIEF.md +52 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-03-prepare-context-planner-review-flow/slice.json +82 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-04-ux-flag-matrix-compatibility/CLOSURE_BRIEF.md +34 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-04-ux-flag-matrix-compatibility/EXECUTION_BRIEF.md +46 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-04-ux-flag-matrix-compatibility/slice.json +73 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-05-progressive-command-adoption/CLOSURE_BRIEF.md +34 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-05-progressive-command-adoption/EXECUTION_BRIEF.md +46 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-05-progressive-command-adoption/slice.json +83 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-06-docs-tests-smoke-readiness/CLOSURE_BRIEF.md +31 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-06-docs-tests-smoke-readiness/EXECUTION_BRIEF.md +50 -0
- package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-06-docs-tests-smoke-readiness/slice.json +95 -0
- package/src/create-quiver/commands/ai.js +458 -3
- package/src/create-quiver/commands/spec.js +64 -2
- package/src/create-quiver/index.js +49 -1
- package/src/create-quiver/lib/ai/context-proposal.js +389 -0
- package/src/create-quiver/lib/ai/context-proposal.schema.js +31 -0
- package/src/create-quiver/lib/cli/editor.js +118 -0
- package/src/create-quiver/lib/cli/theme.js +100 -0
- package/src/create-quiver/lib/cli/ux-flags.js +151 -0
- package/src/create-quiver/lib/cli/ux.js +130 -0
|
@@ -53,6 +53,7 @@ const { checkPrReadiness, checkScope, checkSliceReadiness } = require('./lib/rea
|
|
|
53
53
|
const { cleanupSlice, refreshActiveSlicesBoard, startSlice } = require('./lib/lifecycle');
|
|
54
54
|
const { buildSpecStatus, closeSpecWorktree, formatSpecCloseResult, formatSpecStartResult, formatSpecStatus, startSpecWorktree } = require('./lib/spec-worktrees');
|
|
55
55
|
const { getContextPathExclusionReason } = require('./lib/ai/safety');
|
|
56
|
+
const { validateUxFlags } = require('./lib/cli/ux-flags');
|
|
56
57
|
const { relativePosixPath, resolveTargetRoot } = require('./lib/paths');
|
|
57
58
|
const {
|
|
58
59
|
CURRENT_SCAN_RELATIVE_PATH,
|
|
@@ -306,6 +307,10 @@ Options:
|
|
|
306
307
|
--include-templates Export packaged templates in init profile
|
|
307
308
|
--dry-run Preview init, analyze, migrate, prepare, spec create/start/close, demo, ai agent set, or AI work without executing writes/providers
|
|
308
309
|
--print-prompt Print the exact AI prompt and exit without executing provider CLIs
|
|
310
|
+
--with-planner Enable planner-assisted behavior on commands that explicitly support it
|
|
311
|
+
--interactive Enable prompts on commands that explicitly support interactive choices
|
|
312
|
+
--review Open or prepare human review before persistent writes where supported
|
|
313
|
+
--no-color Disable ANSI colors in human output
|
|
309
314
|
--fix For doctor, apply safe non-destructive repairs
|
|
310
315
|
--execute For ai execute-plan, run the planned slices instead of printing commands
|
|
311
316
|
--create For ai pr, create the PR after preflight instead of printing the plan only
|
|
@@ -417,6 +422,10 @@ function parseArgs(argv) {
|
|
|
417
422
|
strict: false,
|
|
418
423
|
strictOverlap: false,
|
|
419
424
|
json: false,
|
|
425
|
+
noColor: false,
|
|
426
|
+
withPlanner: false,
|
|
427
|
+
interactive: false,
|
|
428
|
+
review: false,
|
|
420
429
|
includeCompleted: false,
|
|
421
430
|
onlyReady: false,
|
|
422
431
|
allReady: false,
|
|
@@ -647,6 +656,26 @@ function parseArgs(argv) {
|
|
|
647
656
|
continue;
|
|
648
657
|
}
|
|
649
658
|
|
|
659
|
+
if (arg === '--no-color') {
|
|
660
|
+
result.noColor = true;
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (arg === '--with-planner') {
|
|
665
|
+
result.withPlanner = true;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (arg === '--interactive') {
|
|
670
|
+
result.interactive = true;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (arg === '--review') {
|
|
675
|
+
result.review = true;
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
|
|
650
679
|
if (arg === '--include-completed') {
|
|
651
680
|
result.includeCompleted = true;
|
|
652
681
|
continue;
|
|
@@ -2342,6 +2371,8 @@ async function run(argv) {
|
|
|
2342
2371
|
return;
|
|
2343
2372
|
}
|
|
2344
2373
|
|
|
2374
|
+
validateUxFlags(args);
|
|
2375
|
+
|
|
2345
2376
|
if (args.mode === 'analyze') {
|
|
2346
2377
|
runAnalyze(args.targetDir, {
|
|
2347
2378
|
dryRun: args.dryRun,
|
|
@@ -2481,8 +2512,17 @@ async function run(argv) {
|
|
|
2481
2512
|
|
|
2482
2513
|
if (args.aiCommand === 'prepare-context') {
|
|
2483
2514
|
await runAiPrepareContext(process.cwd(), {
|
|
2515
|
+
context: args.aiContext || undefined,
|
|
2484
2516
|
dryRun: args.dryRun,
|
|
2517
|
+
interactive: args.interactive,
|
|
2518
|
+
printPrompt: args.aiPrintPrompt,
|
|
2519
|
+
provider: args.aiProvider,
|
|
2520
|
+
providerExplicit: args.aiProviderExplicit,
|
|
2521
|
+
review: args.review,
|
|
2522
|
+
role: args.aiRole,
|
|
2485
2523
|
runId: args.aiRunId || undefined,
|
|
2524
|
+
timeout: args.aiTimeout,
|
|
2525
|
+
withPlanner: args.withPlanner,
|
|
2486
2526
|
});
|
|
2487
2527
|
return;
|
|
2488
2528
|
}
|
|
@@ -2496,10 +2536,13 @@ async function run(argv) {
|
|
|
2496
2536
|
printPrompt: args.aiPrintPrompt,
|
|
2497
2537
|
provider: args.aiProvider,
|
|
2498
2538
|
providerExplicit: args.aiProviderExplicit,
|
|
2539
|
+
interactive: args.interactive,
|
|
2540
|
+
review: args.review,
|
|
2499
2541
|
role: args.aiRole,
|
|
2500
2542
|
runId: args.aiRunId || undefined,
|
|
2501
2543
|
specSlug: args.specSlug || undefined,
|
|
2502
2544
|
timeout: args.aiTimeout,
|
|
2545
|
+
withPlanner: args.withPlanner,
|
|
2503
2546
|
});
|
|
2504
2547
|
return;
|
|
2505
2548
|
}
|
|
@@ -2620,7 +2663,9 @@ async function run(argv) {
|
|
|
2620
2663
|
create: args.aiCreate,
|
|
2621
2664
|
dryRun: args.dryRun,
|
|
2622
2665
|
input: args.aiInput || undefined,
|
|
2666
|
+
interactive: args.interactive,
|
|
2623
2667
|
remote: args.aiRemote || undefined,
|
|
2668
|
+
review: args.review,
|
|
2624
2669
|
sshHostAlias: args.aiSshHostAlias || undefined,
|
|
2625
2670
|
identityFile: args.aiIdentityFile || undefined,
|
|
2626
2671
|
title: args.aiTitle || undefined,
|
|
@@ -2760,10 +2805,13 @@ async function run(argv) {
|
|
|
2760
2805
|
|
|
2761
2806
|
if (args.mode === 'spec') {
|
|
2762
2807
|
if (args.specCommand === 'create') {
|
|
2763
|
-
runCreateSpec(process.cwd(), {
|
|
2808
|
+
await runCreateSpec(process.cwd(), {
|
|
2764
2809
|
dryRun: args.dryRun,
|
|
2765
2810
|
input: args.aiInput || undefined,
|
|
2811
|
+
interactive: args.interactive,
|
|
2812
|
+
review: args.review,
|
|
2766
2813
|
specSlug: args.specSlug || undefined,
|
|
2814
|
+
withPlanner: args.withPlanner,
|
|
2767
2815
|
});
|
|
2768
2816
|
return;
|
|
2769
2817
|
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const { redactSensitiveLocalValues } = require('./artifacts');
|
|
5
|
+
const { getPreparedContextDocPaths } = require('./context-packs');
|
|
6
|
+
const { contextProposalSchema } = require('./context-proposal.schema');
|
|
7
|
+
const { quiverInternalPaths } = require('../init-layout');
|
|
8
|
+
const {
|
|
9
|
+
getProjectRelativePathIssue,
|
|
10
|
+
validateProjectRelativePath,
|
|
11
|
+
} = require('../paths');
|
|
12
|
+
|
|
13
|
+
const INVALID_PROPOSAL_ARTIFACT_SCHEMA_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
function formatError(message) {
|
|
16
|
+
return `create-quiver: ${message}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class ContextProposalError extends Error {
|
|
20
|
+
constructor(message, issues = []) {
|
|
21
|
+
super(formatError(message));
|
|
22
|
+
this.name = 'ContextProposalError';
|
|
23
|
+
this.code = 'AI_CONTEXT_PROPOSAL_INVALID';
|
|
24
|
+
this.issues = issues;
|
|
25
|
+
this.safeNextSteps = [
|
|
26
|
+
'Run deterministic prepare-context without --with-planner if you need the safest fallback.',
|
|
27
|
+
'Rerun with --with-planner --print-prompt and ask the planner to return only valid JSON.',
|
|
28
|
+
'Review the blocked paths and keep proposal writes limited to approved docs-only context files.',
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeProposalShape(value) {
|
|
34
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const proposal = { ...value };
|
|
39
|
+
if (!proposal.docs && Array.isArray(proposal.files)) {
|
|
40
|
+
proposal.docs = proposal.files;
|
|
41
|
+
delete proposal.files;
|
|
42
|
+
}
|
|
43
|
+
if (proposal.schemaVersion && !proposal.schema_version) {
|
|
44
|
+
proposal.schema_version = proposal.schemaVersion;
|
|
45
|
+
delete proposal.schemaVersion;
|
|
46
|
+
}
|
|
47
|
+
if (proposal.nextSteps && !proposal.next_steps) {
|
|
48
|
+
proposal.next_steps = proposal.nextSteps;
|
|
49
|
+
delete proposal.nextSteps;
|
|
50
|
+
}
|
|
51
|
+
if (proposal.omittedPaths && !proposal.omitted_paths) {
|
|
52
|
+
proposal.omitted_paths = proposal.omittedPaths;
|
|
53
|
+
delete proposal.omittedPaths;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return proposal;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getAllowedContextProposalPaths() {
|
|
60
|
+
return getPreparedContextDocPaths();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function classifyDisallowedContextPath(relativePath) {
|
|
64
|
+
const normalized = String(relativePath || '').replace(/\\/g, '/').toLowerCase();
|
|
65
|
+
|
|
66
|
+
if (/^(src|app|apps|packages|lib|server|client|web)\//.test(normalized)) {
|
|
67
|
+
return 'product-code';
|
|
68
|
+
}
|
|
69
|
+
if (/^(package(?:-lock)?|npm-shrinkwrap)\.json$/.test(normalized)
|
|
70
|
+
|| /^pnpm-lock\.yaml$/.test(normalized)
|
|
71
|
+
|| /^yarn\.lock$/.test(normalized)
|
|
72
|
+
|| /^bun\.lockb?$/.test(normalized)) {
|
|
73
|
+
return 'dependency-or-lockfile';
|
|
74
|
+
}
|
|
75
|
+
if (/^(vite|next|webpack|rollup|tsconfig|jsconfig|babel|postcss|tailwind|eslint|prettier|turbo|vercel|netlify|dockerfile|compose)(\.|$)/i.test(normalized)) {
|
|
76
|
+
return 'build-or-runtime-config';
|
|
77
|
+
}
|
|
78
|
+
if (/^(dist|build|coverage|out|generated|gen|artifacts|reports)\//.test(normalized)) {
|
|
79
|
+
return 'generated-output';
|
|
80
|
+
}
|
|
81
|
+
if (/^\.quiver\//.test(normalized)) {
|
|
82
|
+
return 'quiver-internal-state';
|
|
83
|
+
}
|
|
84
|
+
if (!normalized.startsWith('docs/')) {
|
|
85
|
+
return 'not-a-context-doc';
|
|
86
|
+
}
|
|
87
|
+
return 'unapproved-context-doc';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateContextProposalPath(filePath, options = {}) {
|
|
91
|
+
const issue = getProjectRelativePathIssue(filePath, options.pathLib);
|
|
92
|
+
if (issue) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
path: String(filePath || ''),
|
|
96
|
+
issue,
|
|
97
|
+
message: `path must be project-relative and stay inside the repository (issue=${issue})`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let normalized;
|
|
102
|
+
try {
|
|
103
|
+
normalized = validateProjectRelativePath(filePath, 'planner proposal path', options.pathLib);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
path: String(filePath || ''),
|
|
108
|
+
issue: 'invalid-project-relative-path',
|
|
109
|
+
message: error.message,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const allowed = new Set(options.allowedPaths || getAllowedContextProposalPaths());
|
|
114
|
+
if (!allowed.has(normalized)) {
|
|
115
|
+
const issueName = classifyDisallowedContextPath(normalized);
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
path: normalized,
|
|
119
|
+
issue: issueName,
|
|
120
|
+
message: `path is not approved for planner context writes (${issueName}). Allowed paths: ${Array.from(allowed).join(', ')}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
path: normalized,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function extractBalancedJsonObject(text) {
|
|
131
|
+
const input = String(text || '');
|
|
132
|
+
const start = input.indexOf('{');
|
|
133
|
+
if (start === -1) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let depth = 0;
|
|
138
|
+
let quote = null;
|
|
139
|
+
let escaping = false;
|
|
140
|
+
|
|
141
|
+
for (let index = start; index < input.length; index += 1) {
|
|
142
|
+
const char = input[index];
|
|
143
|
+
|
|
144
|
+
if (escaping) {
|
|
145
|
+
escaping = false;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (char === '\\') {
|
|
149
|
+
escaping = true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (quote) {
|
|
153
|
+
if (char === quote) {
|
|
154
|
+
quote = null;
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (char === '"' || char === "'") {
|
|
159
|
+
quote = char;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (char === '{') {
|
|
163
|
+
depth += 1;
|
|
164
|
+
}
|
|
165
|
+
if (char === '}') {
|
|
166
|
+
depth -= 1;
|
|
167
|
+
if (depth === 0) {
|
|
168
|
+
return input.slice(start, index + 1);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseJsonCandidate(jsonText, source) {
|
|
177
|
+
try {
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
source,
|
|
181
|
+
value: JSON.parse(jsonText),
|
|
182
|
+
};
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
source,
|
|
187
|
+
error,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parsePlannerProposalJson(text) {
|
|
193
|
+
const input = String(text || '').trim();
|
|
194
|
+
if (!input) {
|
|
195
|
+
throw new ContextProposalError('planner proposal output is empty', [
|
|
196
|
+
{ path: null, issue: 'empty-output', message: 'The planner returned no proposal JSON.' },
|
|
197
|
+
]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const direct = parseJsonCandidate(input, 'raw-json');
|
|
201
|
+
if (direct.ok) {
|
|
202
|
+
return direct;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const fencedMatches = Array.from(input.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi));
|
|
206
|
+
const parseFailures = [direct];
|
|
207
|
+
for (const match of fencedMatches) {
|
|
208
|
+
const parsed = parseJsonCandidate(match[1].trim(), 'fenced-json');
|
|
209
|
+
if (parsed.ok) {
|
|
210
|
+
return parsed;
|
|
211
|
+
}
|
|
212
|
+
parseFailures.push(parsed);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const balanced = extractBalancedJsonObject(input);
|
|
216
|
+
if (balanced) {
|
|
217
|
+
const parsed = parseJsonCandidate(balanced, 'embedded-json');
|
|
218
|
+
if (parsed.ok) {
|
|
219
|
+
return parsed;
|
|
220
|
+
}
|
|
221
|
+
parseFailures.push(parsed);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const firstError = parseFailures.find((item) => item.error)?.error;
|
|
225
|
+
throw new ContextProposalError('planner proposal output is not valid JSON', [
|
|
226
|
+
{
|
|
227
|
+
path: null,
|
|
228
|
+
issue: 'malformed-json',
|
|
229
|
+
message: firstError ? firstError.message : 'No JSON object or fenced JSON block could be parsed.',
|
|
230
|
+
},
|
|
231
|
+
]);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function mapZodIssues(error) {
|
|
235
|
+
return error.issues.map((issue) => ({
|
|
236
|
+
path: issue.path.length > 0 ? issue.path.join('.') : null,
|
|
237
|
+
issue: issue.code,
|
|
238
|
+
message: issue.message,
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizeStrings(values) {
|
|
243
|
+
return Array.from(new Set((Array.isArray(values) ? values : [])
|
|
244
|
+
.map((item) => String(item || '').trim())
|
|
245
|
+
.filter(Boolean)));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function normalizeContextProposal(value, options = {}) {
|
|
249
|
+
const proposal = normalizeProposalShape(value);
|
|
250
|
+
const parsed = contextProposalSchema.safeParse(proposal);
|
|
251
|
+
if (!parsed.success) {
|
|
252
|
+
throw new ContextProposalError('planner context proposal does not match the required schema', mapZodIssues(parsed.error));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const issues = [];
|
|
256
|
+
const seenPaths = new Set();
|
|
257
|
+
const docs = [];
|
|
258
|
+
|
|
259
|
+
for (const [index, doc] of parsed.data.docs.entries()) {
|
|
260
|
+
const pathResult = validateContextProposalPath(doc.path, options);
|
|
261
|
+
if (!pathResult.ok) {
|
|
262
|
+
issues.push({
|
|
263
|
+
index,
|
|
264
|
+
path: pathResult.path,
|
|
265
|
+
issue: pathResult.issue,
|
|
266
|
+
message: pathResult.message,
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (seenPaths.has(pathResult.path)) {
|
|
272
|
+
issues.push({
|
|
273
|
+
index,
|
|
274
|
+
path: pathResult.path,
|
|
275
|
+
issue: 'duplicate-path',
|
|
276
|
+
message: 'planner proposal contains multiple updates for the same context doc path',
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
seenPaths.add(pathResult.path);
|
|
281
|
+
|
|
282
|
+
if (doc.action !== 'skip' && !doc.content.trim()) {
|
|
283
|
+
issues.push({
|
|
284
|
+
index,
|
|
285
|
+
path: pathResult.path,
|
|
286
|
+
issue: 'missing-content',
|
|
287
|
+
message: 'create/update proposal entries require non-empty content',
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
docs.push({
|
|
293
|
+
path: pathResult.path,
|
|
294
|
+
action: doc.action,
|
|
295
|
+
content: doc.content,
|
|
296
|
+
reason: doc.reason,
|
|
297
|
+
assumptions: normalizeStrings(doc.assumptions),
|
|
298
|
+
risks: normalizeStrings(doc.risks),
|
|
299
|
+
source: 'planner-proposal',
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (issues.length > 0) {
|
|
304
|
+
throw new ContextProposalError('planner context proposal contains unsafe or ambiguous writes', issues);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
schemaVersion: parsed.data.schema_version,
|
|
309
|
+
kind: parsed.data.kind,
|
|
310
|
+
summary: parsed.data.summary,
|
|
311
|
+
assumptions: normalizeStrings(parsed.data.assumptions),
|
|
312
|
+
risks: normalizeStrings(parsed.data.risks),
|
|
313
|
+
omittedPaths: normalizeStrings(parsed.data.omitted_paths),
|
|
314
|
+
nextSteps: normalizeStrings(parsed.data.next_steps),
|
|
315
|
+
docs,
|
|
316
|
+
writePlan: docs
|
|
317
|
+
.filter((doc) => doc.action !== 'skip')
|
|
318
|
+
.map((doc) => ({
|
|
319
|
+
path: doc.path,
|
|
320
|
+
content: doc.content,
|
|
321
|
+
reason: doc.reason,
|
|
322
|
+
source: doc.source,
|
|
323
|
+
})),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function parseContextProposalOutput(text, options = {}) {
|
|
328
|
+
const parsed = parsePlannerProposalJson(text);
|
|
329
|
+
return {
|
|
330
|
+
...normalizeContextProposal(parsed.value, options),
|
|
331
|
+
parseSource: parsed.source,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function safeArtifactName(now = new Date()) {
|
|
336
|
+
return `${now.toISOString()
|
|
337
|
+
.replace(/\.\d{3}Z$/, 'z')
|
|
338
|
+
.replace(/[^0-9a-z]+/gi, '-')
|
|
339
|
+
.toLowerCase()
|
|
340
|
+
.replace(/^-+|-+$/g, '')}-invalid-context-proposal.json`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function writeInvalidContextProposalArtifact(projectRoot, runId, rawOutput, error, options = {}) {
|
|
344
|
+
if (!projectRoot) {
|
|
345
|
+
throw new Error(formatError('missing project root for invalid context proposal artifact'));
|
|
346
|
+
}
|
|
347
|
+
if (!runId) {
|
|
348
|
+
throw new Error(formatError('missing AI run id for invalid context proposal artifact'));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const now = options.now || new Date();
|
|
352
|
+
const artifactDir = path.join(quiverInternalPaths(projectRoot).runsDir, String(runId), 'raw');
|
|
353
|
+
const filePath = path.join(artifactDir, safeArtifactName(now));
|
|
354
|
+
const artifact = {
|
|
355
|
+
schema_version: INVALID_PROPOSAL_ARTIFACT_SCHEMA_VERSION,
|
|
356
|
+
kind: 'invalid-context-proposal',
|
|
357
|
+
created_at: now.toISOString(),
|
|
358
|
+
ok: false,
|
|
359
|
+
error: {
|
|
360
|
+
code: error?.code || 'AI_CONTEXT_PROPOSAL_INVALID',
|
|
361
|
+
message: error?.message || String(error || 'unknown error'),
|
|
362
|
+
issues: Array.isArray(error?.issues) ? error.issues : [],
|
|
363
|
+
safe_next_steps: Array.isArray(error?.safeNextSteps) ? error.safeNextSteps : [],
|
|
364
|
+
},
|
|
365
|
+
raw_output: redactSensitiveLocalValues(rawOutput, { projectRoot }),
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
fs.mkdirSync(artifactDir, { recursive: true });
|
|
369
|
+
fs.writeFileSync(filePath, `${JSON.stringify(artifact, null, 2)}\n`);
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
filePath,
|
|
373
|
+
path: path.relative(projectRoot, filePath).split(path.sep).join('/'),
|
|
374
|
+
artifact,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
module.exports = {
|
|
379
|
+
INVALID_PROPOSAL_ARTIFACT_SCHEMA_VERSION,
|
|
380
|
+
ContextProposalError,
|
|
381
|
+
classifyDisallowedContextPath,
|
|
382
|
+
extractBalancedJsonObject,
|
|
383
|
+
getAllowedContextProposalPaths,
|
|
384
|
+
normalizeContextProposal,
|
|
385
|
+
parseContextProposalOutput,
|
|
386
|
+
parsePlannerProposalJson,
|
|
387
|
+
validateContextProposalPath,
|
|
388
|
+
writeInvalidContextProposalArtifact,
|
|
389
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const { z } = require('zod');
|
|
2
|
+
|
|
3
|
+
const CONTEXT_PROPOSAL_SCHEMA_VERSION = 1;
|
|
4
|
+
const MAX_DOC_CONTENT_LENGTH = 400_000;
|
|
5
|
+
|
|
6
|
+
const contextProposalDocSchema = z.object({
|
|
7
|
+
path: z.string().trim().min(1, 'doc path is required'),
|
|
8
|
+
action: z.enum(['create', 'update', 'skip']).default('update'),
|
|
9
|
+
content: z.string().max(MAX_DOC_CONTENT_LENGTH).default(''),
|
|
10
|
+
reason: z.string().trim().max(2_000).default('Planner proposed this documentation update.'),
|
|
11
|
+
assumptions: z.array(z.string().trim().min(1)).default([]),
|
|
12
|
+
risks: z.array(z.string().trim().min(1)).default([]),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const contextProposalSchema = z.object({
|
|
16
|
+
schema_version: z.literal(CONTEXT_PROPOSAL_SCHEMA_VERSION).default(CONTEXT_PROPOSAL_SCHEMA_VERSION),
|
|
17
|
+
kind: z.literal('quiver-context-proposal').default('quiver-context-proposal'),
|
|
18
|
+
summary: z.string().trim().max(4_000).default(''),
|
|
19
|
+
assumptions: z.array(z.string().trim().min(1)).default([]),
|
|
20
|
+
risks: z.array(z.string().trim().min(1)).default([]),
|
|
21
|
+
docs: z.array(contextProposalDocSchema).min(1, 'at least one docs update is required'),
|
|
22
|
+
omitted_paths: z.array(z.string().trim().min(1)).default([]),
|
|
23
|
+
next_steps: z.array(z.string().trim().min(1)).default([]),
|
|
24
|
+
}).strict();
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
CONTEXT_PROPOSAL_SCHEMA_VERSION,
|
|
28
|
+
MAX_DOC_CONTENT_LENGTH,
|
|
29
|
+
contextProposalDocSchema,
|
|
30
|
+
contextProposalSchema,
|
|
31
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
const { spawnSync } = require('child_process');
|
|
3
|
+
|
|
4
|
+
function splitEditorCommand(value) {
|
|
5
|
+
const input = String(value || '').trim();
|
|
6
|
+
if (!input) return null;
|
|
7
|
+
const parts = [];
|
|
8
|
+
let current = '';
|
|
9
|
+
let quote = null;
|
|
10
|
+
let escaping = false;
|
|
11
|
+
|
|
12
|
+
for (const char of input) {
|
|
13
|
+
if (escaping) {
|
|
14
|
+
current += char;
|
|
15
|
+
escaping = false;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (char === '\\') {
|
|
19
|
+
escaping = true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (quote) {
|
|
23
|
+
if (char === quote) {
|
|
24
|
+
quote = null;
|
|
25
|
+
} else {
|
|
26
|
+
current += char;
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (char === '"' || char === "'") {
|
|
31
|
+
quote = char;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (/\s/.test(char)) {
|
|
35
|
+
if (current) {
|
|
36
|
+
parts.push(current);
|
|
37
|
+
current = '';
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
current += char;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (current) parts.push(current);
|
|
45
|
+
return parts.length > 0 ? { command: parts[0], args: parts.slice(1) } : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultEditorForPlatform(platform = os.platform()) {
|
|
49
|
+
return platform === 'win32' ? 'notepad' : 'vi';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveEditor(env = process.env, options = {}) {
|
|
53
|
+
const candidates = [
|
|
54
|
+
{ source: 'VISUAL', value: env.VISUAL },
|
|
55
|
+
{ source: 'EDITOR', value: env.EDITOR },
|
|
56
|
+
...((options.fallbacks || [defaultEditorForPlatform(options.platform)]).map((value) => ({
|
|
57
|
+
source: 'fallback',
|
|
58
|
+
value,
|
|
59
|
+
}))),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
const parsed = splitEditorCommand(candidate.value);
|
|
64
|
+
if (parsed) {
|
|
65
|
+
return { ...parsed, source: candidate.source };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function openEditor(filePath, options = {}) {
|
|
73
|
+
const editor = options.editor || resolveEditor(options.env || process.env, options);
|
|
74
|
+
if (!editor) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
canceled: true,
|
|
78
|
+
filePath,
|
|
79
|
+
reason: 'No editor configured. Set VISUAL or EDITOR, then rerun the command.',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const runner = options.spawnSync || spawnSync;
|
|
84
|
+
const result = runner(editor.command, [...editor.args, filePath], {
|
|
85
|
+
cwd: options.cwd || process.cwd(),
|
|
86
|
+
env: options.env || process.env,
|
|
87
|
+
shell: false,
|
|
88
|
+
stdio: options.stdio || 'inherit',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (result.error) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
canceled: true,
|
|
95
|
+
command: editor.command,
|
|
96
|
+
filePath,
|
|
97
|
+
reason: result.error.message,
|
|
98
|
+
source: editor.source,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const status = result.status ?? 0;
|
|
103
|
+
return {
|
|
104
|
+
ok: status === 0,
|
|
105
|
+
canceled: status !== 0,
|
|
106
|
+
command: editor.command,
|
|
107
|
+
exitCode: status,
|
|
108
|
+
filePath,
|
|
109
|
+
source: editor.source,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
defaultEditorForPlatform,
|
|
115
|
+
openEditor,
|
|
116
|
+
resolveEditor,
|
|
117
|
+
splitEditorCommand,
|
|
118
|
+
};
|