@popoverai/dotrequirements 0.25.0 → 0.26.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.
Files changed (103) hide show
  1. package/dist/cli.js +8 -1
  2. package/dist/codebase-to-spec/area-name.d.ts +13 -0
  3. package/dist/codebase-to-spec/area-name.js +18 -0
  4. package/dist/codebase-to-spec/cache.d.ts +31 -0
  5. package/dist/codebase-to-spec/cache.js +29 -1
  6. package/dist/codebase-to-spec/compose.d.ts +7 -4
  7. package/dist/codebase-to-spec/compose.js +10 -21
  8. package/dist/codebase-to-spec/dispatch.js +1 -1
  9. package/dist/codebase-to-spec/present.d.ts +9 -0
  10. package/dist/codebase-to-spec/present.js +30 -4
  11. package/dist/codebase-to-spec/prompts/planner-initial.d.ts +3 -2
  12. package/dist/codebase-to-spec/prompts/planner-initial.js +3 -2
  13. package/dist/codebase-to-spec/renumber.d.ts +52 -0
  14. package/dist/codebase-to-spec/renumber.js +105 -0
  15. package/dist/codebase-to-spec/schemas.d.ts +35 -240
  16. package/dist/codebase-to-spec/schemas.js +5 -173
  17. package/dist/codebase-to-spec/skill-install.d.ts +16 -6
  18. package/dist/codebase-to-spec/skill-install.js +109 -47
  19. package/dist/codebase-to-spec/version-check.d.ts +31 -0
  20. package/dist/codebase-to-spec/version-check.js +56 -0
  21. package/dist/commands/ai-setup.d.ts +12 -1
  22. package/dist/commands/ai-setup.js +65 -33
  23. package/dist/commands/codebase-to-spec/dispatch-editor.js +1 -1
  24. package/dist/commands/codebase-to-spec/dispatch-spec.js +1 -1
  25. package/dist/commands/codebase-to-spec/index.js +7 -103
  26. package/dist/commands/codebase-to-spec/pack.d.ts +4 -0
  27. package/dist/commands/codebase-to-spec/pack.js +25 -1
  28. package/dist/commands/codebase-to-spec/present-orchestrator.d.ts +3 -1
  29. package/dist/commands/codebase-to-spec/present-orchestrator.js +11 -4
  30. package/dist/commands/init.js +6 -1
  31. package/dist/commands/link-resolution.d.ts +79 -0
  32. package/dist/commands/link-resolution.js +141 -0
  33. package/dist/commands/link.d.ts +14 -4
  34. package/dist/commands/link.js +369 -16
  35. package/dist/commands/pull.js +19 -2
  36. package/dist/commands/push.js +36 -2
  37. package/dist/convex.d.ts +5 -3
  38. package/dist/convex.js +5 -3
  39. package/dist/harness/cache.d.ts +0 -14
  40. package/dist/harness/cache.js +1 -41
  41. package/dist/harness/finalize.js +2 -2
  42. package/dist/harness/prepare.js +1 -3
  43. package/dist/harness/requirementsLoader.d.ts +3 -3
  44. package/dist/harness/requirementsLoader.js +13 -8
  45. package/dist/mcp/handlers/authoring.d.ts +5 -5
  46. package/dist/mcp/handlers/authoring.js +9 -9
  47. package/dist/mcp/handlers/push.d.ts +2 -2
  48. package/dist/mcp/handlers/push.js +36 -3
  49. package/dist/mcp/handlers/review.d.ts +4 -4
  50. package/dist/mcp/handlers/review.js +4 -4
  51. package/dist/mcp/handlers/search.d.ts +1 -1
  52. package/dist/mcp/handlers/search.js +1 -1
  53. package/dist/mcp/index.js +29 -0
  54. package/dist/push/core.d.ts +18 -0
  55. package/dist/push/core.js +70 -3
  56. package/dist/push/index.d.ts +1 -1
  57. package/dist/push/index.js +1 -1
  58. package/dist/schema/parser-core.js +5 -1
  59. package/dist/schema/parser.js +5 -1
  60. package/dist/schema/run-marker.d.ts +38 -0
  61. package/dist/schema/run-marker.js +138 -0
  62. package/dist/schema/schemas.d.ts +12 -0
  63. package/dist/schema/schemas.js +1 -0
  64. package/dist/templates/agents/cts-worker.md +1 -1
  65. package/dist/templates/skills/codebase-to-spec/SKILL.md +39 -11
  66. package/dist/templates/workflows/specify-codebase.js +4 -2
  67. package/dist/utils/own-package.d.ts +10 -0
  68. package/dist/utils/own-package.js +13 -0
  69. package/dist/utils/project-selector.d.ts +5 -0
  70. package/dist/utils/project-selector.js +4 -0
  71. package/package.json +2 -2
  72. package/dist/codebase-to-spec/edit-loop.d.ts +0 -54
  73. package/dist/codebase-to-spec/edit-loop.js +0 -195
  74. package/dist/codebase-to-spec/editor.d.ts +0 -54
  75. package/dist/codebase-to-spec/editor.js +0 -74
  76. package/dist/codebase-to-spec/fan-out.d.ts +0 -63
  77. package/dist/codebase-to-spec/fan-out.js +0 -215
  78. package/dist/codebase-to-spec/outline-review-loop.d.ts +0 -51
  79. package/dist/codebase-to-spec/outline-review-loop.js +0 -187
  80. package/dist/codebase-to-spec/planner.d.ts +0 -41
  81. package/dist/codebase-to-spec/planner.js +0 -76
  82. package/dist/codebase-to-spec/prompts/outline-reviewer.d.ts +0 -12
  83. package/dist/codebase-to-spec/prompts/outline-reviewer.js +0 -89
  84. package/dist/codebase-to-spec/slice.d.ts +0 -49
  85. package/dist/codebase-to-spec/slice.js +0 -111
  86. package/dist/codebase-to-spec/specifier.d.ts +0 -60
  87. package/dist/codebase-to-spec/specifier.js +0 -85
  88. package/dist/codebase-to-spec/summary.d.ts +0 -51
  89. package/dist/codebase-to-spec/summary.js +0 -183
  90. package/dist/commands/codebase-to-spec/compose.d.ts +0 -14
  91. package/dist/commands/codebase-to-spec/compose.js +0 -57
  92. package/dist/commands/codebase-to-spec/edit-loop.d.ts +0 -16
  93. package/dist/commands/codebase-to-spec/edit-loop.js +0 -83
  94. package/dist/commands/codebase-to-spec/fan-out.d.ts +0 -19
  95. package/dist/commands/codebase-to-spec/fan-out.js +0 -77
  96. package/dist/commands/codebase-to-spec/plan-loop.d.ts +0 -26
  97. package/dist/commands/codebase-to-spec/plan-loop.js +0 -105
  98. package/dist/commands/codebase-to-spec/present.d.ts +0 -26
  99. package/dist/commands/codebase-to-spec/present.js +0 -97
  100. package/dist/commands/codebase-to-spec/run.d.ts +0 -20
  101. package/dist/commands/codebase-to-spec/run.js +0 -86
  102. package/dist/commands/codebase-to-spec/specify-area.d.ts +0 -18
  103. package/dist/commands/codebase-to-spec/specify-area.js +0 -82
