carrick 0.3.81 → 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 (79) 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 +16 -0
  30. package/dist/init/files.js +35 -0
  31. package/dist/init/files.js.map +1 -0
  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 +91 -23
  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 +382 -101
  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 +97 -0
  54. package/dist/init/task-skills.js +267 -0
  55. package/dist/init/task-skills.js.map +1 -0
  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/dist/scan.d.ts +13 -5
  60. package/dist/scan.js +21 -10
  61. package/dist/scan.js.map +1 -1
  62. package/dist/templates.d.ts +9 -0
  63. package/dist/templates.js +9 -1
  64. package/dist/templates.js.map +1 -1
  65. package/package.json +6 -6
  66. package/plugin/hooks/hooks.json +11 -0
  67. package/sidecar/dist/src/capture/check-classify.d.ts +10 -1
  68. package/sidecar/dist/src/capture/check-classify.js +66 -8
  69. package/sidecar/dist/src/capture/check-deep.d.ts +16 -4
  70. package/sidecar/dist/src/capture/check-deep.js +21 -17
  71. package/sidecar/dist/src/capture/check-fields.d.ts +83 -0
  72. package/sidecar/dist/src/capture/check-fields.js +259 -0
  73. package/sidecar/dist/src/capture/check-probe.d.ts +21 -1
  74. package/sidecar/dist/src/capture/check-probe.js +39 -0
  75. package/sidecar/dist/src/capture/check.js +9 -2
  76. package/templates/skills/carrick-census.md +89 -0
  77. package/templates/skills/carrick-drift.md +107 -0
  78. package/templates/skills/carrick-impact.md +108 -0
  79. package/templates/skills/carrick-reuse.md +106 -0
package/dist/init/run.js CHANGED
@@ -13,13 +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 { createOutput, DOCS } from "./output.js";
22
+ import { CODEX_HOOKS_FILE, writeCodexHooks } from "./codex.js";
23
+ import { ignoredSkillRoots, taskSkillLines, writeTaskSkills } from "./task-skills.js";
24
+ import { writeIfChanged } from "./files.js";
25
+ import { excludedRepos, writeSelection, WORKSPACE_FILE } from "./workspace-file.js";
26
+ import { createOutput, DOCS, PromptCancelled } from "./output.js";
23
27
  import { renderTemplate } from "../templates.js";
24
28
  /**
25
29
  * The one sentence the run ends on.
@@ -39,7 +43,7 @@ import { renderTemplate } from "../templates.js";
39
43
  export const SCAFFOLD_SENTENCE = "Run the carrick scaffold tool for this repo, passing its owner/repo as `repo`, and follow what it returns.";
40
44
  const OWNER_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
41
45
  export function parseArgs(argv, cwd = process.cwd()) {
42
- const options = { workspace: cwd, project: null, repo: null, assumeYes: false };
46
+ const options = { workspace: cwd, project: null, repos: [], assumeYes: false, allowMove: false };
43
47
  for (let index = 0; index < argv.length; index += 1) {
44
48
  const argument = argv[index];
45
49
  switch (argument) {
@@ -47,6 +51,9 @@ export function parseArgs(argv, cwd = process.cwd()) {
47
51
  case "-y":
48
52
  options.assumeYes = true;
49
53
  break;
54
+ case "--allow-move":
55
+ options.allowMove = true;
56
+ break;
50
57
  case "--project": {
51
58
  const value = argv[index + 1];
52
59
  if (!value)
@@ -62,9 +69,12 @@ export function parseArgs(argv, cwd = process.cwd()) {
62
69
  const value = argv[index + 1];
63
70
  if (!value)
64
71
  return "--repo needs an owner/repo";
65
- if (!OWNER_REPO.test(value))
66
- return `invalid repo "${value}": use owner/repo as GitHub spells it`;
67
- 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
+ }
68
78
  index += 1;
69
79
  break;
70
80
  }
@@ -90,32 +100,34 @@ export function parseArgs(argv, cwd = process.cwd()) {
90
100
  }
91
101
  function help() {
92
102
  return [
93
- "carrick init [DIRECTORY] [--project SLUG] [--repo OWNER/REPO]",
103
+ "carrick init [DIRECTORY] [--repo OWNER/REPO]... [--project SLUG] [--allow-move]",
94
104
  "",
95
105
  "Sign in (here, or beforehand with carrick login), then set up a repository",
96
- "or a folder of repos: the project, the repo connection, the agent hooks,",
97
- "the MCP connection, and the service proposal your agent turns into",
98
- "carrick.json. It writes nothing into the repository but the ignored",
99
- ".carrick directory and the hook settings, and it runs no analysis: where",
100
- "Carrick already holds an index for these repos, it reads that index into",
101
- ".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.",
102
114
  "",
103
115
  " -w, --workspace DIR The folder holding the repos (default: this one)",
104
- " --project SLUG Require these repos in this Carrick project",
105
- " --repo OWNER/REPO Name the GitHub repo whose origin remote names none",
106
- " -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",
107
127
  "",
108
128
  `The editor extension, the hooks, CI and a carrick.json written by hand: ${DOCS}`,
109
129
  ].join("\n");
110
130
  }
111
- function writeIfChanged(target, body) {
112
- const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
113
- if (existing === body)
114
- return "unchanged";
115
- fs.mkdirSync(path.dirname(target), { recursive: true });
116
- fs.writeFileSync(target, body);
117
- return "written";
118
- }
119
131
  /** Whether a command answers on this machine, for a line we should not print. */
