@tablation/crew 0.59.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +274 -76
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -255,6 +255,7 @@ crew inbox [--member NAME] your tickets across every workspace
255
255
  crew connect WS[/PROJECT] resolve a workspace/project's ids into the state tree
256
256
  crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml, creating its Repos row if missing (--dry-run previews)
257
257
  crew repos list ROUTE the checkouts a route has, and the tracker Repos row each matches
258
+ crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--write route|repo writes them, --json)
258
259
  crew install [--replace] write and load this platform's scheduler unit (macOS: clears a crew installed from another path)
259
260
  crew uninstall [--all] unload and remove it (--all: every crew launchd unit, whichever path it came from)
260
261
  ```
package/dist/cli.js CHANGED
@@ -5211,7 +5211,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
5211
5211
  "tail",
5212
5212
  "ticket",
5213
5213
  "workspace",
5214
- "workspace-id"
5214
+ "workspace-id",
5215
+ "write"
5215
5216
  ]);
5216
5217
  function positionalArgs(argv2) {
5217
5218
  const out = [];
@@ -12659,77 +12660,12 @@ async function showLogEntry(route, entryId, opts = {}) {
12659
12660
  };
12660
12661
  }
12661
12662
 
12662
- // src/sample-ticket.ts
12663
- import { basename as basename7, dirname as dirname12, join as join20 } from "path";
12664
- var SAMPLE_REPO = "crew-sample";
12665
- var SAMPLE_REMOTE = `local/${SAMPLE_REPO}`;
12666
- var SAMPLE_MARKER = "sample: true";
12667
- var SAMPLE_CREW_YAML = `version: 1
12668
- platform: unix
12669
- branch:
12670
- base: main
12671
- hooks:
12672
- test: "true"
12673
- build: "true"
12674
- release:
12675
- mode: integrate
12676
- versioning: none
12677
- changelog: false
12678
- `;
12679
- var SAMPLE_README = `# crew-sample
12680
-
12681
- A throwaway repository created by \`crew sample-ticket\`. It exists so the crew
12682
- has something safe to work on: its test and build hooks always pass, it has no
12683
- remote, and \`crew sample-ticket --remove\` deletes it.
12684
- `;
12685
- function sampleDir(existing, reposBase, workspace) {
12686
- if (existing.length > 0) return join20(dirname12(existing[0]), SAMPLE_REPO);
12687
- return join20(reposBase, workspace, SAMPLE_REPO);
12688
- }
12689
- var SAMPLE_TITLE = "Sample: add a documented `greet` function with a test";
12690
- function sampleDescription() {
12691
- return [
12692
- SAMPLE_MARKER,
12693
- "",
12694
- "> This is a sample ticket filed by `crew sample-ticket` on a throwaway repository. Nothing here is real work.",
12695
- "",
12696
- "## What",
12697
- "",
12698
- "Add `greet.js` exporting `greet(name)`, which returns `Hello, <name>!` (and `Hello, world!` for no name),",
12699
- "with a JSDoc comment, and `greet.test.js` covering both cases using `node:test`.",
12700
- "",
12701
- "## Acceptance",
12702
- "",
12703
- '- `greet("Ada")` returns `Hello, Ada!`; `greet()` returns `Hello, world!`.',
12704
- "- The test file runs with `node --test` and passes.",
12705
- "- Add a line to the README saying the function exists.",
12706
- "",
12707
- "## Notes",
12708
- "",
12709
- "The repo has no remote: leave the branch local. The hooks in `.crew.yaml` always pass, so QA is checking the",
12710
- "diff against the acceptance list above."
12711
- ].join("\n");
12712
- }
12713
- function isSampleTicket(description) {
12714
- return (description ?? "").trimStart().startsWith(SAMPLE_MARKER);
12715
- }
12716
- function planSampleTicket(i) {
12717
- return {
12718
- title: SAMPLE_TITLE,
12719
- description: sampleDescription(),
12720
- report_type: "feature",
12721
- severity: "s4",
12722
- reporter_name: "crew sample-ticket",
12723
- status: i.approvedStatus,
12724
- project_id: i.projectId,
12725
- repo_id: i.repoId,
12726
- ...i.filedBy ? { filed_by_id: i.filedBy } : {}
12727
- };
12728
- }
12663
+ // src/repos-hooks.ts
12664
+ import { isMap as isMap2, parseDocument as parseDocument2, YAMLMap } from "yaml";
12729
12665
 
12730
12666
  // src/repos-cmd.ts