@@ -1,25 +1,314 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import { ConvexHttpClient } from "convex/browser";
2
4
  import { getConvexUrl } from "../config.js";
5
+ import { api } from "../convex.js";
6
+ import { WEB_APP_URL } from "../push/index.js";
3
7
  import { brand } from "../utils/brand.js";
4
8
  import { ensureGitignore } from "../utils/gitignore.js";
5
9
  import { executeOAuthFlow } from "../utils/oauth-flow.js";
6
- import { getOrCreateProjectSecret, promptExpiryDays, selectProject, selectTeam, } from "../utils/project-selector.js";
10
+ import { getOrCreateProjectSecret, promptExpiryDays, selectTeam, } from "../utils/project-selector.js";
7
11
  import { findProjectRoot, readProjectSettings, writeProjectSettings, } from "../utils/project-settings.js";
8
12
  import { promptChoice, promptConfirm } from "../utils/prompts.js";
13
+ import { hasCapacity, isNonInteractive, resolveProject, resolveProjectName, resolveTeam, } from "./link-resolution.js";
9
14
  import { pullCommand } from "./pull.js";
10
15
  /**
11
- * Link command - connect or reconnect local project to cloud.
16
+ * Link command - connect or reconnect local project to cloud, creating the
17
+ * cloud project when needed. Link owns the "upgrade local to cloud"
18
+ * transition.
12
19
  *
13
20
  * Requirements:
14
21
  * - LINK-1: Requires existing .requirements/ folder
15
22
  * - LINK-2: Offers refresh when credentials exist
16
23
  * - LINK-3: Uses inline OAuth (no persistent tokens)
17
- * - LINK-4, LINK-5: Team and project selection
24
+ * - LINK-4, LINK-5: Team and project selection (including the create option)
18
25
  * - LINK-6: Saves credentials to project-settings.json
19
- * - LINK-7: Optionally pulls requirements
26
+ * - LINK-7: Optionally pulls requirements (interactive only)
20
27
  * - LINK-8: Handles network errors gracefully
28
+ * - LINK-9: Creates the cloud project when chosen
29
+ * - LINK-10/11/12: Non-interactive mode for AI-driven setup
30
+ * - LINK-13: Non-interactive success includes share artifacts (team-invite
31
+ * URL + read-only pull command) so one "yes" puts the spec in front of
32
+ * the team
21
33
  */
