carrick 0.3.82 → 0.3.83

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 (73) hide show
  1. package/README.md +46 -19
  2. package/bin/carrick.mjs +45 -2
  3. package/dist/contract.d.ts +17 -0
  4. package/dist/contract.js.map +1 -1
  5. package/dist/hook/apply-patch.d.ts +13 -0
  6. package/dist/hook/apply-patch.js +100 -0
  7. package/dist/hook/apply-patch.js.map +1 -0
  8. package/dist/hook/post-edit.d.ts +30 -1
  9. package/dist/hook/post-edit.js +95 -24
  10. package/dist/hook/post-edit.js.map +1 -1
  11. package/dist/hook/reuse.d.ts +97 -0
  12. package/dist/hook/reuse.js +245 -0
  13. package/dist/hook/reuse.js.map +1 -0
  14. package/dist/hook/stop.d.ts +3 -0
  15. package/dist/hook/stop.js +76 -0
  16. package/dist/hook/stop.js.map +1 -0
  17. package/dist/hook/user-prompt.d.ts +9 -0
  18. package/dist/hook/user-prompt.js +79 -0
  19. package/dist/hook/user-prompt.js.map +1 -0
  20. package/dist/init/codex.d.ts +51 -0
  21. package/dist/init/codex.js +167 -0
  22. package/dist/init/codex.js.map +1 -0
  23. package/dist/init/connect.d.ts +13 -0
  24. package/dist/init/connect.js +21 -15
  25. package/dist/init/connect.js.map +1 -1
  26. package/dist/init/doctor.d.ts +36 -0
  27. package/dist/init/doctor.js +97 -2
  28. package/dist/init/doctor.js.map +1 -1
  29. package/dist/init/files.d.ts +15 -0
  30. package/dist/init/files.js +20 -1
  31. package/dist/init/files.js.map +1 -1
  32. package/dist/init/outdated.d.ts +54 -0
  33. package/dist/init/outdated.js +175 -0
  34. package/dist/init/outdated.js.map +1 -0
  35. package/dist/init/output.d.ts +35 -3
  36. package/dist/init/output.js +115 -23
  37. package/dist/init/output.js.map +1 -1
  38. package/dist/init/projects.d.ts +27 -13
  39. package/dist/init/projects.js +48 -50
  40. package/dist/init/projects.js.map +1 -1
  41. package/dist/init/remove.d.ts +0 -2
  42. package/dist/init/remove.js +61 -15
  43. package/dist/init/remove.js.map +1 -1
  44. package/dist/init/repos.d.ts +34 -0
  45. package/dist/init/repos.js +77 -0
  46. package/dist/init/repos.js.map +1 -1
  47. package/dist/init/run.d.ts +73 -6
  48. package/dist/init/run.js +357 -94
  49. package/dist/init/run.js.map +1 -1
  50. package/dist/init/settings.d.ts +20 -0
  51. package/dist/init/settings.js +42 -4
  52. package/dist/init/settings.js.map +1 -1
  53. package/dist/init/task-skills.d.ts +27 -0
  54. package/dist/init/task-skills.js +64 -1
  55. package/dist/init/task-skills.js.map +1 -1
  56. package/dist/init/workspace-file.d.ts +73 -0
  57. package/dist/init/workspace-file.js +173 -0
  58. package/dist/init/workspace-file.js.map +1 -0
  59. package/package.json +6 -6
  60. package/plugin/hooks/hooks.json +11 -0
  61. package/sidecar/dist/src/capture/check-classify.d.ts +10 -1
  62. package/sidecar/dist/src/capture/check-classify.js +66 -8
  63. package/sidecar/dist/src/capture/check-deep.d.ts +16 -4
  64. package/sidecar/dist/src/capture/check-deep.js +21 -17
  65. package/sidecar/dist/src/capture/check-fields.d.ts +83 -0
  66. package/sidecar/dist/src/capture/check-fields.js +259 -0
  67. package/sidecar/dist/src/capture/check-probe.d.ts +21 -1
  68. package/sidecar/dist/src/capture/check-probe.js +39 -0
  69. package/sidecar/dist/src/capture/check.js +9 -2
  70. package/templates/skills/carrick-census.md +18 -9
  71. package/templates/skills/carrick-drift.md +7 -1
  72. package/templates/skills/carrick-impact.md +3 -1
  73. package/templates/skills/carrick-reuse.md +43 -26
package/dist/init/run.js CHANGED
@@ -13,15 +13,17 @@ import { spawnSync } from "node:child_process";
13
13
  import { readCredential } from "../auth/credentials.js";
14
14
  import { signIn } from "../auth/run.js";
15
15
  import { resolveRepos } from "../auth/read.js";