12731
12667
  import { homedir as homedir7 } from "os";
12732
- import { basename as basename8, join as join21, resolve as resolve3 } from "path";
12668
+ import { basename as basename7, join as join20, resolve as resolve3 } from "path";
12733
12669
  import { isMap, isSeq, parseDocument, stringify as stringify2 } from "yaml";
12734
12670
  var ReposError = class extends Error {
12735
12671
  };
@@ -12801,7 +12737,7 @@ function configuredRepos(text, route, base) {
12801
12737
  });
12802
12738
  }
12803
12739
  const dir = m.get("dir");
12804
- return dir ? [{ name: basename8(String(dir)), dir: expandDir(String(dir), base) }] : [];
12740
+ return dir ? [{ name: basename7(String(dir)), dir: expandDir(String(dir), base) }] : [];
12805
12741
  }
12806
12742
  function planRepoAdd(i) {
12807
12743
  const doc = parseDocument(i.text);
@@ -12813,7 +12749,7 @@ function planRepoAdd(i) {
12813
12749
  throw new ReposError(`${target} is already configured on route ${i.route}`);
12814
12750
  }
12815
12751
  const matched = matchRepoName(i.origin, i.resolved);
12816
- const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename8(target);
12752
+ const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename7(target);
12817
12753
  let warning;
12818
12754
  if (!matched) {
12819
12755
  const known = Object.values(i.resolved?.repoNames ?? {});
@@ -12927,13 +12863,218 @@ function planRepoClone(i) {
12927
12863
  }
12928
12864
  const ship = doc.get("ship", true);
12929
12865
  const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
12930
- const target = i.dir ? expandDir(i.dir, i.base) : join21(expandDir(basePath, i.base), i.route.split("/")[0], name);
12866
+ const target = i.dir ? expandDir(i.dir, i.base) : join20(expandDir(basePath, i.base), i.route.split("/")[0], name);
12931
12867
  const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name });
12932
12868
  return { ...add, remote, target };
12933
12869
  }
12934
12870
 