22
- export async function linkCommand() {
34
+ export async function linkCommand(options = {}) {
35
+ if (isNonInteractive(options)) {
36
+ await runNonInteractiveLink(options);
37
+ return;
38
+ }
39
+ await runInteractiveLink(options);
40
+ }
41
+ const EXIT_DECISION_NEEDED = 2;
42
+ async function runNonInteractiveLink(options) {
43
+ // LINK-10.5/11.3: in JSON mode the single stdout line is the result object;
44
+ // every informational message (including the OAuth flow's, and the browser
45
+ // fallback URL) goes to stderr so the output stays machine-parseable.
46
+ const originalLog = console.log;
47
+ if (options.json) {
48
+ console.log = (...args) => console.error(...args);
49
+ }
50
+ const finish = (outcome) => {
51
+ console.log = originalLog;
52
+ if (options.json) {
53
+ originalLog(JSON.stringify(outcome));
54
+ }
55
+ else if (outcome.status === "decision_needed") {
56
+ console.log(`\n${outcome.message}`);
57
+ for (const opt of outcome.options) {
58
+ console.log(` - ${opt.label}${opt.retryFlag ? ` (retry with: dotrequirements link ${opt.retryFlag})` : ""}`);
59
+ }
60
+ }
61
+ else if (outcome.status === "error") {
62
+ console.error(`\n✗ ${outcome.message}`);
63
+ }
64
+ else {
65
+ console.log(`\n✓ Linked to project: ${outcome.projectName}${outcome.renamed ? " (renamed to match this repository)" : ""}`);
66
+ console.log(` Project ID: ${outcome.projectSlug}`);
67
+ // LINK-13: hand over the share mechanics, one per recipient surface
68
+ if (outcome.inviteUrl) {
69
+ console.log(` Invite a teammate (web): ${outcome.inviteUrl}`);
70
+ }
71
+ if (outcome.sharePullCommand) {
72
+ console.log(` Share read-only to an IDE: ${outcome.sharePullCommand}`);
73
+ }
74
+ }
75
+ if (outcome.status === "decision_needed") {
76
+ process.exitCode = EXIT_DECISION_NEEDED;
77
+ }
78
+ else if (outcome.status === "error") {
79
+ process.exitCode = 1;
80
+ }
81
+ };
82
+ try {
83
+ // LINK-1: same precondition as interactive
84
+ const cwd = process.cwd();
85
+ const projectRoot = findProjectRoot(cwd);
86
+ if (!projectRoot || projectRoot !== cwd) {
87
+ finish({
88
+ status: "error",
89
+ message: 'No .requirements/ folder found. Run "dotrequirements init" to initialize a project first.',
90
+ });
91
+ return;
92
+ }
93
+ // LINK-10.6: existing credentials default to refresh-same-project
94
+ let refreshTargetSlug;
95
+ try {
96
+ const existing = readProjectSettings(projectRoot);
97
+ refreshTargetSlug = existing?.projectId;
98
+ }
99
+ catch {
100
+ refreshTargetSlug = undefined;
101
+ }
102
+ // LINK-3 / LINK-10.7: browser auth is the only human step
103
+ const oauthResult = await executeOAuthFlow();
104
+ const client = new ConvexHttpClient(getConvexUrl());
105
+ client.setAuth(oauthResult.accessToken);
106
+ await client.mutation(api.teams.mutations.ensureDefaultTeam, {});
107
+ const teams = (await client.query(api.teams.queries.listForUserWithUsage));
108
+ if (teams.length === 0) {
109
+ finish({
110
+ status: "error",
111
+ message: "You are not a member of any teams. Create one at https://app.dotrequirements.io/teams/new and retry.",
112
+ });
113
+ return;
114
+ }
115
+ // LINK-10.6: refresh path — find the already-linked project. A deviation
116
+ // flag (--team/--connect/--create, LINK-12) overrides the refresh default
117
+ // and falls through to normal resolution. Settings store the raw
118
+ // projectId when a project has no slug, so match either.
119
+ if (refreshTargetSlug &&
120
+ !options.connect &&
121
+ !options.create &&
122
+ !options.team) {
123
+ for (const team of teams) {
124
+ const projects = await listProjects(client, team.teamId);
125
+ const match = projects.find((p) => p.projectSlug === refreshTargetSlug ||
126
+ p.projectId === refreshTargetSlug);
127
+ if (match) {
128
+ const settingsPath = await connectToProject(client, projectRoot, match.projectId, refreshTargetSlug);
129
+ // LINK-13.2: ensureShareToken is idempotent, so a refresh hands
130
+ // back the same share command rather than minting a new token
131
+ const artifacts = await mintShareArtifacts(client, team.teamId, match.projectId);
132
+ finish({
133
+ status: "linked",
134
+ projectSlug: refreshTargetSlug,
135
+ projectName: match.projectName,
136
+ renamed: false,
137
+ settingsPath,
138
+ ...artifacts,
139
+ });
140
+ return;
141
+ }
142
+ }
143
+ finish({
144
+ status: "error",
145
+ message: `Currently-linked project "${refreshTargetSlug}" was not found in your teams. Pass --connect <slug> to link a different project.`,
146
+ });
147
+ return;
148
+ }
149
+ const teamResolution = resolveTeam(teams, options);
150
+ if (teamResolution.kind === "no-match") {
151
+ finish({
152
+ status: "error",
153
+ message: `No team matched "${teamResolution.provided}". Your teams: ${teamResolution.alternatives.join(", ")}`,
154
+ });
155
+ return;
156
+ }
157
+ if (teamResolution.kind === "decision") {
158
+ const { kind: _kind, ...decision } = teamResolution;
159
+ finish({ status: "decision_needed", ...decision });
160
+ return;
161
+ }
162
+ const team = teamResolution.team;
163
+ const projects = await listProjects(client, team.teamId);
164
+ const projectResolution = resolveProject(projects, team, options);
165
+ if (projectResolution.kind === "no-match") {
166
+ finish({
167
+ status: "error",
168
+ message: `No project matched "${projectResolution.provided}". Projects in ${team.name}: ${projectResolution.alternatives.join(", ") || "(none)"}`,
169
+ });
170
+ return;
171
+ }
172
+ if (projectResolution.kind === "decision") {
173
+ const { kind: _kind, ...decision } = projectResolution;
174
+ finish({ status: "decision_needed", ...decision });
175
+ return;
176
+ }
177
+ if (projectResolution.kind === "create") {
178
+ // LINK-9 via LINK-12.2 (or zero-projects default)
179
+ const name = resolveProjectName(options, readPackageName(cwd), path.basename(cwd));
180
+ const created = (await client.mutation(api.projects.mutations.create, {
181
+ name,
182
+ teamId: team.teamId,
183
+ }));
184
+ const settingsPath = await connectToProject(client, projectRoot, created.projectId, created.slug);
185
+ const artifacts = await mintShareArtifacts(client, team.teamId, created.projectId);
186
+ finish({
187
+ status: "linked",
188
+ projectSlug: created.slug,
189
+ projectName: name,
190
+ renamed: false,
191
+ settingsPath,
192
+ ...artifacts,
193
+ });
194
+ return;
195
+ }
196
+ // connect (possibly renaming the empty default project, LINK-10.2)
197
+ let projectName = projectResolution.project.projectName;
198
+ let renamed = false;
199
+ if (projectResolution.rename) {
200
+ const newName = resolveProjectName(options, readPackageName(cwd), path.basename(cwd));
201
+ try {
202
+ await client.mutation(api.projects.mutations.update, {
203
+ target: {
204
+ type: "project",
205
+ // biome-ignore lint/suspicious/noExplicitAny: CLI workspace doesn't import convex's branded Id type; the runtime value is a plain string the server validates.
206
+ id: projectResolution.project.projectId,
207
+ },
208
+ name: newName,
209
+ });
210
+ projectName = newName;
211
+ renamed = true;
212
+ }
213
+ catch {
214
+ // LINK-10.9: renaming is admin-gated server-side — a non-admin member
215
+ // (e.g. invited via init --invite) still gets connected, just without
216
+ // the rename
217
+ console.error(`Note: couldn't rename the default project to "${newName}" (team admin required) — connected without renaming.`);
218
+ }
219
+ }
220
+ const slug = projectResolution.project.projectSlug ??
221
+ projectResolution.project.projectId;
222
+ const settingsPath = await connectToProject(client, projectRoot, projectResolution.project.projectId, slug);
223
+ const artifacts = await mintShareArtifacts(client, team.teamId, projectResolution.project.projectId);
224
+ finish({
225
+ status: "linked",
226
+ projectSlug: projectResolution.project.projectSlug,
227
+ projectName,
228
+ renamed,
229
+ settingsPath,
230
+ ...artifacts,
231
+ });
232
+ }
233
+ catch (error) {
234
+ finish({
235
+ status: "error",
236
+ message: error instanceof Error ? error.message : String(error),
237
+ });
238
+ }
239
+ finally {
240
+ console.log = originalLog;
241
+ }
242
+ }
243
+ /**
244
+ * LINK-13: mint the artifacts that put the spec in front of the team — a
245
+ * single-use team-invite URL (web reviewers) and a read-only pull command
246
+ * (IDE teammates). Either mint may fail without failing the connection
247
+ * (LINK-13.3): createInvite is admin-gated, so non-admin members link
248
+ * successfully and the invite URL is simply omitted.
249
+ */
250
+ async function mintShareArtifacts(client, teamId, projectId) {
251
+ const artifacts = {};
252
+ try {
253
+ const invite = (await client.mutation(api.teamInvites.mutations.createInvite,
254
+ // biome-ignore lint/suspicious/noExplicitAny: CLI workspace doesn't import convex's branded Id type; the runtime value is a plain string the server validates.
255
+ { target: { type: "team", id: teamId } }));
256
+ artifacts.inviteUrl = `${WEB_APP_URL}/invite/${invite.token}`;
257
+ }
258
+ catch {
259
+ // LINK-13.3: admin-gated — omit the invite URL for non-admin members
260
+ }
261
+ try {
262
+ const share = (await client.mutation(api.projectSecrets.mutations.ensureShareToken,
263
+ // biome-ignore lint/suspicious/noExplicitAny: CLI workspace doesn't import convex's branded Id type; the runtime value is a plain string the server validates.
264
+ { target: { type: "project", id: projectId } }));
265
+ artifacts.sharePullCommand = `npx -y @popoverai/dotrequirements pull --share ${share.token}`;
266
+ }
267
+ catch {
268
+ // LINK-13.3: connection already succeeded — omit rather than fail
269
+ }
270
+ return artifacts;
271
+ }
272
+ async function listProjects(client, teamId) {
273
+ const projects = (await client.query(api.projectSecrets.queries.listOwnSecretsForTeam,
274
+ // biome-ignore lint/suspicious/noExplicitAny: CLI workspace doesn't import convex's branded Id type; the runtime value is a plain string the server validates.
275
+ { target: { type: "team", id: teamId } }));
276
+ return projects.map((p) => ({
277
+ projectId: p.projectId,
278
+ projectName: p.projectName,
279
+ projectSlug: p.projectSlug,
280
+ isDefault: p.isDefault ?? false,
281
+ // Older servers don't report content state; treat unknown as content so
282
+ // we never rename something we can't vouch for
283
+ hasUserContent: p.hasUserContent ?? true,
284
+ }));
285
+ }
286
+ /**
287
+ * Mint (or fetch) a secret and write credentials (LINK-6). Returns the
288
+ * settings file path. Non-interactive: tier-default expiry, no prompts.
289
+ */
290
+ async function connectToProject(client, projectRoot, projectId, slug) {
291
+ const secretResult = await getOrCreateProjectSecret(client, projectId, undefined);
292
+ writeProjectSettings(projectRoot, {
293
+ projectId: slug,
294
+ projectSecret: secretResult.secret,
295
+ });
296
+ ensureGitignore(projectRoot);
297
+ return path.join(projectRoot, ".requirements", "project-settings.json");
298
+ }
299
+ function readPackageName(cwd) {
300
+ try {
301
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"));
302
+ return typeof pkg.name === "string" ? pkg.name : undefined;
303
+ }
304
+ catch {
305
+ return undefined;
306
+ }
307
+ }
308
+ // ============================================================================
309
+ // Interactive mode
310
+ // ============================================================================
311
+ async function runInteractiveLink(options) {
23
312
  try {
24
313
  console.log(`Linking to ${brand} cloud...\n`);
25
314
  const cwd = process.cwd();
@@ -65,19 +354,38 @@ export async function linkCommand() {
65
354
  console.log("Link cancelled.");
66
355
  return;
67
356
  }
68
- // LINK-5: Select project
69
- const selectedProject = await selectProject(client, selectedTeam.teamId);
70
- if (!selectedProject) {
71
- // selectProject already shows message about no projects
357
+ // LINK-5: Select project — or create one (LINK-5.1, LINK-9)
358
+ const selection = await selectProjectOrCreate(client, selectedTeam);
359
+ if (selection === null) {
360
+ console.log("Link cancelled.");
72
361
  return;
73
362
  }
363
+ let projectId;
364
+ let projectName;
365
+ let projectSlugOrId;
366
+ if (selection === "create") {
367
+ // LINK-9: create with the detected default name (-n/--name overrides)
368
+ const defaultName = resolveProjectName(options, readPackageName(cwd), path.basename(cwd));
369
+ console.log(`\nCreating project "${defaultName}"...`);
370
+ const created = (await client.mutation(api.projects.mutations.create, {
371
+ name: defaultName,
372
+ teamId: selectedTeam.teamId,
373
+ }));
374
+ projectId = created.projectId;
375
+ projectName = defaultName;
376
+ projectSlugOrId = created.slug;
377
+ console.log(`✓ Created project: ${created.slug}`);
378
+ }
379
+ else {
380
+ projectId = selection.projectId;
381
+ projectName = selection.projectName;
382
+ projectSlugOrId = selection.projectSlug || selection.projectId;
383
+ }
74
384
  // AUTHZ-3: Prompt for secret expiry (paid tier only)
75
385
  const expiryDays = await promptExpiryDays(selectedTeam.tier);
76
386
  // LINK-5.3: Get or create secret for this project
77
387
  console.log("\nGetting project credentials...");
78
- const secretResult = await getOrCreateProjectSecret(client, selectedProject.projectId, expiryDays);
79
- const projectId = selectedProject.projectSlug || selectedProject.projectId;
80
- const secret = secretResult.secret;
388
+ const secretResult = await getOrCreateProjectSecret(client, projectId, expiryDays);
81
389
  if (secretResult.created) {
82
390
  console.log("✓ Created new project secret");
83
391
  }
@@ -85,18 +393,21 @@ export async function linkCommand() {
85
393
  console.log("✓ Retrieved existing project secret");
86
394
  }
87
395
  // LINK-6: Write credentials to project-settings.json
88
- writeProjectSettings(projectRoot, { projectId, projectSecret: secret });
396
+ writeProjectSettings(projectRoot, {
397
+ projectId: projectSlugOrId,
398
+ projectSecret: secretResult.secret,
399
+ });
89
400
  ensureGitignore(projectRoot);
90
401
  console.log("✓ Wrote credentials to .requirements/project-settings.json");
91
402
  // LINK-7: Optionally pull requirements
92
403
  const shouldPull = await promptConfirm("Pull requirements from cloud now?", true);
93
404
  if (shouldPull) {
94
405
  console.log("\nPulling requirements...");
95
- await pullCommand({ project: projectId });
406
+ await pullCommand({ project: projectSlugOrId });
96
407
  }
97
408
  // LINK-7.2: Success message
98
- console.log(`\n✓ Successfully linked to project: ${selectedProject.projectName}`);
99
- console.log(` Project ID: ${projectId}`);
409
+ console.log(`\n✓ Successfully linked to project: ${projectName}`);
410
+ console.log(` Project ID: ${projectSlugOrId}`);
100
411
  console.log("\nDocumentation: https://docs.dotrequirements.io/getting-started");
101
412
  }
102
413
  catch (error) {
@@ -109,6 +420,48 @@ export async function linkCommand() {
109
420
  process.exit(1);
110
421
  }
111
422
  }
423
+ /**
424
+ * LINK-5: project selection including the create option (LINK-5.1) when the
425
+ * team has capacity. Returns the chosen project, "create", or null (cancel).
426
+ */
427
+ async function selectProjectOrCreate(client, team) {
428
+ console.log("\nFetching projects...\n");
429
+ const projects = await listProjects(client, team.teamId);
430
+ const teamOption = {
431
+ teamId: team.teamId,
432
+ name: team.name ?? "this team",
433
+ tier: team.tier,
434
+ tierName: team.tierName ?? team.tier,
435
+ projectCount: team.projectCount ?? projects.length,
436
+ projectLimit: team.projectLimit ?? -1,
437
+ };
438
+ const canCreate = hasCapacity(teamOption);
439
+ if (projects.length === 0) {
440
+ if (!canCreate) {
441
+ // LINK-5.2: connect is impossible; the remedies live elsewhere
442
+ console.log("This team has no projects and is at its project limit.");
443
+ console.log("Delete a project you no longer need (in the web app) or upgrade the team, then retry.\n");
444
+ return null;
445
+ }
446
+ const confirmed = await promptConfirm("This team has no projects. Create a new one?", true);
447
+ return confirmed ? "create" : null;
448
+ }
449
+ const choices = projects.map((p) => ({
450
+ title: p.projectSlug
451
+ ? `${p.projectName} (${p.projectSlug})`
452
+ : p.projectName,
453
+ value: p.projectId,
454
+ }));
455
+ if (canCreate) {
456
+ choices.push({ title: "Create a new project", value: "CREATE_NEW" });
457
+ }
458
+ const selected = await promptChoice("Select a project:", choices);
459
+ if (!selected)
460
+ return null;
461
+ if (selected === "CREATE_NEW")
462
+ return "create";
463
+ return projects.find((p) => p.projectId === selected) ?? null;
464
+ }
112
465
  /**
113
466
  * LINK-2: Handle existing credentials - offer refresh or connect to different project
114
467
  * Returns true if user wants to continue (either refresh or different project)
@@ -32,9 +32,14 @@ export async function pullCommand(options) {
32
32
  console.log(`\nProject: "${projectName}"`);
33
33
  }
34
34
  catch (error) {
35
- // SHARE-TOKEN-CLI-2: Handle invalid/revoked tokens gracefully
35
+ // SHARE-TOKEN-CLI-2: Handle invalid/revoked tokens gracefully.
36
+ // Production redacts ConvexError messages to "Server Error" but
37
+ // preserves error.data — check both, or the friendly message only
38
+ // ever appears against dev deployments.
39
+ const data = error.data;
36
40
  const errorMessage = error instanceof Error ? error.message : String(error);
37
- if (errorMessage.includes("Invalid share token")) {
41
+ if (data?.message === "Invalid share token" ||
42
+ errorMessage.includes("Invalid share token")) {
38
43
  throw new Error("Invalid share token. The token may be incorrect or has been revoked.\n" +
39
44
  "Please request a new share token from your team.");
40
45
  }
@@ -96,11 +101,23 @@ export async function pullCommand(options) {
96
101
  const filePath = existingFilePath ??
97
102
  path.join(requirementsDir, `${sanitizeFileName(doc.title)}.requirements.md`);
98
103
  const fileName = path.basename(filePath);
104
+ // IMPORT-1: carry the CTS run marker forward from the existing local
105
+ // file — pull rebuilds frontmatter from cloud data, and silently dropping
106
+ // the marker would strip a committed spec's import credential before a
107
+ // teammate ever pushes it
108
+ let existingCtsRun;
109
+ if (existingFilePath && fs.existsSync(existingFilePath)) {
110
+ const match = fs
111
+ .readFileSync(existingFilePath, "utf-8")
112
+ .match(/^ctsRun: (.+)$/m);
113
+ existingCtsRun = match?.[1].trim();
114
+ }
99
115
  // Build metadata with pulledAt for conflict detection
100
116
  // SYNC-CLI-EDIT-2: pulledAt is compared with cloud updatedAt during push
101
117
  const metadata = {
102
118
  pulledAt: new Date().toISOString(),
103
119
  version: doc.version,
120
+ ...(existingCtsRun ? { ctsRun: existingCtsRun } : {}),
104
121
  document: {
105
122
  id: doc.documentId,
106
123
  title: doc.title,
@@ -100,16 +100,50 @@ export async function pushCommand(file, options) {
100
100
  console.log(` ✓ Updated: ${fileName}`);
101
101
  }
102
102
  }
103
- console.log(`\n✓ Push complete!`);
103
+ // IMPORT-3: marker failures are loud but never fatal
104
+ if (result.importWarnings.length > 0) {
105
+ console.log();
106
+ for (const { fileName, status } of result.importWarnings) {
107
+ if (status === "already_used") {
108
+ console.log(`⚠ ${fileName}: this file's import marker was already used, so its requirements were saved as natively authored (they count toward your plan's requirement limit). Re-running codebase-to-spec produces a fresh marker.`);
109
+ }
110
+ else {
111
+ console.log(`⚠ ${fileName}: this file's import marker was not recognized — your CLI may need updating. Its requirements were saved as natively authored (they count toward your plan's requirement limit).`);
112
+ }
113
+ }
114
+ }
115
+ // SYNC-FAIL-1: a failed push must not masquerade as success — agents and
116
+ // scripts drive this command and rely on the exit code to tell the truth
117
+ const failed = result.errors.length;
118
+ const syncedCount = result.created + result.updated;
119
+ if (failed > 0) {
120
+ process.exitCode = 1;
121
+ if (syncedCount === 0) {
122
+ console.log(`\n✗ Push failed: ${failed} document(s) could not be saved.`);
123
+ }
124
+ else {
125
+ console.log(`\n⚠ Push incomplete: ${syncedCount} document(s) synced, ${failed} failed.`);
126
+ }
127
+ }
128
+ else {
129
+ console.log(`\n✓ Push complete!`);
130
+ }
104
131
  if (result.created > 0) {
105
132
  console.log(` Created: ${result.created} document(s)`);
106
133
  }
107
134
  if (result.updated > 0) {
108
135
  console.log(` Updated: ${result.updated} document(s)`);
109
136
  }
110
- if (result.created === 0 && result.updated === 0) {
137
+ if (failed === 0 && syncedCount === 0) {
111
138
  console.log(" No documents were pushed.");
112
139
  }
140
+ // SYNC-LAND-1: land the user on their spec, not at a dead end
141
+ if (result.synced.length > 0) {
142
+ console.log("\nReview in dot•requirements:");
143
+ for (const doc of result.synced) {
144
+ console.log(` ${doc.fileName} → ${doc.url}`);
145
+ }
146
+ }
113
147
  }
114
148
  /**
115
149
  * Display the dry run summary.
package/dist/convex.d.ts CHANGED
@@ -34,13 +34,11 @@ export declare const api: {
34
34
  queries: {
35
35
  listByProject: import("convex/server").FunctionReference<"query", "public", any, any, string | undefined>;
36
36
  };
37
- mutations: {
38
- bulkUpdate: import("convex/server").FunctionReference<"mutation", "public", any, any, string | undefined>;
39
- };
40
37
  };
41
38
  projects: {
42
39
  mutations: {
43
40
  create: import("convex/server").FunctionReference<"mutation", "public", any, any, string | undefined>;
41
+ update: import("convex/server").FunctionReference<"mutation", "public", any, any, string | undefined>;
44
42
  };
45
43
  };
46
44
  teams: {
@@ -55,6 +53,9 @@ export declare const api: {
55
53
  queries: {
56
54
  getInviteInfo: import("convex/server").FunctionReference<"query", "public", any, any, string | undefined>;
57
55
  };
56
+ mutations: {
57
+ createInvite: import("convex/server").FunctionReference<"mutation", "public", any, any, string | undefined>;
58
+ };
58
59
  };
59
60
  polar: {
60
61
  acceptTeamInvite: import("convex/server").FunctionReference<"action", "public", any, any, string | undefined>;
@@ -67,6 +68,7 @@ export declare const api: {
67
68
  mutations: {
68
69
  ensureOwnSecret: import("convex/server").FunctionReference<"mutation", "public", any, any, string | undefined>;
69
70
  revokeOwnSecret: import("convex/server").FunctionReference<"mutation", "public", any, any, string | undefined>;
71
+ ensureShareToken: import("convex/server").FunctionReference<"mutation", "public", any, any, string | undefined>;
70
72
  };
71
73
  };
72
74
  lib: {
package/dist/convex.js CHANGED
@@ -40,13 +40,11 @@ export const api = {
40
40
  queries: {
41
41
  listByProject: query("requirements/queries:listByProject"),
42
42
  },
43
- mutations: {
44
- bulkUpdate: mutation("requirements/mutations:bulkUpdate"),
45
- },
46
43
  },
47
44
  projects: {
48
45
  mutations: {
49
46
  create: mutation("projects/mutations:create"),
47
+ update: mutation("projects/mutations:update"),
50
48
  },
51
49
  },
52
50
  teams: {
@@ -61,6 +59,9 @@ export const api = {
61
59
  queries: {
62
60
  getInviteInfo: query("teamInvites/queries:getInviteInfo"),
63
61
  },
62
+ mutations: {
63
+ createInvite: mutation("teamInvites/mutations:createInvite"),
64
+ },
64
65
  },
65
66
  polar: {
66
67
  acceptTeamInvite: action("polar:acceptTeamInvite"),
@@ -73,6 +74,7 @@ export const api = {
73
74
  mutations: {
74
75
  ensureOwnSecret: mutation("projectSecrets/mutations:ensureOwnSecret"),
75
76
  revokeOwnSecret: mutation("projectSecrets/mutations:revokeOwnSecret"),
77
+ ensureShareToken: mutation("projectSecrets/mutations:ensureShareToken"),
76
78
  },
77
79
  },
78
80
  lib: {
@@ -94,20 +94,6 @@ export declare function getTestRunId(requirementsDir: string): string | null;
94
94
  * Clean up the test run ID file
95
95
  */
96
96
  export declare function cleanupTestRunId(requirementsDir: string): void;
97
- /**
98
- * Write the project root to cache (for cross-process persistence).
99
- * This allows test workers to find the lookup cache even after cwd changes.
100
- */
101
- export declare function writeProjectRoot(requirementsDir: string, projectRoot: string): void;
102
- /**
103
- * Read the project root from cache, returning null if not found.
104
- */
105
- export declare function readProjectRoot(requirementsDir: string): string | null;
106
- /**
107
- * Try to find the project root from any known .requirements cache directory.
108
- * Walks up from cwd looking for .requirements/.cache/project-root file.
109
- */
110
- export declare function findCachedProjectRoot(startDir?: string): string | null;
111
97
  /**
112
98
  * Clear tracking data for a new test run
113
99
  */