create-quiver 0.14.0 → 0.15.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 (92) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +187 -518
  3. package/README_FOR_AI.md +39 -27
  4. package/ROADMAP.md +11 -0
  5. package/assets/quiver-wordmark.svg +22 -0
  6. package/docs/AI_CONTEXT.md.template +2 -0
  7. package/docs/AI_ONBOARDING_PROMPT.md.template +9 -1
  8. package/docs/CLI_UX_GUIDE.md +185 -0
  9. package/docs/COMMANDS.md.template +22 -3
  10. package/docs/GITFLOW_PR_GUIDE.md +70 -0
  11. package/docs/getting-started/linux.md +84 -0
  12. package/docs/getting-started/macos.md +85 -0
  13. package/docs/getting-started/windows-git-bash-wsl.md +78 -0
  14. package/docs/getting-started/windows-powershell.md +96 -0
  15. package/docs/reference/commands.md +98 -0
  16. package/docs/workflows/existing-project.md +131 -0
  17. package/docs/workflows/full-ai-spec-to-pr.md +311 -0
  18. package/docs/workflows/legacy-quiver-project.md +102 -0
  19. package/docs/workflows/new-project.md +76 -0
  20. package/package.json +5 -1
  21. package/specs/quiver-v29-planner-prepare-context-cli-ux/EVIDENCE_REPORT.md +163 -0
  22. package/specs/quiver-v29-planner-prepare-context-cli-ux/EXECUTION_PLAN.md +72 -0
  23. package/specs/quiver-v29-planner-prepare-context-cli-ux/SPEC.md +173 -0
  24. package/specs/quiver-v29-planner-prepare-context-cli-ux/STATUS.md +34 -0
  25. package/specs/quiver-v29-planner-prepare-context-cli-ux/pr.md +95 -0
  26. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-00-cli-ux-spec-foundation/CLOSURE_BRIEF.md +32 -0
  27. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-00-cli-ux-spec-foundation/EXECUTION_BRIEF.md +45 -0
  28. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-00-cli-ux-spec-foundation/slice.json +58 -0
  29. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-01-cli-ux-primitives-theme/CLOSURE_BRIEF.md +33 -0
  30. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-01-cli-ux-primitives-theme/EXECUTION_BRIEF.md +49 -0
  31. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-01-cli-ux-primitives-theme/slice.json +75 -0
  32. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-02-planner-context-proposal-contract/CLOSURE_BRIEF.md +32 -0
  33. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-02-planner-context-proposal-contract/EXECUTION_BRIEF.md +47 -0
  34. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-02-planner-context-proposal-contract/slice.json +71 -0
  35. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-03-prepare-context-planner-review-flow/CLOSURE_BRIEF.md +33 -0
  36. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-03-prepare-context-planner-review-flow/EXECUTION_BRIEF.md +52 -0
  37. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-03-prepare-context-planner-review-flow/slice.json +82 -0
  38. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-04-ux-flag-matrix-compatibility/CLOSURE_BRIEF.md +34 -0
  39. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-04-ux-flag-matrix-compatibility/EXECUTION_BRIEF.md +46 -0
  40. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-04-ux-flag-matrix-compatibility/slice.json +73 -0
  41. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-05-progressive-command-adoption/CLOSURE_BRIEF.md +34 -0
  42. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-05-progressive-command-adoption/EXECUTION_BRIEF.md +46 -0
  43. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-05-progressive-command-adoption/slice.json +83 -0
  44. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-06-docs-tests-smoke-readiness/CLOSURE_BRIEF.md +31 -0
  45. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-06-docs-tests-smoke-readiness/EXECUTION_BRIEF.md +50 -0
  46. package/specs/quiver-v29-planner-prepare-context-cli-ux/slices/slice-06-docs-tests-smoke-readiness/slice.json +95 -0
  47. package/specs/quiver-v30-interactive-cli-ux-agent-selection/EVIDENCE_REPORT.md +213 -0
  48. package/specs/quiver-v30-interactive-cli-ux-agent-selection/EXECUTION_PLAN.md +85 -0
  49. package/specs/quiver-v30-interactive-cli-ux-agent-selection/SPEC.md +213 -0
  50. package/specs/quiver-v30-interactive-cli-ux-agent-selection/STATUS.md +31 -0
  51. package/specs/quiver-v30-interactive-cli-ux-agent-selection/pr.md +103 -0
  52. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-00-spec-foundation/CLOSURE_BRIEF.md +33 -0
  53. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-00-spec-foundation/EXECUTION_BRIEF.md +56 -0
  54. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-00-spec-foundation/slice.json +71 -0
  55. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-01-cli-ux-runtime-progress-engine/CLOSURE_BRIEF.md +31 -0
  56. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-01-cli-ux-runtime-progress-engine/EXECUTION_BRIEF.md +54 -0
  57. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-01-cli-ux-runtime-progress-engine/slice.json +69 -0
  58. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-02-agent-profile-selection-selectors/CLOSURE_BRIEF.md +33 -0
  59. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-02-agent-profile-selection-selectors/EXECUTION_BRIEF.md +56 -0
  60. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-02-agent-profile-selection-selectors/slice.json +81 -0
  61. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-03-provider-model-selection-contract/CLOSURE_BRIEF.md +32 -0
  62. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-03-provider-model-selection-contract/EXECUTION_BRIEF.md +54 -0
  63. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-03-provider-model-selection-contract/slice.json +75 -0
  64. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-04-planner-ia-progress-flows/CLOSURE_BRIEF.md +32 -0
  65. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-04-planner-ia-progress-flows/EXECUTION_BRIEF.md +57 -0
  66. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-04-planner-ia-progress-flows/slice.json +85 -0
  67. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-05-executor-pr-progress-flows/CLOSURE_BRIEF.md +33 -0
  68. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-05-executor-pr-progress-flows/EXECUTION_BRIEF.md +57 -0
  69. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-05-executor-pr-progress-flows/slice.json +85 -0
  70. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-06-doctor-visual-json-contract/CLOSURE_BRIEF.md +35 -0
  71. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-06-doctor-visual-json-contract/EXECUTION_BRIEF.md +55 -0
  72. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-06-doctor-visual-json-contract/slice.json +81 -0
  73. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-07-interactive-init-spec-create/CLOSURE_BRIEF.md +34 -0
  74. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-07-interactive-init-spec-create/EXECUTION_BRIEF.md +55 -0
  75. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-07-interactive-init-spec-create/slice.json +85 -0
  76. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-08-tests-docs-cross-platform-release/CLOSURE_BRIEF.md +34 -0
  77. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-08-tests-docs-cross-platform-release/EXECUTION_BRIEF.md +59 -0
  78. package/specs/quiver-v30-interactive-cli-ux-agent-selection/slices/slice-08-tests-docs-cross-platform-release/slice.json +95 -0
  79. package/src/create-quiver/commands/ai.js +809 -71
  80. package/src/create-quiver/commands/spec.js +167 -5
  81. package/src/create-quiver/index.js +582 -71
  82. package/src/create-quiver/lib/agent-profiles.js +111 -10
  83. package/src/create-quiver/lib/ai/context-proposal.js +389 -0
  84. package/src/create-quiver/lib/ai/context-proposal.schema.js +31 -0
  85. package/src/create-quiver/lib/ai/execution-plan.js +106 -8
  86. package/src/create-quiver/lib/ai/executor.js +284 -28
  87. package/src/create-quiver/lib/ai/providers.js +71 -1
  88. package/src/create-quiver/lib/cli/editor.js +118 -0
  89. package/src/create-quiver/lib/cli/selectors.js +107 -0
  90. package/src/create-quiver/lib/cli/theme.js +103 -0
  91. package/src/create-quiver/lib/cli/ux-flags.js +169 -0
  92. package/src/create-quiver/lib/cli/ux.js +225 -0