12871
+ // src/repos-hooks.ts
12872
+ function packageManager(f) {
12873
+ if (f.exists("pnpm-lock.yaml")) return "pnpm";
12874
+ if (f.exists("yarn.lock")) return "yarn";
12875
+ if (f.exists("bun.lock") || f.exists("bun.lockb")) return "bun";
12876
+ return "npm";
12877
+ }
12878
+ function fromPackageJson(f) {
12879
+ const text = f.read("package.json");
12880
+ if (text === void 0) return void 0;
12881
+ let pkg;
12882
+ try {
12883
+ pkg = JSON.parse(text);
12884
+ } catch {
12885
+ return void 0;
12886
+ }
12887
+ if (!pkg || typeof pkg !== "object") return void 0;
12888
+ const pm = packageManager(f);
12889
+ const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
12890
+ const out = {};
12891
+ if (typeof scripts.test === "string") out.test = `${pm} test`;
12892
+ if (typeof scripts.build === "string") out.build = pm === "yarn" ? "yarn build" : `${pm} run build`;
12893
+ if (typeof pkg.version === "string") {
12894
+ out.version = `node -p "require('./package.json').version"`;
12895
+ out.bump = `npm version "$CREW_BUMP" --no-git-tag-version | sed 's/^v//'`;
12896
+ }
12897
+ return out;
12898
+ }
12899
+ function fromPackageSwift(f) {
12900
+ return f.exists("Package.swift") ? { test: "swift test", build: "swift build" } : void 0;
12901
+ }
12902
+ function fromPyproject(f) {
12903
+ return f.exists("pyproject.toml") ? { test: "pytest" } : void 0;
12904
+ }
12905
+ function fromMakefile(f) {
12906
+ const text = f.read("Makefile") ?? f.read("makefile") ?? f.read("GNUmakefile");
12907
+ if (text === void 0) return void 0;
12908
+ const out = {};
12909
+ for (const target of ["test", "build"]) {
12910
+ if (new RegExp(`^${target}\\s*:`, "m").test(text)) out[target] = `make ${target}`;
12911
+ }
12912
+ return out;
12913
+ }
12914
+ var DETECTORS = [
12915
+ { source: "package.json", detect: fromPackageJson },
12916
+ { source: "Package.swift", detect: fromPackageSwift },
12917
+ { source: "pyproject.toml", detect: fromPyproject },
12918
+ { source: "Makefile", detect: fromMakefile }
12919
+ ];
12920
+ function suggestHooks(f) {
12921
+ const found = DETECTORS.flatMap((d) => {
12922
+ const hooks = d.detect(f);
12923
+ return hooks && Object.keys(hooks).length > 0 ? [{ source: d.source, hooks }] : [];
12924
+ });
12925
+ if (found.length === 0) {
12926
+ return { source: null, hooks: {}, notes: ["no package.json scripts, Package.swift, pyproject.toml or Makefile test/build targets found \u2014 nothing to propose"] };
12927
+ }
12928
+ const [first, ...others] = found;
12929
+ const notes = others.length > 0 ? [`also found ${others.map((o) => o.source).join(", ")}; proposing from ${first.source} only`] : [];
12930
+ return { source: first.source, hooks: first.hooks, notes };
12931
+ }
12932
+ function parseHooksArgs(args, routes) {
12933
+ const rest = [...args];
12934
+ let route;
12935
+ if (rest.length > 1 && routes.includes(rest[0])) route = rest.shift();
12936
+ if (!route) {
12937
+ if (routes.length === 1) route = routes[0];
12938
+ else throw new ReposError(routes.length === 0 ? "the config has no routes \u2014 run `crew connect` first" : `several routes are configured; name one: ${routes.join(", ")}`);
12939
+ }
12940
+ if (rest.length !== 1) throw new ReposError("expected: crew repos hooks [route] <name> [--suggest] [--write route|repo] [--json]");
12941
+ return { route, name: rest[0] };
12942
+ }
12943
+ function repoDirOf(text, base, route, name) {
12944
+ const repos = configuredRepos(text, route, base);
12945
+ const hit = repos.find((r) => r.name === name);
12946
+ if (!hit) {
12947
+ throw new ReposError(`route ${route} has no repo named "${name}" (have: ${repos.map((r) => r.name).join(", ") || "none"})`);
12948
+ }
12949
+ return hit.dir;
12950
+ }
12951
+ function planRouteHooks(text, route, name, hooks) {
12952
+ const doc = parseDocument2(text);
12953
+ if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
12954
+ const m = routeMapOf(doc, route);
12955
+ const repos = m.get("repos", true);
12956
+ let target = m;
12957
+ if (isMap2(repos)) {
12958
+ const entry = repos.get(name, true);
12959
+ if (isMap2(entry)) target = entry;
12960
+ else {
12961
+ const created = new YAMLMap();
12962
+ created.set("dir", String(repos.get(name)));
12963
+ repos.set(name, created);
12964
+ target = created;
12965
+ }
12966
+ }
12967
+ const current = target.get("hooks", true);
12968
+ let hooksMap;
12969
+ if (isMap2(current)) hooksMap = current;
12970
+ else {
12971
+ hooksMap = new YAMLMap();
12972
+ target.set("hooks", hooksMap);
12973
+ }
12974
+ const written = [];
12975
+ const kept = [];
12976
+ for (const [k, v] of Object.entries(hooks)) {
12977
+ if (v === void 0) continue;
12978
+ if (hooksMap.has(k)) kept.push(k);
12979
+ else {
12980
+ hooksMap.set(k, v);
12981
+ written.push(k);
12982
+ }
12983
+ }
12984
+ return { text: String(doc), written, kept };
12985
+ }
12986
+ function starterRepoConfig(hooks) {
12987
+ const lines = [
12988
+ "# Starter .crew.yaml proposed by `crew repos hooks --write repo`.",
12989
+ "# Review the hooks, then commit this file; it is the durable home for them",
12990
+ "# (docs/REPO_SPEC.md). A route-level copy in crew.yaml is shadowed by it.",
12991
+ `version: ${SPEC_VERSION}`,
12992
+ "platform: any",
12993
+ "",
12994
+ "hooks:"
12995
+ ];
12996
+ const entries = Object.entries(hooks).filter(([, v]) => v !== void 0);
12997
+ if (entries.length === 0) lines.push(' # test: "..."', ' # build: "..."');
12998
+ for (const [k, v] of entries) lines.push(` ${k}: ${JSON.stringify(v)}`);
12999
+ lines.push("", "release:", " # integrate: merging to the base branch is the release; nothing is deployed.", " mode: integrate", "");
13000
+ const text = lines.join("\n");
13001
+ parseRepoConfig(text, ".crew.yaml");
13002
+ return text;
13003
+ }
13004
+ function existingRepoConfig(f) {
13005
+ return REPO_CONFIG_FILENAMES.find((n) => f.exists(n));
13006
+ }
13007
+
13008
+ // src/sample-ticket.ts
13009
+ import { basename as basename8, dirname as dirname13, join as join21 } from "path";
13010
+ var SAMPLE_REPO = "crew-sample";
13011
+ var SAMPLE_REMOTE = `local/${SAMPLE_REPO}`;
13012
+ var SAMPLE_MARKER = "sample: true";
13013
+ var SAMPLE_CREW_YAML = `version: 1
13014
+ platform: unix
13015
+ branch:
13016
+ base: main
13017
+ hooks:
13018
+ test: "true"
13019
+ build: "true"
13020
+ release:
13021
+ mode: integrate
13022
+ versioning: none
13023
+ changelog: false
13024
+ `;
13025
+ var SAMPLE_README = `# crew-sample
13026
+
13027
+ A throwaway repository created by \`crew sample-ticket\`. It exists so the crew
13028
+ has something safe to work on: its test and build hooks always pass, it has no
13029
+ remote, and \`crew sample-ticket --remove\` deletes it.
13030
+ `;
13031
+ function sampleDir(existing, reposBase, workspace) {
13032
+ if (existing.length > 0) return join21(dirname13(existing[0]), SAMPLE_REPO);
13033
+ return join21(reposBase, workspace, SAMPLE_REPO);
13034
+ }
13035
+ var SAMPLE_TITLE = "Sample: add a documented `greet` function with a test";
13036
+ function sampleDescription() {
13037
+ return [
13038
+ SAMPLE_MARKER,
13039
+ "",
13040
+ "> This is a sample ticket filed by `crew sample-ticket` on a throwaway repository. Nothing here is real work.",
13041
+ "",
13042
+ "## What",
13043
+ "",
13044
+ "Add `greet.js` exporting `greet(name)`, which returns `Hello, <name>!` (and `Hello, world!` for no name),",
13045
+ "with a JSDoc comment, and `greet.test.js` covering both cases using `node:test`.",
13046
+ "",
13047
+ "## Acceptance",
13048
+ "",
13049
+ '- `greet("Ada")` returns `Hello, Ada!`; `greet()` returns `Hello, world!`.',
13050
+ "- The test file runs with `node --test` and passes.",
13051
+ "- Add a line to the README saying the function exists.",
13052
+ "",
13053
+ "## Notes",
13054
+ "",
13055
+ "The repo has no remote: leave the branch local. The hooks in `.crew.yaml` always pass, so QA is checking the",
13056
+ "diff against the acceptance list above."
13057
+ ].join("\n");
13058
+ }
13059
+ function isSampleTicket(description) {
13060
+ return (description ?? "").trimStart().startsWith(SAMPLE_MARKER);
13061
+ }
13062
+ function planSampleTicket(i) {
13063
+ return {
13064
+ title: SAMPLE_TITLE,
13065
+ description: sampleDescription(),
13066
+ report_type: "feature",
13067
+ severity: "s4",
13068
+ reporter_name: "crew sample-ticket",
13069
+ status: i.approvedStatus,
13070
+ project_id: i.projectId,
13071
+ repo_id: i.repoId,
13072
+ ...i.filedBy ? { filed_by_id: i.filedBy } : {}
13073
+ };
13074
+ }
13075
+
12935
13076
  // src/doctor-fix.ts