120
132
  function onPath(command) {
121
133
  const probe = spawnSync(process.platform === "win32" ? "where" : "which", [command], {
@@ -133,7 +145,7 @@ function onPath(command) {
133
145
  * Capped and counted like the identity lines, because a project can hold two
134
146
  * hundred.
135
147
  */
136
- export function absentRepos(identity, names, project) {
148
+ export function absentRepos(identity, names, project, label = (slug) => slug) {
137
149
  const onDisk = new Set(names.map((name) => name.toLowerCase()));
138
150
  const sole = identity.project_repos.length === 1;
139
151
  const lines = [];
@@ -147,7 +159,9 @@ export function absentRepos(identity, names, project) {
147
159
  const shown = absent.length > 10
148
160
  ? `${absent.slice(0, 10).join(", ")} and ${absent.length - 10} more`
149
161
  : absent.join(", ");
150
- 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)}`;
151
165
  lines.push(`Also in ${where}, not on this machine: ${shown}.`);
152
166
  }
153
167
  return lines;
@@ -220,7 +234,126 @@ export function mcpUnstampedLines(mcp) {
220
234
  .filter((outcome) => outcome.state === "unstamped")
221
235
  .map((outcome) => `${outcome.client}: ${outcome.detail}`);
222
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
+ }
223
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) {
224
357
  const parsed = parseArgs(argv);
225
358
  if (typeof parsed === "string") {
226
359
  process.stdout.write(`${parsed}\n`);
@@ -231,49 +364,75 @@ export async function init(argv) {
231
364
  process.stderr.write(`carrick init: ${workspace} is not a directory on this machine\n`);
232
365
  return 1;
233
366
  }
234
- const interactive = process.stdin.isTTY === true;
235
- const out = createOutput();
236
- // Authentication and all derivation validation precede local writes.
237
- 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
+ //
238
373
  // The repos the workspace read found an index for: where CI has already built
239
374
  // one, this run reads it rather than ordering a scan (carrick#993 row 2).
240
375
  const hostedIndex = [];
376
+ // The project the skills are written against, once it is settled. It stays
377
+ // null where no repo here names a GitHub repository, and the skills then
378
+ // tell the agent to read the scope from the git remote instead.
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 = [];
241
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
+ }
242
419
  // A machine that has never signed in signs in here rather than being told
243
420
  // to run another command: `carrick login` is the same browser round trip,
244
421
  // and refusing to take it was the first thing a new install did
245
422
  // (carrick#955). Without a terminal there is no browser to hand it to.
246
- let credential = readCredential();
247
- if (!credential) {
423
+ const saved = readCredential();
424
+ if (saved)
425
+ credential = saved;
426
+ else {
248
427
  if (!interactive) {
249
428
  throw new Error("carrick init requires a Carrick login. Run carrick login, or set CARRICK_TOKEN.");
250
429
  }
251
430
  credential = await signIn(out.say);
252
431
  }
253
- derived = deriveWorkspace(workspace);
254
- const derivedIdentities = derived.plan.repos.map((repo) => repoIdentity(repo.path));
255
- // `--repo` names what a remote could not: an ssh alias ssh itself cannot
256
- // resolve, a mirror, a clone with no origin. It names one repository, so
257
- // it is taken only when exactly one repo here is missing an identity.
258
- const unnamed = derivedIdentities.filter((repo) => repo.name === null);
259
- let taken = null;
260
- if (parsed.repo !== null) {
261
- if (unnamed.length > 1) {
262
- 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.`);
263
- }
264
- if (unnamed.length === 0) {
265
- out.warn(`--repo ${parsed.repo} was not needed: every repo here names its own GitHub repository.`);
266
- }
267
- else {
268
- taken = unnamed[0].path;
269
- out.done(`${parsed.repo} taken as the GitHub repository for ${taken}`);
270
- }
271
- }
272
- const repoIdentities = derivedIdentities.map((repo) => repo.path === taken ? { ...repo, name: parsed.repo, problem: null } : repo);
273
- 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))];
274
433
  if (names.length > 200)