@@ -5,7 +5,7 @@ const { assertSupportedProvider, formatProviderList } = require('./ai/providers'
5
5
  const { quiverInternalPaths } = require('./init-layout');
6
6
 
7
7
  const AGENT_PROFILE_ROLES = Object.freeze(['planner', 'executor', 'reviewer', 'doctor']);
8
- const PROFILE_STATE_VERSION = 1;
8
+ const PROFILE_STATE_VERSION = 2;
9
9
 
10
10
  function formatError(message) {
11
11
  return `create-quiver: ${message}`;
@@ -56,9 +56,46 @@ function emptyProfilesState() {
56
56
  return {
57
57
  version: PROFILE_STATE_VERSION,
58
58
  profiles: {},
59
+ profile_sets: {},
59
60
  };
60
61
  }
61
62
 
63
+ function profileSetKey(role) {
64
+ return `${normalizeAgentProfileRole(role)}s`;
65
+ }
66
+
67
+ function normalizeProfileId(value, fallback = 'default') {
68
+ const source = String(value || fallback || 'default').trim().toLowerCase();
69
+ const normalized = source
70
+ .replace(/[^a-z0-9]+/g, '-')
71
+ .replace(/^-+|-+$/g, '');
72
+ return normalized || 'default';
73
+ }
74
+
75
+ function resolveAgentProfileDisplayName(profile = {}) {
76
+ return profile.displayName || profile.model || profile.provider || profile.role || '';
77
+ }
78
+
79
+ function normalizeStoredProfile(profile, role, fallbackId = 'default') {
80
+ const normalizedRole = normalizeAgentProfileRole(role || profile?.role);
81
+ const provider = profile?.provider ? assertSupportedProvider(profile.provider) : '';
82
+ const normalized = {
83
+ id: normalizeProfileId(profile?.id || profile?.label, fallbackId),
84
+ role: normalizedRole,
85
+ provider,
86
+ model: normalizeOptionalText(profile?.model, 'model'),
87
+ label: normalizeOptionalText(profile?.label, 'label'),
88
+ displayName: normalizeOptionalText(profile?.displayName || profile?.display_name, 'displayName'),
89
+ context: normalizeOptionalText(profile?.context, 'context'),
90
+ default: profile?.default === true,
91
+ updated_at: profile?.updated_at || '',
92
+ };
93
+ if (!normalized.displayName) {
94
+ normalized.displayName = resolveAgentProfileDisplayName(normalized);
95
+ }
96
+ return normalized;
97
+ }
98
+
62
99
  function readAgentProfiles(projectRoot) {
63
100
  const filePath = agentProfilesPath(projectRoot);
64
101
  if (!fs.existsSync(filePath)) {
@@ -69,6 +106,8 @@ function readAgentProfiles(projectRoot) {
69
106
  return {
70
107
  version: state.version || PROFILE_STATE_VERSION,
71
108
  profiles: state.profiles && typeof state.profiles === 'object' ? state.profiles : {},
109
+ profile_sets: state.profile_sets && typeof state.profile_sets === 'object' ? state.profile_sets : {},
110
+ updated_at: state.updated_at || undefined,
72
111
  };
73
112
  }
74
113
 
@@ -80,18 +119,53 @@ function writeAgentProfiles(projectRoot, state) {
80
119
  }
81
120
 
82
121
  function getAgentProfile(projectRoot, role) {
122
+ const normalizedRole = normalizeAgentProfileRole(role);
123
+ const profiles = getAgentProfilesForRole(projectRoot, normalizedRole);
124
+ if (profiles.length === 0) return null;
125
+ return profiles.find((profile) => profile.default) || profiles[0];
126
+ }
127
+
128
+ function getAgentProfileById(projectRoot, role, profileId) {
129
+ const normalizedRole = normalizeAgentProfileRole(role);
130
+ const normalizedId = normalizeProfileId(profileId);
131
+ return getAgentProfilesForRole(projectRoot, normalizedRole)
132
+ .find((profile) => profile.id === normalizedId) || null;
133
+ }
134
+
135
+ function getAgentProfilesForRole(projectRoot, role) {
83
136
  const normalizedRole = normalizeAgentProfileRole(role);
84
137
  const state = readAgentProfiles(projectRoot);
85
- return state.profiles[normalizedRole] || null;
138
+ const setKey = profileSetKey(normalizedRole);
139
+ const fromSet = Array.isArray(state.profile_sets[setKey])
140
+ ? state.profile_sets[setKey].map((profile, index) => normalizeStoredProfile(profile, normalizedRole, index === 0 ? 'default' : `${normalizedRole}-${index + 1}`))
141
+ : [];
142
+ const legacy = state.profiles[normalizedRole]
143
+ ? normalizeStoredProfile({ ...state.profiles[normalizedRole], default: true }, normalizedRole, 'default')
144
+ : null;
145
+ const merged = new Map();
146
+
147
+ if (legacy) merged.set(legacy.id, legacy);
148
+ for (const profile of fromSet) {
149
+ merged.set(profile.id, profile);
150
+ }
151
+
152
+ const profiles = Array.from(merged.values()).filter((profile) => profile.provider);
153
+ if (profiles.length > 0 && !profiles.some((profile) => profile.default)) {
154
+ profiles[0].default = true;
155
+ }
156
+ return profiles;
86
157
  }
87
158
 
88
159
  function listAgentProfiles(projectRoot) {
89
- const state = readAgentProfiles(projectRoot);
90
- return AGENT_PROFILE_ROLES.map((role) => ({
91
- role,
92
- configured: Boolean(state.profiles[role]),
93
- profile: state.profiles[role] || null,
94
- }));
160
+ return AGENT_PROFILE_ROLES.map((role) => {
161
+ const profiles = getAgentProfilesForRole(projectRoot, role);
162
+ return {
163
+ role,
164
+ configured: profiles.length > 0,
165
+ profile: profiles.find((profile) => profile.default) || profiles[0] || null,
166
+ profiles,
167
+ };
168
+ });
95
169
  }
96
170
 
97
171
  function setAgentProfile(projectRoot, role, options = {}) {
@@ -109,23 +183,47 @@ function buildAgentProfileState(projectRoot, role, options = {}) {
109
183
  const provider = assertSupportedProvider(options.provider);
110
184
  const model = normalizeOptionalText(options.model, 'model');
111
185
  const label = normalizeOptionalText(options.label, 'label');
186
+ const displayName = normalizeOptionalText(options.displayName || options.display_name, 'displayName');
112
187
  const context = normalizeOptionalText(options.context, 'context');
113
188
  const state = readAgentProfiles(projectRoot);
114
- const current = state.profiles[normalizedRole] || {};
189
+ const currentProfiles = getAgentProfilesForRole(projectRoot, normalizedRole);
190
+ const currentDefault = currentProfiles.find((profile) => profile.default) || currentProfiles[0] || {};
191
+ const id = normalizeProfileId(options.id || currentDefault.id || label || model || provider);
192
+ const current = currentProfiles.find((profile) => profile.id === id) || {};
115
193
  const now = options.now instanceof Date ? options.now.toISOString() : new Date().toISOString();
194
+ const shouldBeDefault = options.default === true || currentProfiles.length === 0 || (options.default !== false && current.default === true);
116
195
  const profile = {
196
+ id,
117
197
  role: normalizedRole,
118
198
  provider,
119
199
  model: model || current.model || '',
120
200
  label: label || current.label || '',
201
+ displayName: displayName || current.displayName || model || label || provider,
121
202
  context: context || current.context || '',
203
+ default: shouldBeDefault,
122
204
  updated_at: now,
123
205
  };
124
206
 
125
207
  state.version = PROFILE_STATE_VERSION;
208
+ const setKey = profileSetKey(normalizedRole);
209
+ const nextProfiles = currentProfiles
210
+ .filter((item) => item.id !== id)
211
+ .map((item) => ({
212
+ ...item,
213
+ default: shouldBeDefault ? false : item.default === true,
214
+ }))
215
+ .concat(profile);
216
+ if (!nextProfiles.some((item) => item.default)) {
217
+ nextProfiles[0].default = true;
218
+ }
219
+ state.profile_sets = {
220
+ ...state.profile_sets,
221
+ [setKey]: nextProfiles,
222
+ };
223
+ const defaultProfile = nextProfiles.find((item) => item.default) || profile;
126
224
  state.profiles = {
127
225
  ...state.profiles,
128
- [normalizedRole]: profile,
226
+ [normalizedRole]: defaultProfile,
129
227
  };
130
228
  state.updated_at = now;
131
229
 
@@ -149,6 +247,9 @@ module.exports = {
149
247
  AGENT_PROFILE_ROLES,
150
248
  PROFILE_STATE_VERSION,
151
249
  agentProfilesPath,
250
+ getAgentProfileById,
251
+ getAgentProfilesForRole,
252
+ resolveAgentProfileDisplayName,
152
253
  formatProviderList,
153
254
  buildAgentProfileState,
154
255
  getAgentProfile,
@@ -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
+ };