16
- import { deriveWorkspace, writeProposal, PROPOSAL_FILE, repoIdentity, } from "./repos.js";
16
+ import { deriveWorkspace, selectedProposal, selectRepos, writeProposal, PROPOSAL_FILE, repoIdentity, } from "./repos.js";
17
17
  import { connectRepos, reposAreInProject, projectAssignments } from "./connect.js";
18
18
  import { downloadHostedIndex, hostedReport, nativeRunner } from "./hosted.js";
19
- import { ensureProject, projectStep, SLUG } from "./projects.js";
19
+ import { createProject, listProjects, planProject, projectLabel, projectStep, SLUG, } from "./projects.js";
20
20
  import { connectMcpClients, mcpLine } from "./mcp.js";
21
21
  import { hookCommand, mergeCarrickHooks, removeCarrickHooks } from "./settings.js";
22
+ import { CODEX_HOOKS_FILE, writeCodexHooks } from "./codex.js";
22
23
  import { ignoredSkillRoots, taskSkillLines, writeTaskSkills } from "./task-skills.js";
23
24
  import { writeIfChanged } from "./files.js";
24
- import { createOutput, DOCS } from "./output.js";
25
+ import { excludedRepos, writeSelection, WORKSPACE_FILE } from "./workspace-file.js";
26
+ import { createOutput, DOCS, PromptCancelled } from "./output.js";
25
27
  import { renderTemplate } from "../templates.js";
26
28
  /**
27
29
  * The one sentence the run ends on.
@@ -41,7 +43,7 @@ import { renderTemplate } from "../templates.js";
41
43
  export const SCAFFOLD_SENTENCE = "Run the carrick scaffold tool for this repo, passing its owner/repo as `repo`, and follow what it returns.";
42
44
  const OWNER_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
43
45
  export function parseArgs(argv, cwd = process.cwd()) {
44
- const options = { workspace: cwd, project: null, repo: null, assumeYes: false };
46
+ const options = { workspace: cwd, project: null, repos: [], assumeYes: false, allowMove: false };
45
47
  for (let index = 0; index < argv.length; index += 1) {
46
48
  const argument = argv[index];
47
49
  switch (argument) {
@@ -49,6 +51,9 @@ export function parseArgs(argv, cwd = process.cwd()) {
49
51
  case "-y":
50
52
  options.assumeYes = true;
51
53
  break;
54
+ case "--allow-move":
55
+ options.allowMove = true;
56
+ break;
52
57
  case "--project": {
53
58
  const value = argv[index + 1];
54
59
  if (!value)
@@ -64,9 +69,12 @@ export function parseArgs(argv, cwd = process.cwd()) {
64
69
  const value = argv[index + 1];
65
70
  if (!value)
66
71
  return "--repo needs an owner/repo";
67
- if (!OWNER_REPO.test(value))
68
- return `invalid repo "${value}": use owner/repo as GitHub spells it`;
69
- options.repo = value;
72
+ for (const name of value.split(",")) {
73
+ if (!OWNER_REPO.test(name))
74
+ return `invalid repo "${name}": use owner/repo as GitHub spells it`;
75
+ if (!options.repos.includes(name))
76
+ options.repos.push(name);
77
+ }
70
78
  index += 1;
71
79
  break;
72
80
  }
@@ -92,20 +100,30 @@ export function parseArgs(argv, cwd = process.cwd()) {
92
100
  }
93
101
  function help() {
94
102
  return [
95
- "carrick init [DIRECTORY] [--project SLUG] [--repo OWNER/REPO]",
103
+ "carrick init [DIRECTORY] [--repo OWNER/REPO]... [--project SLUG] [--allow-move]",
96
104
  "",
97
105
  "Sign in (here, or beforehand with carrick login), then set up a repository",
98
- "or a folder of repos: the project, the repo connection, the agent hooks,",
99
- "the MCP connection, and the service proposal your agent turns into",
100
- "carrick.json. It writes nothing into the repository but the ignored",
101
- ".carrick directory, the hook settings, and the four task skills your agent",
102
- "loads. It runs no analysis: where Carrick already holds an index for these",
103
- "repos, it reads that index into .carrick so this machine can answer from it.",
106
+ "or a folder of repos: which repos this install covers, the project, the repo",
107
+ "connection, the agent hooks, the MCP connection, and the service proposal",
108
+ "your agent turns into carrick.json. It writes nothing into the repository but",
109
+ "the ignored .carrick directory, the hook settings, and the four task skills",
110
+ "your agent loads. It runs no analysis: where Carrick already holds an index",
111
+ "for these repos, it reads that index into .carrick so this machine can answer",
112
+ "from it. Nothing is written, here or in Carrick, until you accept what it",
113
+ "proposes.",
104
114
  "",
105
115
  " -w, --workspace DIR The folder holding the repos (default: this one)",
106
- " --project SLUG Require these repos in this Carrick project",
107
- " --repo OWNER/REPO Name the GitHub repo whose origin remote names none",
108
- " -y, --yes Take the repo list as proposed",
116
+ " --repo OWNER/REPO A repo this install covers: repeatable, or one",
117
+ " comma-separated list. In a folder of repos without a",
118
+ " terminal to choose in, this is required. It also names",
119
+ " the GitHub repo whose origin remote names none",
120
+ " --project SLUG Put those repos in this Carrick project, creating it",
121
+ " if it is not there. A repo that is in another project",
122
+ " is MOVED out of it, which changes what every agent",
123
+ " querying either project can see, so the move is named",
124
+ " and asked about separately",
125
+ " --allow-move Accept those moves without being asked. --yes does not",
126
+ " -y, --yes Take the proposal as printed",
109
127
  "",
110
128
  `The editor extension, the hooks, CI and a carrick.json written by hand: ${DOCS}`,
111
129
  ].join("\n");
@@ -127,7 +145,7 @@ function onPath(command) {
127
145
  * Capped and counted like the identity lines, because a project can hold two
128
146
  * hundred.
129
147
  */