275
434
  throw new Error("This workspace has more than 200 GitHub repos. Initialise smaller workspace groups.");
276
- const missingIdentities = repoIdentities.filter((repo) => repo.name === null);
435
+ const missingIdentities = selected.filter((repo) => repo.name === null);
277
436
  // Said before anything is requested, and said whether or not --project was
278
437
  // given: a repo with no identity is left out of the project step, the
279
438
  // connection check and the workspace read, and a run that dropped it in
@@ -302,28 +461,139 @@ export async function init(argv) {
302
461
  // later upload has no repo identity to resolve a project from.
303
462
  out.warn("No repo here names a GitHub repository, so this run chooses no project and checks no connection.");
304
463
  }
305
- 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;
306
470
  // The project half of the browser round trip, where this API can do it
307
- // from here: the project is created here, and `connectRepos` puts the
308
- // repos in it as the grant connects them (carrick#999), so the wait is on
309
- // the GitHub App grant and nothing else. Without --project the step reads
310
- // the assignment the repos already have and offers the list, rather than
311
- // 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).
312
475
  const prompts = { say: out.say, ask: out.ask, confirm: out.confirm, interactive, assumeYes: parsed.assumeYes };
313
- let project = parsed.project;
314
- let projectExists = false;
315
- if (project === null) {
316
- const chosen = await projectStep(credential.token, projectAssignments(initial, names), prompts);
317
- project = chosen.slug;
318
- 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.`);
504
+ }
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
+ }
319
541
  }
320
- else if (!reposAreInProject(initial, names, project)) {
321
- projectExists = await ensureProject(credential.token, project, prompts);
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
+ }
322
590
  }
323
591
  const identity = await connectRepos(credential.token, names, initial, {
324
592
  interactive,
325
593
  project: project ?? undefined,
326
594
  projectExists,
595
+ movable,
596
+ label,
327
597
  say: out.say,
328
598
  });
329
599
  // Who this machine is, and where its answers come from. The workspace slug
@@ -331,13 +601,13 @@ export async function init(argv) {
331
601
  // user (carrick#1026).
332
602
  out.done(project === null
333
603
  ? `Signed in as ${identity.workspace.slug}`
334
- : `Signed in as ${identity.workspace.slug} · project ${project}`);
604
+ : `Signed in as ${identity.workspace.slug} · project ${label(project)}`);
335
605
  if (project !== null && !reposAreInProject(identity, names, project)) {
336
606
  // An unverified project does not end the run, whether it was named on
337
607
  // the command line or picked here. Stopping cost someone their hooks and
338
608
  // their proposal for a browser step they could only take afterwards, and
339
609
  // the documented command then needed two runs (carrick#993 row 8).
340
- 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.`);
341
611
  }