12936
- import { parseDocument as parseDocument2 } from "yaml";
13077
+ import { parseDocument as parseDocument3 } from "yaml";
12937
13078
  var sound = (r) => r.hasRepos && r.problems.length === 0;
12938
13079
  function planFix(f) {
12939
13080
  const actions = [];
@@ -12960,7 +13101,7 @@ function routesInScope(routes, named) {
12960
13101
  return named ? routes.filter((r) => r.route === named) : routes;
12961
13102
  }
12962
13103
  function planEnable(text, route) {
12963
- const doc = parseDocument2(text);
13104
+ const doc = parseDocument3(text);
12964
13105
  routeMapOf(doc, route).set("enabled", true);
12965
13106
  return String(doc);
12966
13107
  }
@@ -14703,6 +14844,7 @@ function usage() {
14703
14844
  crew skills sync [route] push only crew's skill files (e.g. grill-me) into the workspace Agent Skills table
14704
14845
  crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml (--dry-run previews)
14705
14846
  crew repos clone [ROUTE] NAME clone a Repos row's remote into the route directory and attach it (--dir PATH, --dry-run, --json)
14847
+ crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--write route|repo, --json)
14706
14848
  crew repos list ROUTE the checkouts a route has, with the tracker's Repos row each matches
14707
14849
  crew logbook list [route] recent Agent Log entries, filterable by --role/--ticket
14708
14850
  crew logbook show [route] ID [--prompt] one entry; --prompt reconstructs and verifies its prompt
@@ -16382,11 +16524,67 @@ ${changed2.map((c) => ` - ${c}
16382
16524
  case "repos": {
16383
16525
  const sub = positional[1];
16384
16526
  const usage2 = () => {
16385
- process.stderr.write("crew repos add [workspace/project] [name] [path] [--name N] [--project P] [--dry-run] [--no-create]\ncrew repos clone [workspace/project] <name> [--dir PATH] [--dry-run] [--json]\ncrew repos list <workspace/project>\n");
16527
+ process.stderr.write("crew repos add [workspace/project] [name] [path] [--name N] [--project P] [--dry-run] [--no-create]\ncrew repos clone [workspace/project] <name> [--dir PATH] [--dry-run] [--json]\ncrew repos list <workspace/project>\ncrew repos hooks [workspace/project] <name> [--suggest] [--write route|repo] [--json]\n");
16386
16528
  process.exit(2);
16387
16529
  };
16388
- if (sub !== "add" && sub !== "list" && sub !== "clone") usage2();
16530
+ if (sub !== "add" && sub !== "list" && sub !== "clone" && sub !== "hooks") usage2();
16389
16531
  const configText = readFileSync22(cfg.configFile, "utf8");
16532
+ if (sub === "hooks") {
16533
+ try {
16534
+ const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
16535
+ const dir = repoDirOf(configText, dirname16(cfg.configFile), a.route, a.name);
16536
+ if (!existsSync29(dir)) throw new ReposError(`${dir} does not exist on disk`);
16537
+ const files = { exists: (r) => existsSync29(resolvePath(dir, r)), read: (r) => {
16538
+ try {
16539
+ return readFileSync22(resolvePath(dir, r), "utf8");
16540
+ } catch {
16541
+ return void 0;
16542
+ }
16543
+ } };
16544
+ const suggestion = suggestHooks(files);
16545
+ const write = value("write");
16546
+ if (write !== void 0 && write !== "route" && write !== "repo") throw new ReposError(`--write takes "route" or "repo" (got "${write}")`);
16547
+ let written;
16548
+ if (write && suggestion.source === null) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
16549
+ if (write === "route") {
16550
+ const plan = planRouteHooks(configText, a.route, a.name, suggestion.hooks);
16551
+ writeFileSync16(cfg.configFile, plan.text);
16552
+ written = { target: "route", file: cfg.configFile, hooks: plan.written, kept: plan.kept };
16553
+ } else if (write === "repo") {
16554
+ const have = existingRepoConfig(files);
16555
+ if (have) throw new ReposError(`${resolvePath(dir, have)} already exists; not overwriting it`);
16556
+ const file = resolvePath(dir, ".crew.yaml");
16557
+ writeFileSync16(file, starterRepoConfig(suggestion.hooks), { flag: "wx" });
16558
+ written = { target: "repo", file, hooks: Object.keys(suggestion.hooks), kept: [] };
16559
+ }
16560
+ if (flag("json")) {
16561
+ process.stdout.write(`${JSON.stringify({ route: a.route, repo: a.name, dir, ...suggestion, written: written ?? null })}
16562
+ `);
16563
+ } else {
16564
+ if (suggestion.source) process.stdout.write(`${a.name}: proposed from ${suggestion.source}
16565
+ `);
16566
+ for (const [k, v] of Object.entries(suggestion.hooks)) process.stdout.write(` ${k}: ${v}
16567
+ `);
16568
+ for (const n of suggestion.notes) process.stdout.write(`note: ${n}
16569
+ `);
16570
+ if (written) {
16571
+ process.stdout.write(`wrote ${written.hooks.join(", ") || "nothing new"} to ${written.file}
16572
+ `);
16573
+ if (written.kept.length) process.stdout.write(`kept existing route hooks: ${written.kept.join(", ")}
16574
+ `);
16575
+ if (written.target === "repo") process.stdout.write("review it and commit it to the repository\n");
16576
+ }
16577
+ }
16578
+ } catch (e) {
16579
+ if (e instanceof ReposError) {
16580
+ process.stderr.write(`crew repos: ${e.message}
16581
+ `);
16582
+ process.exit(2);
16583
+ }
16584
+ throw e;
16585
+ }
16586
+ break;
16587
+ }
16390
16588
  let routeName = positional[2];
16391
16589
  let addPath = ".";
16392
16590
  let addName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",