130
- export function absentRepos(identity, names, project) {
148
+ export function absentRepos(identity, names, project, label = (slug) => slug) {
131
149
  const onDisk = new Set(names.map((name) => name.toLowerCase()));
132
150
  const sole = identity.project_repos.length === 1;
133
151
  const lines = [];
@@ -141,7 +159,9 @@ export function absentRepos(identity, names, project) {
141
159
  const shown = absent.length > 10
142
160
  ? `${absent.slice(0, 10).join(", ")} and ${absent.length - 10} more`
143
161
  : absent.join(", ");
144
- const where = sole || entry.project_slug === project ? "this project" : `project "${entry.project_slug}"`;
162
+ const where = sole || entry.project_slug === project
163
+ ? "this project"
164
+ : `project ${label(entry.project_slug)}`;
145
165
  lines.push(`Also in ${where}, not on this machine: ${shown}.`);
146
166
  }
147
167
  return lines;
@@ -214,7 +234,126 @@ export function mcpUnstampedLines(mcp) {
214
234
  .filter((outcome) => outcome.state === "unstamped")
215
235
  .map((outcome) => `${outcome.client}: ${outcome.detail}`);
216
236
  }
237
+ /**
238
+ * An end to the run with its own exit code, decided before anything is written.
239
+ *
240
+ * A refusal ("no terminal and no --yes") and a declined proposal are the same
241
+ * shape — stop here, leave everything alone — and they happen at four points
242
+ * in the decision. Thrown, they all end in one place, beside the cancel, which
243
+ * is the only way to be sure no path between them reaches a write.
244
+ */
245
+ class Stop extends Error {
246
+ code;
247
+ constructor(code, message = "") {
248
+ super(message);
249
+ this.name = "Stop";
250
+ this.code = code;
251
+ }
252
+ }
253
+ /** The line a run that was stopped at a question ends on (carrick#1338). */
254
+ export const CANCELLED = "Cancelled. Nothing was written, here or in Carrick.";
255
+ /**
256
+ * The line a declined proposal ends on.
257
+ *
258
+ * A "no" used to be silent, which beside a cancel that says so reads as two
259
+ * different things having happened. Both leave the machine and the server as
260
+ * they were, and both say it.
261
+ */
262
+ export const NOTHING_WRITTEN = "Nothing written, here or in Carrick.";
263
+ /** A repo as the picker lists it: what it is called, and what is in it. */
264
+ export function repoChoices(plan, candidates) {
265
+ return candidates.map((repo) => {
266
+ const found = plan.repos.find((entry) => entry.path === repo.path);
267
+ const packages = found?.services.length ?? 0;
268
+ const hint = [
269
+ `${packages} package${packages === 1 ? "" : "s"}`,
270
+ ...(repo.name === null ? ["no GitHub identity"] : []),
271
+ ].join(", ");
272
+ return { value: repo.path, label: repo.name ?? path.basename(repo.path), hint };
273
+ });
274
+ }
275
+ /**
276
+ * Which repos in this folder this install covers, settled before anything is
277
+ * read from Carrick and long before anything is written (carrick#1338).
278
+ *
279
+ * A folder routinely holds a repo that must not be scanned, and the only
280
+ * answer this command used to take was one yes covering every repo in it. The
281
+ * terminal picks; without one, `--repo` names them; with neither, there is no
282
+ * safe default and the run stops here, having written nothing.
283
+ */
284
+ export async function chooseRepos(plan, candidates, options, out) {
285
+ if (options.repos.length > 0) {
286
+ const chosen = selectRepos(candidates, options.repos);
287
+ if ("problem" in chosen)
288
+ throw new Error(chosen.problem);
289
+ if (chosen.taken !== null) {
290
+ out.done(`${chosen.taken.name} taken as the GitHub repository for ${chosen.taken.path}`);
291
+ }
292
+ return chosen.repos;
293
+ }
294
+ // One repo is not a choice, and `--yes` is a reader stating that the list as
295
+ // derived is the list they want.
296
+ if (candidates.length === 1 || options.assumeYes)
297
+ return candidates;
298
+ if (!options.interactive) {
299
+ throw new Error(`${plan.workspace} holds ${candidates.length} repos and there is no terminal to choose in. Name the ones this install covers with --repo owner/repo (repeatable), or add --yes to cover all ${candidates.length}.`);
300
+ }
301
+ const kept = new Set(await out.choose("Which repos does this install cover?", repoChoices(plan, candidates)));
302
+ return candidates.filter((repo) => kept.has(repo.path));
303
+ }
304
+ /**
305
+ * The excluded repos a `--repo` flag is asking for, by identity.
306
+ *
307
+ * An excluded repo is invisible to everything downstream — the scanner never
308
+ * derives it, so `selectRepos` sees a value matching nothing on disk and, by
309
+ * carrick#991's rule, attaches it to the one repo here with no GitHub identity
310
+ * of its own. That is a flag quietly covering a different repository, so the
311
+ * question is asked here instead, before any of it.
312
+ *
313
+ * Asked of the directory, not of the spelling: `--repo owner/web` and a
314
+ * directory called `web` are not the same claim, and the repo's own origin
315
+ * remote is what says whether they name one repository. A directory that is
316
+ * gone, or that names no GitHub repo, answers nothing and is not reported —
317
+ * there is no identity to have asked for.
318
+ */
319
+ export function namedButExcluded(workspace, excluded, requested, identify = repoIdentity) {
320
+ if (requested.length === 0 || excluded.length === 0)
321
+ return [];
322
+ const wanted = new Set(requested.map((name) => name.toLowerCase()));
323
+ return excluded.filter((name) => {
324
+ const directory = path.join(workspace, name);
325
+ if (!fs.existsSync(directory))
326
+ return false;
327
+ const identity = identify(directory);
328
+ return identity.name !== null && wanted.has(identity.name.toLowerCase());
329
+ });
330
+ }
331
+ /** The repos this install covers, one line each, for the proposal. */
332
+ export function coverageLines(plan, selected) {
333
+ return selected.slice(0, 10).map((repo) => {
334
+ const found = plan.repos.find((entry) => entry.path === repo.path);
335
+ const packages = found?.services.length ?? 0;
336
+ return ` ${repo.name ?? path.basename(repo.path)} ${packages} package${packages === 1 ? "" : "s"}`;
337
+ });
338
+ }
339
+ /**
340
+ * What the server would be asked to change, said before it is asked.
341
+ *
342
+ * A move is named with the project it comes OUT of, display name and slug
343
+ * both, because that is the project whose agents stop seeing this repo and
344
+ * the one a reader has to find again to undo it (carrick#1338).
345
+ */
346
+ export function moveLines(moving, from, target, label) {
347
+ return moving.map((name) => ` ${name} will move from ${label(from(name))} to ${label(target)}`);
348
+ }
217
349
  export async function init(argv) {
350
+ return initWith(argv, createOutput(), process.stdin.isTTY === true);
351
+ }
352
+ /**
353
+ * `init` with the terminal stated rather than sniffed, so a test can be the
354
+ * terminal: the cancel path and the picker have no other way in.
355
+ */
356
+ export async function initWith(argv, out, interactive) {
218
357
  const parsed = parseArgs(argv);
219
358
  if (typeof parsed === "string") {
220
359
  process.stdout.write(`${parsed}\n`);
@@ -225,10 +364,12 @@ export async function init(argv) {
225
364
  process.stderr.write(`carrick init: ${workspace} is not a directory on this machine\n`);
226
365
  return 1;
227
366
  }
228
- const interactive = process.stdin.isTTY === true;
229
- const out = createOutput();
230
- // Authentication and all derivation validation precede local writes.
231
- let derived;
367
+ // Everything this run would change is decided before any of it happens: the
368
+ // repos it covers, the project they belong in, and the moves that would
369
+ // take. `--project` used to move repos on the server as its first act and
370
+ // print the proposal afterwards, so a reader's only chance to drop a repo
371
+ // came after that repo had already been moved (carrick#1338).
372
+ //
232
373
  // The repos the workspace read found an index for: where CI has already built
233
374
  // one, this run reads it rather than ordering a scan (carrick#993 row 2).
234
375
  const hostedIndex = [];
@@ -236,42 +377,62 @@ export async function init(argv) {
236
377
  // null where no repo here names a GitHub repository, and the skills then
237
378
  // tell the agent to read the scope from the git remote instead.
238
379
  let projectSlug = null;
380
+ let derived;
381
+ let credential;
382
+ let names;
383
+ let initial;
384
+ let projects;
385
+ let decision;
386
+ // The repos this run has permission to take out of another project.
387
+ const movable = new Set();
388
+ // The directory names this run leaves out, for the workspace file below.
389
+ let deselected = [];
239
390
  try {
391
+ // What a previous run wrote down, before anything is derived from it: the
392
+ // scanner has already dropped an excluded repo by the time the proposal
393
+ // arrives, so this is the only place that can tell a reader why the repo
394
+ // they just named is not here (carrick#1344).
395
+ const excluded = excludedRepos(workspace);
396
+ const asked = namedButExcluded(workspace, excluded, parsed.repos);
397
+ if (asked.length > 0) {
398
+ throw new Error(`${WORKSPACE_FILE} leaves ${asked.join(" and ")} out of this workspace, so --repo cannot cover ${asked.length === 1 ? "it" : "them"}. Take ${asked.length === 1 ? "that name" : "those names"} out of the exclude list there and run carrick init again.`);
399
+ }
400
+ if (excluded.length > 0) {
401
+ out.say(`${WORKSPACE_FILE} leaves ${excluded.join(", ")} out of this workspace, so nothing below covers ${excluded.length === 1 ? "it" : "them"}.`);
402
+ }
403
+ // The derivation and the identities are local reads: they name the choice,
404
+ // and a run nobody has chosen in yet must not have signed anything in.
405
+ derived = deriveWorkspace(workspace);
406
+ const candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
407
+ const selected = await chooseRepos(derived.plan, candidates, { repos: parsed.repos, assumeYes: parsed.assumeYes, interactive }, out);
408
+ if (selected.length === 0)
409
+ throw new Stop(0);
410
+ derived = selectedProposal(derived, selected.map((repo) => repo.path));
411
+ const kept = new Set(selected.map((repo) => repo.path));
412
+ deselected = candidates
413
+ .filter((repo) => !kept.has(repo.path))
414
+ .map((repo) => path.basename(repo.path));
415
+ const dropped = candidates.length - selected.length;
416
+ if (dropped > 0) {
417
+ out.done(`${selected.length} of ${candidates.length} repos covered; ${dropped} left out of the proposal, the project and the connection`);
418
+ }
240
419
  // A machine that has never signed in signs in here rather than being told
241
420
  // to run another command: `carrick login` is the same browser round trip,
242
421
  // and refusing to take it was the first thing a new install did
243
422
  // (carrick#955). Without a terminal there is no browser to hand it to.
244
- let credential = readCredential();
245
- if (!credential) {
423
+ const saved = readCredential();
424
+ if (saved)
425
+ credential = saved;
426
+ else {
246
427
  if (!interactive) {
247
428
  throw new Error("carrick init requires a Carrick login. Run carrick login, or set CARRICK_TOKEN.");
248
429
  }
249
430
  credential = await signIn(out.say);
250
431
  }
251
- derived = deriveWorkspace(workspace);
252
- const derivedIdentities = derived.plan.repos.map((repo) => repoIdentity(repo.path));
253
- // `--repo` names what a remote could not: an ssh alias ssh itself cannot
254
- // resolve, a mirror, a clone with no origin. It names one repository, so
255
- // it is taken only when exactly one repo here is missing an identity.
256
- const unnamed = derivedIdentities.filter((repo) => repo.name === null);
257
- let taken = null;
258
- if (parsed.repo !== null) {
259
- if (unnamed.length > 1) {
260
- throw new Error(`--repo names one repository, but ${unnamed.length} repos here have no GitHub identity: ${unnamed.map((repo) => repo.path).join(", ")}. Run carrick init --repo in each of them, or fix their origin remotes.`);
261
- }
262
- if (unnamed.length === 0) {
263
- out.warn(`--repo ${parsed.repo} was not needed: every repo here names its own GitHub repository.`);
264
- }
265
- else {
266
- taken = unnamed[0].path;
267
- out.done(`${parsed.repo} taken as the GitHub repository for ${taken}`);
268
- }
269
- }
270
- const repoIdentities = derivedIdentities.map((repo) => repo.path === taken ? { ...repo, name: parsed.repo, problem: null } : repo);
271
- const names = [...new Set(repoIdentities.map((repo) => repo.name).filter((name) => name !== null))];
432
+ names = [...new Set(selected.map((repo) => repo.name).filter((name) => name !== null))];
272
433
  if (names.length > 200)
273
434
  throw new Error("This workspace has more than 200 GitHub repos. Initialise smaller workspace groups.");
274
- const missingIdentities = repoIdentities.filter((repo) => repo.name === null);
435
+ const missingIdentities = selected.filter((repo) => repo.name === null);
275
436
  // Said before anything is requested, and said whether or not --project was
276
437
  // given: a repo with no identity is left out of the project step, the
277
438
  // connection check and the workspace read, and a run that dropped it in
@@ -300,29 +461,139 @@ export async function init(argv) {
300
461
  // later upload has no repo identity to resolve a project from.
301
462
  out.warn("No repo here names a GitHub repository, so this run chooses no project and checks no connection.");
302
463
  }
303
- const initial = await resolveRepos(credential.token, names);
464
+ initial = await resolveRepos(credential.token, names);
465
+ // Read once, for the names: every project this run prints is printed as
466
+ // the dashboard shows it, with the slug beside it (carrick#1338). A
467
+ // workspace whose API has no such action answers null and the lines carry
468
+ // slugs alone, as they did.
469
+ projects = names.length > 0 ? await listProjects(credential.token) : null;
304
470
  // The project half of the browser round trip, where this API can do it
305
- // from here: the project is created here, and `connectRepos` puts the
306
- // repos in it as the grant connects them (carrick#999), so the wait is on
307
- // the GitHub App grant and nothing else. Without --project the step reads
308
- // the assignment the repos already have and offers the list, rather than
309
- // doing nothing at all (carrick#987).
471
+ // from here. Nothing is created or moved in it: it settles what this run
472
+ // would do, and the proposal below is where that is accepted. Without
473
+ // --project the step reads the assignment the repos already have and
474
+ // offers the list, rather than doing nothing at all (carrick#987).
310
475
  const prompts = { say: out.say, ask: out.ask, confirm: out.confirm, interactive, assumeYes: parsed.assumeYes };
311
- let project = parsed.project;
312
- let projectExists = false;
313
- if (project === null) {
314
- const chosen = await projectStep(credential.token, projectAssignments(initial, names), prompts);
315
- project = chosen.slug;
316
- projectExists = chosen.exists;
476
+ decision =
477
+ parsed.project === null
478
+ ? await projectStep(projectAssignments(initial, names), projects, prompts)
479
+ : reposAreInProject(initial, names, parsed.project)
480
+ ? { slug: parsed.project, exists: true, create: false }
481
+ : await planProject(parsed.project, projects, prompts);
482
+ const project = decision.slug;
483
+ projectSlug = project;
484
+ const label = (slug) => projectLabel(slug, projects);
485
+ // What the server would be asked to change, before it is asked: the repos
486
+ // that would leave a project somebody put them in, and the ones the grant
487
+ // has yet to connect at all.
488
+ const current = projectAssignments(initial, names);
489
+ const assigned = new Map(names.map((name, index) => [name, current[index] ?? null]));
490
+ const moving = project === null ? [] : names.filter((name) => {
491
+ const current = assigned.get(name);
492
+ return current !== null && current !== undefined && current !== project;
493
+ });
494
+ const joining = project === null ? [] : names.filter((name) => assigned.get(name) == null);
495
+ // The proposal: what this run covers, what it would change, and what the
496
+ // scanner said about it. Everything above is a read; everything below the
497
+ // confirm is a write.
498
+ const plan = derived.plan;
499
+ if (plan.parent_proposal) {
500
+ const parent = plan.parent_proposal;
501
+ const folders = parent.repos.map((repo) => path.basename(repo));
502
+ const shown = folders.length > 3 ? `${folders.slice(0, 3).join(", ")} and ${folders.length - 3} more` : folders.join(", ");
503
+ out.warn(`The parent folder ${parent.directory} holds ${parent.repos.length} ${parent.repos.length === 1 ? "repo" : "repos"}: ${shown}. Run carrick init .. to initialise that workspace.`);
317
504
  }
318
- else if (!reposAreInProject(initial, names, project)) {
319
- projectExists = await ensureProject(credential.token, project, prompts);
505
+ // The scanner's own warnings about what it proposed. Capped: the proposal
506
+ // file carries every one of them, and it is named on the line above.
507
+ const warnings = plan.repos.flatMap((repo) => repo.warnings);
508
+ for (const warning of warnings.slice(0, 3))
509
+ out.warn(warning);
510
+ if (warnings.length > 3)
511
+ out.warn(`${warnings.length - 3} more notes on the proposal are in ${PROPOSAL_FILE}.`);
512
+ for (const missing of plan.missing)
513
+ out.warn(`Missing workspace override: ${missing}`);
514
+ if (selected.length > 1) {
515
+ out.say("This install covers:");
516
+ for (const line of coverageLines(plan, selected))
517
+ out.say(line);
518
+ if (selected.length > 10)
519
+ out.say(` and ${selected.length - 10} more`);
520
+ }
521
+ if (project !== null) {
522
+ for (const line of moveLines(moving, (name) => assigned.get(name) ?? "", project, label))
523
+ out.say(line);
524
+ if (joining.length > 0) {
525
+ out.say(` ${joining.join(", ")} will be placed in project ${label(project)} once the browser connects ${joining.length === 1 ? "it" : "them"}`);
526
+ }
527
+ if (decision.create)
528
+ out.say(` Project "${project}" will be created in this workspace`);
529
+ }
530
+ const subject = packagesFound(plan);
531
+ if (!parsed.assumeYes) {
532
+ if (!interactive) {
533
+ throw new Stop(1, `use --yes to accept this proposal without a terminal${moving.length > 0 && !parsed.allowMove ? ", and --allow-move to accept the move above" : ""}.`);
534
+ }
535
+ // The count is in the question, because the listing that used to stand
536
+ // above it is gone: this is where a reader decides (carrick#1026).
537
+ if (!(await out.confirm(`Write the proposal for ${subject} and configure hooks?`))) {
538
+ out.refuse(NOTHING_WRITTEN);
539
+ throw new Stop(0);
540
+ }
541
+ }
542
+ // A move is its own question. `--yes` accepts a list of packages and a set
543
+ // of local files; taking a repo out of a project changes what every agent
544
+ // querying that project can see, and that is not the same answer
545
+ // (carrick#1338).
546
+ if (moving.length > 0 && project !== null && !parsed.allowMove) {
547
+ const from = [...new Set(moving.map((name) => label(assigned.get(name) ?? "")))].join(" and ");
548
+ if (!interactive) {
549
+ throw new Stop(1, `${moving.join(", ")} ${moving.length === 1 ? "is" : "are"} in project ${from}. Moving ${moving.length === 1 ? "it" : "them"} into ${label(project)} changes what every agent querying either project can see, so it needs --allow-move.`);
550
+ }
551
+ if (!(await out.confirm(`Move ${moving.length === 1 ? moving[0] : `${moving.length} repos`} out of ${from} into ${label(project)}?`))) {
552
+ out.refuse(NOTHING_WRITTEN);
553
+ throw new Stop(0);
554
+ }
555
+ }
556
+ for (const name of [...moving, ...joining])
557
+ movable.add(name.toLowerCase());
558
+ }
559
+ catch (error) {
560
+ // A question the reader ended stops the run where it stands. Nothing above
561
+ // this line writes, so there is nothing to undo (carrick#1338).
562
+ if (error instanceof PromptCancelled) {
563
+ out.refuse(CANCELLED);
564
+ return 1;
565
+ }
566
+ if (error instanceof Stop) {
567
+ if (error.message !== "")
568
+ process.stderr.write(`carrick init: ${error.message}\n`);
569
+ return error.code;
570
+ }
571
+ process.stderr.write(`carrick init: ${error.message}\n`);
572
+ return 1;
573
+ }
574
+ // Accepted. From here the run writes: the project, the assignment, the
575
+ // proposal, the hooks, the skills and the MCP entries.
576
+ const plan = derived.plan;
577
+ const project = decision.slug;
578
+ const label = (slug) => projectLabel(slug, projects);
579
+ try {
580
+ let projectExists = decision.exists;
581
+ if (decision.create && project !== null) {
582
+ const outcome = await createProject(credential.token, project);
583
+ if (outcome.kind === "created") {
584
+ out.say(`Created project "${project}".`);
585
+ projectExists = true;
586
+ }
587
+ else if (outcome.kind === "refused") {
588
+ out.say(`Carrick did not create "${project}": ${outcome.message}`);
589
+ }
320
590
  }
321
- projectSlug = project;
322
591
  const identity = await connectRepos(credential.token, names, initial, {
323
592
  interactive,
324
593
  project: project ?? undefined,
325
594
  projectExists,
595
+ movable,
596
+ label,
326
597
  say: out.say,
327
598
  });
328
599
  // Who this machine is, and where its answers come from. The workspace slug
@@ -330,13 +601,13 @@ export async function init(argv) {
330
601
  // user (carrick#1026).
331
602
  out.done(project === null
332
603
  ? `Signed in as ${identity.workspace.slug}`
333
- : `Signed in as ${identity.workspace.slug} · project ${project}`);
604
+ : `Signed in as ${identity.workspace.slug} · project ${label(project)}`);
334
605
  if (project !== null && !reposAreInProject(identity, names, project)) {
335
606
  // An unverified project does not end the run, whether it was named on
336
607
  // the command line or picked here. Stopping cost someone their hooks and
337
608
  // their proposal for a browser step they could only take afterwards, and
338
609
  // the documented command then needed two runs (carrick#993 row 8).
339
- out.warn(`Finish the browser steps above to put these repos in "${project}", then run carrick init --project ${project} again to verify.`);
610
+ out.warn(`Finish the browser steps above to put these repos in ${label(project)}, then run carrick init --project ${project} again to verify.`);
340
611
  }
341
612
  if (identity.allowance_sentence)
342
613
  out.say(identity.allowance_sentence);
@@ -356,48 +627,28 @@ export async function init(argv) {
356
627
  // answers across every repo in a project, so a folder holding half of one
357
628
  // is a partial index and nothing here would otherwise say so
358
629
  // (carrick#993 row 18).
359
- for (const line of absentRepos(identity, names, project))
630
+ for (const line of absentRepos(identity, names, project, label))
360
631
  out.warn(line);
361
632
  }
362
633
  catch (error) {
363
634
  process.stderr.write(`carrick init: ${error.message}\n`);
364
635
  return 1;
365
636
  }
366
- const plan = derived.plan;
367
- if (plan.parent_proposal) {
368
- const parent = plan.parent_proposal;
369
- const names = parent.repos.map((repo) => path.basename(repo));
370
- const shown = names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
371
- out.warn(`The parent folder ${parent.directory} holds ${parent.repos.length} ${parent.repos.length === 1 ? "repo" : "repos"}: ${shown}. Run carrick init .. to initialise that workspace.`);
372
- }
373
- // The scanner's own warnings about what it proposed. Capped: the proposal
374
- // file carries every one of them, and it is named on the line above.
375
- const warnings = plan.repos.flatMap((repo) => repo.warnings);
376
- for (const warning of warnings.slice(0, 3))
377
- out.warn(warning);
378
- if (warnings.length > 3)
379
- out.warn(`${warnings.length - 3} more notes on the proposal are in ${PROPOSAL_FILE}.`);
380
- for (const missing of plan.missing)
381
- out.warn(`Missing workspace override: ${missing}`);
382
- const proposed = packagesLine(plan);
383
- const subject = packagesFound(plan);
384
- if (!parsed.assumeYes) {
385
- if (!process.stdin.isTTY) {
386
- process.stderr.write("carrick init: use --yes to accept this proposal without a terminal.\n");
387
- return 1;
388
- }
389
- // The count is in the question, because the listing that used to stand
390
- // above it is gone: this is where a reader decides (carrick#1026).
391
- if (!await out.confirm(`Write the proposal for ${subject} and configure hooks?`))
392
- return 0;
393
- }
394
637
  try {
395
638
  // The proposal is a seed for an agent, not a config: nothing derived
396
639
  // without a model is written into the repository, because the first scan
397
640
  // is the paid one and it has to run against a config someone has read
398
641
  // (carrick-cloud#799).
399
642
  writeProposal(plan.workspace, derived);
400
- out.done(proposed);
643
+ out.done(packagesLine(plan));
644
+ // And the selection itself, in the file every later command reads. Without
645
+ // it the choice lasted one run: `carrick refresh` and `carrick index` walk
646
+ // the folder, the editor hook answers for a file inside a repo nobody
647
+ // covers, and the next `init` asks again (carrick#1344).
648
+ const selection = writeSelection(plan.workspace, deselected);
649
+ if (selection !== null && selection.added.length > 0) {
650
+ out.done(`${selection.added.join(", ")} excluded in ${WORKSPACE_FILE}, so the scans, the hooks and the next run leave ${selection.added.length === 1 ? "it" : "them"} alone`);
651
+ }
401
652
  }
402
653
  catch (error) {
403
654
  process.stderr.write(`carrick init: ${error.message}\n`);
@@ -429,6 +680,18 @@ export async function init(argv) {
429
680
  hooksWritten = false;
430
681
  out.refuse(`Could not configure Carrick hooks: ${error.message}. Fix ${settingsName} and run carrick init again.`);
431
682
  }
683
+ // The same two-part nudge for Codex, in the file Codex reads project hooks
684
+ // from. Written for both hosts for the same reason the task skills are
685
+ // (carrick#1335): which agent opens this workspace is not a thing init can
686
+ // know, and a hook file for a host nobody runs costs nothing.
687
+ try {
688
+ if (writeCodexHooks(workspace, command.command) === "written") {
689
+ out.done(`Codex hook entries written to ${CODEX_HOOKS_FILE}. Codex asks you to trust them the next time it starts; until you do, they do not run.`);
690
+ }
691
+ }
692
+ catch (error) {
693
+ out.refuse(`Could not configure the Codex hooks: ${error.message}. Fix ${CODEX_HOOKS_FILE} and run carrick init again.`);
694
+ }
432
695
  // The task skills, beside the hooks and independent of them: a settings file
433
696
  // somebody hand-edited into invalid JSON is no reason to withhold the bodies
434
697
  // an agent reads. A file this package did not write, or one somebody has