342
612
  if (identity.allowance_sentence)
343
613
  out.say(identity.allowance_sentence);
@@ -357,48 +627,28 @@ export async function init(argv) {
357
627
  // answers across every repo in a project, so a folder holding half of one
358
628
  // is a partial index and nothing here would otherwise say so
359
629
  // (carrick#993 row 18).
360
- for (const line of absentRepos(identity, names, project))
630
+ for (const line of absentRepos(identity, names, project, label))
361
631
  out.warn(line);
362
632
  }
363
633
  catch (error) {
364
634
  process.stderr.write(`carrick init: ${error.message}\n`);
365
635
  return 1;
366
636
  }
367
- const plan = derived.plan;
368
- if (plan.parent_proposal) {
369
- const parent = plan.parent_proposal;
370
- const names = parent.repos.map((repo) => path.basename(repo));
371
- const shown = names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
372
- 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.`);
373
- }
374
- // The scanner's own warnings about what it proposed. Capped: the proposal
375
- // file carries every one of them, and it is named on the line above.
376
- const warnings = plan.repos.flatMap((repo) => repo.warnings);
377
- for (const warning of warnings.slice(0, 3))
378
- out.warn(warning);
379
- if (warnings.length > 3)
380
- out.warn(`${warnings.length - 3} more notes on the proposal are in ${PROPOSAL_FILE}.`);
381
- for (const missing of plan.missing)
382
- out.warn(`Missing workspace override: ${missing}`);
383
- const proposed = packagesLine(plan);
384
- const subject = packagesFound(plan);
385
- if (!parsed.assumeYes) {
386
- if (!process.stdin.isTTY) {
387
- process.stderr.write("carrick init: use --yes to accept this proposal without a terminal.\n");
388
- return 1;
389
- }
390
- // The count is in the question, because the listing that used to stand
391
- // above it is gone: this is where a reader decides (carrick#1026).
392
- if (!await out.confirm(`Write the proposal for ${subject} and configure hooks?`))
393
- return 0;
394
- }
395
637
  try {
396
638
  // The proposal is a seed for an agent, not a config: nothing derived
397
639
  // without a model is written into the repository, because the first scan
398
640
  // is the paid one and it has to run against a config someone has read
399
641
  // (carrick-cloud#799).
400
642
  writeProposal(plan.workspace, derived);
401
- 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
+ }
402
652
  }
403
653
  catch (error) {
404
654
  process.stderr.write(`carrick init: ${error.message}\n`);
@@ -430,6 +680,37 @@ export async function init(argv) {
430
680
  hooksWritten = false;
431
681
  out.refuse(`Could not configure Carrick hooks: ${error.message}. Fix ${settingsName} and run carrick init again.`);
432
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
+ }
695
+ // The task skills, beside the hooks and independent of them: a settings file
696
+ // somebody hand-edited into invalid JSON is no reason to withhold the bodies
697
+ // an agent reads. A file this package did not write, or one somebody has
698
+ // since changed, is left alone and named.
699
+ try {
700
+ const { done, warn } = taskSkillLines(writeTaskSkills(workspace, { slug: projectSlug }));
701
+ for (const line of done)
702
+ out.done(line);
703
+ for (const line of warn)
704
+ out.warn(line);
705
+ const ignored = ignoredSkillRoots(workspace);
706
+ if (ignored.length > 0) {
707
+ const one = ignored.length === 1;
708
+ out.warn(`${ignored.join(" and ")} ${one ? "is" : "are"} git-ignored here, so these skills stay on this machine. Track ${one ? "it" : "them"} to give the rest of the team the same answers.`);
709
+ }
710
+ }
711
+ catch (error) {
712
+ out.refuse(`Could not write the task skills: ${error.message}`);
713
+ }
433
714
  const mcp = connectMcpClients();
434
715
  if (hooksWritten)
435
716
  out.done(configuredLine(mcp));