tines 0.0.140 → 0.0.142

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 (2) hide show
  1. package/dist/index.js +503 -63
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3716,6 +3716,44 @@ function displayActor(ev) {
3716
3716
  return actorLabel(ev.actor);
3717
3717
  }
3718
3718
 
3719
+ // ../shared/src/requirements.ts
3720
+ function attachFlag(type, slot, contentType) {
3721
+ switch (type) {
3722
+ case "file":
3723
+ return "--file <path>";
3724
+ case "folder":
3725
+ return "--folder <dir>";
3726
+ case "link":
3727
+ return "--link <url>";
3728
+ case "pr":
3729
+ return "--pr <owner/repo#N>";
3730
+ case "text": {
3731
+ const ext = contentType === "text/markdown" ? "md" : contentType === "text/plain" ? "txt" : null;
3732
+ return ext ? `--text @${slot}.${ext}` : "--text <markdown|@file>";
3733
+ }
3734
+ }
3735
+ }
3736
+ function requirementFix(r, ref) {
3737
+ const attach = (type) => `tines issues artifacts attach ${ref} ${r.artifact} ${attachFlag(type, r.artifact, r.content_type)}`;
3738
+ if (r.status === "missing" || r.current_type === null) {
3739
+ return { kind: "attach", command: attach(r.type ?? "file") };
3740
+ }
3741
+ if (r.status === "satisfied") {
3742
+ return { kind: "attach", command: attach(r.current_type) };
3743
+ }
3744
+ if (r.status === "stale") {
3745
+ return {
3746
+ kind: "reattach_or_reaffirm",
3747
+ command: `${attach(r.current_type)} \u2014 or, if the current content still stands: tines issues artifacts reaffirm ${ref} ${r.artifact}`
3748
+ };
3749
+ }
3750
+ const reattachable = r.type === void 0 ? r.current_type === "file" || r.current_type === "text" : r.current_type === r.type;
3751
+ return reattachable ? { kind: "attach", command: attach(r.current_type) } : {
3752
+ kind: "delete_and_attach",
3753
+ command: `tines issues artifacts delete ${ref} ${r.artifact} && ${attach(r.type ?? "file")}`
3754
+ };
3755
+ }
3756
+
3719
3757
  // ../shared/src/client.ts
3720
3758
  var ApiError = class extends Error {
3721
3759
  status;
@@ -4149,6 +4187,29 @@ function artifactSummary(a) {
4149
4187
  return `${prRefLabel(cv)} \u2014 ${cv.pr_repo_url}/pull/${cv.pr_number}`;
4150
4188
  }
4151
4189
  }
4190
+ function artifactTypeLabel(a) {
4191
+ const ct = a.current_version.content_type;
4192
+ return (a.artifact_type === "file" || a.artifact_type === "text") && ct ? `${a.artifact_type}, ${ct}` : a.artifact_type;
4193
+ }
4194
+ function requirementStatus(r) {
4195
+ switch (r.status) {
4196
+ case "satisfied":
4197
+ return `satisfied (v${r.current_version?.version})`;
4198
+ case "missing":
4199
+ return "missing";
4200
+ case "stale":
4201
+ return `stale (v${r.current_version?.version}, attached before the current state)`;
4202
+ case "type_mismatch":
4203
+ return r.type !== void 0 && r.current_type !== null && r.current_type !== r.type ? `type mismatch (holds ${r.current_type})` : `type mismatch (v${r.current_version?.version} is not ${r.content_type ?? r.type})`;
4204
+ }
4205
+ }
4206
+ function requirementLines(r) {
4207
+ const spec = [r.type, r.content_type].filter(Boolean).join(", ");
4208
+ return [
4209
+ `requires artifact "${r.artifact}"${spec ? ` (${spec})` : ""}: ${requirementStatus(r)}${r.description ? ` \u2014 ${r.description}` : ""}`,
4210
+ ...r.fix ? [` fix: ${r.fix}`] : []
4211
+ ];
4212
+ }
4152
4213
  function scheduleRef(s) {
4153
4214
  return `${s.project_name}/${s.name}`;
4154
4215
  }
@@ -4406,12 +4467,10 @@ allowed actions: ${actions.join(", ")}` : "\nallowed actions: none (terminal sta
4406
4467
  const unmet = err.details?.unmet;
4407
4468
  if (Array.isArray(unmet)) {
4408
4469
  for (const raw of unmet) {
4409
- const r = raw;
4410
- const spec = [r.type, r.content_type].filter(Boolean).join(", ");
4411
- message3 += `
4412
- requires artifact "${r.artifact}"${spec ? ` (${spec})` : ""}: ${r.status ?? "unmet"}${r.description ? ` \u2014 ${r.description}` : ""}`;
4413
- if (r.fix) message3 += `
4414
- fix: ${r.fix}`;
4470
+ for (const line of requirementLines(raw)) {
4471
+ message3 += `
4472
+ ${line}`;
4473
+ }
4415
4474
  }
4416
4475
  }
4417
4476
  die(message3);
@@ -4748,9 +4807,334 @@ function register(program3) {
4748
4807
  }
4749
4808
 
4750
4809
  // src/commands/issues.ts
4751
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync4, statSync, writeFileSync as writeFileSync2 } from "node:fs";
4810
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync4, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
4752
4811
  import { basename, dirname as dirname2, join as join2 } from "node:path";
4753
4812
 
4813
+ // src/attach-source.ts
4814
+ import { existsSync as existsSync2, statSync } from "node:fs";
4815
+ var fsProbe = (path2) => {
4816
+ if (!existsSync2(path2)) return "missing";
4817
+ try {
4818
+ return statSync(path2).isDirectory() ? "dir" : "file";
4819
+ } catch {
4820
+ return "missing";
4821
+ }
4822
+ };
4823
+ var SOURCE_FLAGS = ["--file", "--folder", "--text", "--link", "--pr"];
4824
+ function gatesFor(issue, name2) {
4825
+ const gates = [];
4826
+ for (const t of issue.allowed_transitions) {
4827
+ for (const check of t.requires ?? []) {
4828
+ if (name2 === void 0 || check.artifact === name2) {
4829
+ gates.push({ transition: t.name, check });
4830
+ }
4831
+ }
4832
+ }
4833
+ return gates;
4834
+ }
4835
+ function flagFor(type) {
4836
+ return type === "file" ? "--file" : `--${type}`;
4837
+ }
4838
+ function sourceLabel(flags, positional) {
4839
+ if (flags.file !== void 0) return "--file";
4840
+ if (flags.folder !== void 0) return "--folder";
4841
+ if (flags.text !== void 0) return "--text";
4842
+ if (flags.link !== void 0) return "--link";
4843
+ if (flags.pr !== void 0) return "--pr";
4844
+ return positional !== void 0 ? "the positional source" : "the source";
4845
+ }
4846
+ function assertOneSource(flags, positional) {
4847
+ const values = [flags.file, flags.folder, flags.text, flags.link, flags.pr];
4848
+ const given = SOURCE_FLAGS.filter((_, i) => values[i] !== void 0);
4849
+ const count = given.length + (positional !== void 0 ? 1 : 0);
4850
+ if (count === 0) {
4851
+ throw new CliError(
4852
+ "pass exactly one content source: a positional <source> (a path, a URL, or owner/repo#N), --file <path>, --folder <dir>, --text <md|@file>, --link <url>, or --pr <spec> (a link goes in --link; --url is the API base URL)"
4853
+ );
4854
+ }
4855
+ if (count > 1) {
4856
+ const what = [
4857
+ ...given,
4858
+ ...positional !== void 0 ? [`the positional "${positional}"`] : []
4859
+ ];
4860
+ throw new CliError(`pass the source once: got ${what.join(" and ")}`);
4861
+ }
4862
+ }
4863
+ function normalizePositional(raw) {
4864
+ if (raw === "-" || raw === "@-") return { value: raw, stdin: true };
4865
+ if (raw.startsWith("@@")) return { value: raw.slice(1), stdin: false };
4866
+ if (raw.startsWith("@")) return { value: raw.slice(1), stdin: false };
4867
+ return { value: raw, stdin: false };
4868
+ }
4869
+ var isUrl = (v) => /^https?:\/\//.test(v);
4870
+ function concreteContentType(ct) {
4871
+ return ct !== void 0 && ct.includes("/") && !ct.endsWith("/") ? ct : void 0;
4872
+ }
4873
+ function declaredTypes(gates) {
4874
+ return [
4875
+ ...new Set(gates.map((g) => g.check.type).filter((t) => t !== void 0))
4876
+ ];
4877
+ }
4878
+ function shapeCompatible(types, value, stdin, probe) {
4879
+ if (stdin) return types.filter((t) => t === "text" || t === "file");
4880
+ const what = probe(value);
4881
+ if (what === "dir") return types.filter((t) => t === "folder");
4882
+ if (what === "file") return types.filter((t) => t === "text" || t === "file");
4883
+ if (isUrl(value)) {
4884
+ return types.filter((t) => t === "link" || t === "pr" && parsePrSpec(value) !== null);
4885
+ }
4886
+ return types.filter((t) => t === "pr" && parsePrSpec(value) !== null);
4887
+ }
4888
+ function gateSpec(check) {
4889
+ const type = check.type ?? "any type";
4890
+ return check.content_type ? `${type} (${check.content_type})` : type;
4891
+ }
4892
+ function joinTransitions(names) {
4893
+ const unique = [...new Set(names)].map((n) => `"${n}"`);
4894
+ if (unique.length <= 1) return unique.join("");
4895
+ return `${unique.slice(0, -1).join(", ")} and ${unique[unique.length - 1]}`;
4896
+ }
4897
+ function fixCommand(gate, ref) {
4898
+ return gate.check.fix || requirementFix(gate.check, ref).command;
4899
+ }
4900
+ function accepts(gate, type, contentType) {
4901
+ const check = gate.check;
4902
+ if (check.type !== void 0 && check.type !== type) return false;
4903
+ if (check.content_type === void 0) return true;
4904
+ if (contentType === void 0) return true;
4905
+ return contentType.startsWith(check.content_type);
4906
+ }
4907
+ function effectiveContentType(type, source, explicit, sniff) {
4908
+ if (explicit !== void 0) return explicit;
4909
+ if (type === "text") return "text/markdown";
4910
+ if (source.kind === "file-path") return sniff(source.path);
4911
+ if (source.kind === "file-stdin") return "application/octet-stream";
4912
+ return void 0;
4913
+ }
4914
+ function planAttach(input, sniff) {
4915
+ const { ref, name: name2, positional, flags, probe } = input;
4916
+ assertOneSource(flags, positional);
4917
+ const gates = flags.ignoreGates ? [] : input.gates;
4918
+ const plan = positional !== void 0 ? planPositional(name2, positional, gates, probe, flags) : planFlags(flags, probe);
4919
+ if (gates.length > 0) {
4920
+ checkAccepted(plan, gates, ref, name2, flags, positional, sniff);
4921
+ checkExistingSlot(plan, gates, ref, name2, flags, positional);
4922
+ }
4923
+ return plan;
4924
+ }
4925
+ function planPositional(name2, raw, gates, probe, flags) {
4926
+ const { value, stdin } = normalizePositional(raw);
4927
+ const types = declaredTypes(gates);
4928
+ let type;
4929
+ if (types.length === 1) {
4930
+ type = types[0];
4931
+ } else if (types.length > 1) {
4932
+ const compatible = shapeCompatible(types, value, stdin, probe);
4933
+ if (compatible.length === 1) {
4934
+ type = compatible[0];
4935
+ } else {
4936
+ const specs = gates.filter((g) => g.check.type !== void 0).map((g) => `as ${g.check.type} by "${g.transition}"`);
4937
+ throw new CliError(
4938
+ `"${name2}" is gated ${specs.join(" and ")}; pass ${types.map(flagFor).join("/")} to choose`
4939
+ );
4940
+ }
4941
+ }
4942
+ const contentType = flags.contentType ?? gateContentType(gates, type);
4943
+ const withCt = (p) => ({
4944
+ ...p,
4945
+ ...contentType !== void 0 ? { contentType } : {},
4946
+ ...flags.filename !== void 0 ? { filename: flags.filename } : {}
4947
+ });
4948
+ if (type === void 0) {
4949
+ if (stdin) {
4950
+ throw new CliError(
4951
+ `"${name2}" is not gated on this issue, so "-" has no type to read stdin as \u2014 use --text - for a document, or --file <path>`
4952
+ );
4953
+ }
4954
+ const what = probe(value);
4955
+ if (what === "dir") return withCt({ type: "folder", source: { kind: "folder", dir: value } });
4956
+ if (what === "file")
4957
+ return withCt({ type: "file", source: { kind: "file-path", path: value } });
4958
+ const pr = parsePrSpec(value);
4959
+ if (pr) return withCt({ type: "pr", source: { kind: "pr", pr } });
4960
+ if (isUrl(value)) {
4961
+ return withCt({
4962
+ type: "link",
4963
+ source: {
4964
+ kind: "link",
4965
+ url: value,
4966
+ ...flags.title !== void 0 ? { title: flags.title } : {}
4967
+ }
4968
+ });
4969
+ }
4970
+ throw new CliError(
4971
+ `no such file "${value}" \u2014 a positional source is a path, a URL or owner/repo#N; inline text goes in --text (text is only inferred under a text gate)`
4972
+ );
4973
+ }
4974
+ switch (type) {
4975
+ case "text": {
4976
+ if (stdin) return withCt({ type: "text", source: { kind: "text-stdin" } });
4977
+ const what = probe(value);
4978
+ if (what === "dir") {
4979
+ throw new CliError(`"${value}" is a directory; "${name2}" is gated as text`);
4980
+ }
4981
+ if (what === "missing") throw new CliError(`cannot read ${value}: no such file`);
4982
+ return withCt({ type: "text", source: { kind: "text-path", path: value } });
4983
+ }
4984
+ case "file": {
4985
+ if (stdin) return withCt({ type: "file", source: { kind: "file-stdin" } });
4986
+ const what = probe(value);
4987
+ if (what === "dir") {
4988
+ throw new CliError(`"${value}" is a directory; "${name2}" is gated as file`);
4989
+ }
4990
+ if (what === "missing") throw new CliError(`cannot read ${value}: no such file`);
4991
+ return withCt({ type: "file", source: { kind: "file-path", path: value } });
4992
+ }
4993
+ case "folder": {
4994
+ if (probe(value) !== "dir") {
4995
+ throw new CliError(`"${name2}" is gated as folder, but "${value}" is not a directory`);
4996
+ }
4997
+ return withCt({ type: "folder", source: { kind: "folder", dir: value } });
4998
+ }
4999
+ case "link": {
5000
+ if (!isUrl(value)) {
5001
+ throw new CliError(`"${name2}" is gated as link, but "${value}" is not an http(s) URL`);
5002
+ }
5003
+ return withCt({
5004
+ type: "link",
5005
+ source: {
5006
+ kind: "link",
5007
+ url: value,
5008
+ ...flags.title !== void 0 ? { title: flags.title } : {}
5009
+ }
5010
+ });
5011
+ }
5012
+ case "pr": {
5013
+ const pr = parsePrSpec(value);
5014
+ if (!pr) {
5015
+ throw new CliError(
5016
+ `"${name2}" is gated as pr, but "${value}" is not owner/repo#N or a GitHub PR URL`
5017
+ );
5018
+ }
5019
+ return withCt({ type: "pr", source: { kind: "pr", pr } });
5020
+ }
5021
+ }
5022
+ }
5023
+ function gateContentType(gates, type) {
5024
+ if (type !== "text" && type !== "file") return void 0;
5025
+ const declared = [
5026
+ ...new Set(
5027
+ gates.filter((g) => g.check.type === type).map((g) => concreteContentType(g.check.content_type)).filter((ct) => ct !== void 0)
5028
+ )
5029
+ ];
5030
+ return declared.length === 1 ? declared[0] : void 0;
5031
+ }
5032
+ function planFlags(flags, probe) {
5033
+ const ct = flags.contentType;
5034
+ const filename = flags.filename;
5035
+ const modifiers = {
5036
+ ...ct !== void 0 ? { contentType: ct } : {},
5037
+ ...filename !== void 0 ? { filename } : {}
5038
+ };
5039
+ if (flags.folder !== void 0) {
5040
+ if (probe(flags.folder) !== "dir") {
5041
+ throw new CliError(`--folder needs a directory, got "${flags.folder}"`);
5042
+ }
5043
+ return { type: "folder", source: { kind: "folder", dir: flags.folder }, ...modifiers };
5044
+ }
5045
+ if (flags.file !== void 0) {
5046
+ const what = probe(flags.file);
5047
+ if (what === "missing") throw new CliError(`cannot read ${flags.file}: no such file`);
5048
+ if (what === "dir")
5049
+ throw new CliError(`--file needs a file, got the directory "${flags.file}"`);
5050
+ return { type: "file", source: { kind: "file-path", path: flags.file }, ...modifiers };
5051
+ }
5052
+ if (flags.text !== void 0) {
5053
+ const v = flags.text;
5054
+ const source = v === "-" || v === "@-" ? { kind: "text-stdin" } : v.startsWith("@@") ? { kind: "text-inline", value: v.slice(1) } : v.startsWith("@") ? { kind: "text-path", path: v.slice(1) } : { kind: "text-inline", value: v };
5055
+ return { type: "text", source, ...modifiers };
5056
+ }
5057
+ if (flags.link !== void 0) {
5058
+ return {
5059
+ type: "link",
5060
+ source: {
5061
+ kind: "link",
5062
+ url: flags.link,
5063
+ ...flags.title !== void 0 ? { title: flags.title } : {}
5064
+ },
5065
+ ...modifiers
5066
+ };
5067
+ }
5068
+ const parsed = parsePrSpec(flags.pr);
5069
+ if (!parsed) throw new CliError(`--pr takes owner/repo#N or a GitHub PR URL, got "${flags.pr}"`);
5070
+ return { type: "pr", source: { kind: "pr", pr: parsed }, ...modifiers };
5071
+ }
5072
+ function checkExistingSlot(plan, gates, ref, name2, flags, positional) {
5073
+ const held = gates.find((g) => g.check.current_type !== null)?.check.current_type;
5074
+ if (held === void 0 || held === null || held === plan.type) return;
5075
+ const rejecting = gates.find((g) => g.check.type !== void 0 && g.check.type !== held);
5076
+ const fix = rejecting ? fixCommand(rejecting, ref) : `tines issues artifacts delete ${ref} ${name2} && tines issues artifacts attach ${ref} ${name2} ${flagFor(plan.type)} <source>`;
5077
+ throw new CliError(
5078
+ `"${name2}" already holds a ${held} artifact and the type is immutable; ${sourceLabel(flags, positional)} would attach ${plan.type}. Use: ${fix} \u2014 or attach it under a different name (--ignore-gates does not bypass this; the server rejects the type change too)`
5079
+ );
5080
+ }
5081
+ function checkAccepted(plan, gates, ref, name2, flags, positional, sniff) {
5082
+ const effective = effectiveContentType(plan.type, plan.source, plan.contentType, sniff);
5083
+ if (gates.some((g2) => accepts(g2, plan.type, effective))) return;
5084
+ const label = sourceLabel(flags, positional);
5085
+ const typeMatched = gates.filter((g2) => (g2.check.type ?? plan.type) === plan.type);
5086
+ if (typeMatched.length > 0) {
5087
+ const g2 = typeMatched[0];
5088
+ throw new CliError(
5089
+ `"${name2}" is gated by ${joinTransitions(typeMatched.map((x) => x.transition))} as ${gateSpec(g2.check)}; ${label} would attach ${effective ?? plan.type}, which does not satisfy it. Use: ${fixCommand(g2, ref)} with --content-type <mime under ${g2.check.content_type}> (or --ignore-gates to attach it anyway)`
5090
+ );
5091
+ }
5092
+ const g = gates[0];
5093
+ throw new CliError(
5094
+ `"${name2}" is gated by ${joinTransitions(gates.map((x) => x.transition))} as ${gateSpec(g.check)}; ${label} would create a ${plan.type} artifact that can never satisfy it. Use: ${fixCommand(g, ref)} (or --ignore-gates to attach a ${plan.type} anyway)`
5095
+ );
5096
+ }
5097
+ function satisfiedBy(artifact, gates) {
5098
+ const satisfies = [];
5099
+ const rejects = [];
5100
+ for (const gate of gates) {
5101
+ if (gate.check.artifact !== artifact.name) continue;
5102
+ if (accepts(gate, artifact.artifact_type, artifact.current_version.content_type ?? void 0)) {
5103
+ satisfies.push(gate.transition);
5104
+ } else {
5105
+ rejects.push({ transition: gate.transition, wants: wantsLabel(gate, artifact) });
5106
+ }
5107
+ }
5108
+ return { satisfies, rejects };
5109
+ }
5110
+ function wantsLabel(gate, artifact) {
5111
+ const check = gate.check;
5112
+ if (check.type !== void 0 && check.type !== artifact.artifact_type)
5113
+ return `wants ${check.type}`;
5114
+ return `wants ${check.content_type ?? check.type ?? "something else"}`;
5115
+ }
5116
+ function gateLabel(artifact, gates) {
5117
+ const { rejects } = satisfiedBy(artifact, gates);
5118
+ return [...new Set(rejects.map((r) => r.wants))].join("; ");
5119
+ }
5120
+ var ATTACH_SOURCE_HELP = `
5121
+ Source:
5122
+ A positional <source> is typed by the slot's gate when this issue has one:
5123
+ a text gate reads the path as the document (the gate's content type wins over
5124
+ the extension), a file gate uploads its bytes, a folder gate walks it, and a
5125
+ link/pr gate takes a URL or owner/repo#N. "-" reads stdin under a text or
5126
+ file gate.
5127
+
5128
+ With no gate, the shape alone decides: a directory is a folder, an http(s)
5129
+ URL is a link (a GitHub PR URL is a pr), owner/repo#N is a pr, and anything
5130
+ else is a file \u2014 a .md path included. Inline text is never inferred: pass it
5131
+ with --text.
5132
+
5133
+ Flags still override, and are refused before any network write when no
5134
+ available transition's requirement for the slot could ever accept what they
5135
+ would create. --ignore-gates attaches it anyway. Prefix a source starting
5136
+ with "-" with "--".`;
5137
+
4754
5138
  // src/help-guard.ts
4755
5139
  function helpGuard(command, markdown) {
4756
5140
  if (markdown === "--help" || markdown === "-h") {
@@ -4879,6 +5263,13 @@ ${issue.description}`);
4879
5263
  `
4880
5264
  allowed actions: ${allowed.length ? allowed.join(", ") : "none (terminal state)"}`
4881
5265
  );
5266
+ for (const t of issue.allowed_transitions) {
5267
+ for (const check of t.requires ?? []) {
5268
+ const [head, ...rest] = requirementLines(check);
5269
+ console.log(` "${t.name}" ${head}`);
5270
+ for (const line of rest) console.log(` ${line}`);
5271
+ }
5272
+ }
4882
5273
  if (issue.comments.length > 0) {
4883
5274
  console.log(`
4884
5275
  comments (${issue.comments.length}):`);
@@ -4887,6 +5278,18 @@ comments (${issue.comments.length}):`);
4887
5278
  }
4888
5279
  }
4889
5280
  }
5281
+ function readTextSource(source) {
5282
+ if (source.kind === "text-inline") return source.value;
5283
+ if (source.kind === "text-stdin") return readBodyValue("-");
5284
+ if (source.kind === "text-path") {
5285
+ try {
5286
+ return readFileSync4(source.path, "utf8");
5287
+ } catch (err) {
5288
+ die(`cannot read ${source.path}: ${err instanceof Error ? err.message : String(err)}`);
5289
+ }
5290
+ }
5291
+ throw new Error(`not a text source: ${source.kind}`);
5292
+ }
4890
5293
  function walkFolder(dir) {
4891
5294
  const files = [];
4892
5295
  const walk = (abs, rel) => {
@@ -4911,7 +5314,16 @@ function printExplainer(issue, ex) {
4911
5314
  console.log(`
4912
5315
  ${ex.verdict}
4913
5316
  `);
4914
- table(ex.checks.map((c) => [` ${c.ok ? "ok" : "FAIL"}`, c.name.replaceAll("_", " "), c.detail]));
5317
+ table(
5318
+ ex.checks.map((c) => [
5319
+ ` ${c.ok ? "ok" : "FAIL"}`,
5320
+ c.name.replaceAll("_", " "),
5321
+ c.detail,
5322
+ // The remedy, when the check has one and it is something to run
5323
+ // here: a link is useless without an origin.
5324
+ c.action?.cli ? `fix: ${c.action.cli}` : ""
5325
+ ])
5326
+ );
4915
5327
  if (ex.pin) {
4916
5328
  console.log(
4917
5329
  `
@@ -5259,7 +5671,7 @@ skills: ${context.skills.map((s) => s.name).join(", ")}`);
5259
5671
  `refusing to write: checkout-directory conflict${context.conflicts.length === 1 ? "" : "s"} among the effective repos (${context.conflicts.map((c) => `"${c.dir}": ${c.item_ids.join(", ")}`).join("; ")}); rename or re-dir the items first`
5260
5672
  );
5261
5673
  }
5262
- if (existsSync2(opts.out) && readdirSync(opts.out).length > 0 && !opts.force) {
5674
+ if (existsSync3(opts.out) && readdirSync(opts.out).length > 0 && !opts.force) {
5263
5675
  die(`refusing to write into non-empty directory ${opts.out} (pass --force to override)`);
5264
5676
  }
5265
5677
  mkdirSync2(opts.out, { recursive: true });
@@ -5314,13 +5726,17 @@ skills: ${context.skills.map((s) => s.name).join(", ")}`);
5314
5726
  const res = await api.listArtifacts(issue.id);
5315
5727
  if (opts.json) return printJson(res);
5316
5728
  if (res.items.length === 0) return console.log("no artifacts attached");
5729
+ const gates = gatesFor(issue);
5730
+ const labels = res.items.map((a) => gateLabel(a, gates));
5731
+ const gated = labels.some((l) => l !== "");
5317
5732
  table([
5318
- ["NAME", "TYPE", "VERSION", "FRESH", "SUMMARY", "ATTACHED"],
5319
- ...res.items.map((a) => [
5733
+ ["NAME", "TYPE", "VERSION", "FRESH", ...gated ? ["GATE"] : [], "SUMMARY", "ATTACHED"],
5734
+ ...res.items.map((a, i) => [
5320
5735
  a.name,
5321
5736
  a.artifact_type,
5322
5737
  `v${a.current_version.version}`,
5323
5738
  a.fresh ? "yes" : "no",
5739
+ ...gated ? [labels[i] || "ok"] : [],
5324
5740
  artifactSummary(a),
5325
5741
  timestamp(a.current_version.created_at)
5326
5742
  ])
@@ -5358,79 +5774,97 @@ files (v${artifact.current_version.version}):`);
5358
5774
  }
5359
5775
  });
5360
5776
  withCommon(
5361
- artifactsCmd.command("attach <ref> <name>").description(
5777
+ artifactsCmd.command("attach <ref> <name> [source]").description(
5362
5778
  "Attach content to a named artifact slot (creates it, or appends the next version)"
5363
5779
  ).option("-f, --file <path>", "upload a file (MIME sniffed from the extension)").option(
5364
5780
  "--folder <dir>",
5365
5781
  "snapshot a directory tree as one version (collect locally, attach once; MIME per file sniffed)"
5366
- ).option("-t, --text <md|@file>", "inline text document: inline Markdown or @file").option("--link <url>", "link: the URL to attach").option("--pr <spec>", "PR reference: owner/repo#N or a GitHub PR URL").option("--content-type <mime>", "declared MIME type (with --file or --text)").option("--filename <name>", "display filename (with --text; defaults to <name>.md)").option("--title <title>", "display title (with --link)").option("-d, --description <text>", "artifact description, shown in lists and launch prompts")
5782
+ ).option("-t, --text <md|@file>", "inline text document: inline Markdown or @file").option("--link <url>", "link: the URL to attach").option("--pr <spec>", "PR reference: owner/repo#N or a GitHub PR URL").option("--content-type <mime>", "declared MIME type (with --file or --text)").option("--filename <name>", "display filename (with --text; defaults to <name>.md)").option("--title <title>", "display title (with --link)").option("-d, --description <text>", "artifact description, shown in lists and launch prompts").option(
5783
+ "--ignore-gates",
5784
+ "attach this type even when a transition requirement rejects it (skips inference and the pre-flight checks)"
5785
+ ).addHelpText("after", ATTACH_SOURCE_HELP)
5367
5786
  ).action(
5368
- async (ref, name2, opts) => {
5787
+ async (ref, name2, source, opts) => {
5369
5788
  const api = client(opts);
5370
- const sources = [opts.file, opts.folder, opts.text, opts.link, opts.pr].filter(
5371
- (v) => v !== void 0
5372
- );
5373
- if (sources.length !== 1) {
5374
- die(
5375
- "pass exactly one content source: --file <path>, --folder <dir>, --text <md|@file>, --link <url>, or --pr <spec> (a link goes in --link; --url is the API base URL)"
5376
- );
5789
+ try {
5790
+ assertOneSource(opts, source);
5791
+ } catch (err) {
5792
+ die(err instanceof Error ? err.message : String(err));
5377
5793
  }
5378
5794
  const issue = await resolveIssue(api, ref);
5795
+ const gates = gatesFor(issue, name2);
5796
+ const plan = planAttach(
5797
+ {
5798
+ ref: `${issue.project_name}/${issue.number}`,
5799
+ name: name2,
5800
+ positional: source,
5801
+ flags: opts,
5802
+ gates,
5803
+ probe: fsProbe
5804
+ },
5805
+ sniffContentType
5806
+ );
5807
+ const withDescription = opts.description !== void 0 ? { description: opts.description } : {};
5379
5808
  let artifact;
5380
- if (opts.folder !== void 0) {
5381
- if (!existsSync2(opts.folder) || !statSync(opts.folder).isDirectory()) {
5382
- die(`--folder needs a directory, got "${opts.folder}"`);
5383
- }
5384
- const files = walkFolder(opts.folder);
5385
- if (files.length === 0) die(`${opts.folder} contains no files to snapshot`);
5809
+ if (plan.type === "folder") {
5810
+ const dir = plan.source.dir;
5811
+ const files = walkFolder(dir);
5812
+ if (files.length === 0) die(`${dir} contains no files to snapshot`);
5386
5813
  artifact = await api.uploadArtifactFolder(issue.id, name2, files);
5387
5814
  if (opts.description !== void 0) {
5388
5815
  artifact = await api.putArtifact(issue.id, name2, { description: opts.description });
5389
5816
  }
5390
- } else if (opts.file !== void 0) {
5817
+ } else if (plan.type === "file") {
5818
+ const fromPath = plan.source.kind === "file-path" ? plan.source.path : null;
5391
5819
  let bytes;
5392
- try {
5393
- bytes = readFileSync4(opts.file);
5394
- } catch (err) {
5395
- die(`cannot read ${opts.file}: ${err instanceof Error ? err.message : String(err)}`);
5820
+ if (fromPath === null) {
5821
+ if (process.stdin.isTTY) die('"-" reads the file from stdin, but stdin is a terminal');
5822
+ bytes = readFileSync4(0);
5823
+ } else {
5824
+ try {
5825
+ bytes = readFileSync4(fromPath);
5826
+ } catch (err) {
5827
+ die(`cannot read ${fromPath}: ${err instanceof Error ? err.message : String(err)}`);
5828
+ }
5396
5829
  }
5397
5830
  artifact = await api.uploadArtifactFile(issue.id, name2, bytes, {
5398
- filename: opts.filename ?? basename(opts.file),
5399
- contentType: opts.contentType ?? sniffContentType(opts.file)
5831
+ filename: plan.filename ?? (fromPath === null ? name2 : basename(fromPath)),
5832
+ contentType: plan.contentType ?? (fromPath === null ? "application/octet-stream" : sniffContentType(fromPath))
5400
5833
  });
5401
5834
  if (opts.description !== void 0) {
5402
5835
  artifact = await api.putArtifact(issue.id, name2, { description: opts.description });
5403
5836
  }
5404
- } else if (opts.text !== void 0) {
5837
+ } else if (plan.type === "text") {
5838
+ const content = readTextSource(plan.source);
5405
5839
  artifact = await api.putArtifact(issue.id, name2, {
5406
5840
  type: "text",
5407
- content: readBodyValue(opts.text),
5408
- ...opts.filename !== void 0 ? { filename: opts.filename } : {},
5409
- ...opts.contentType !== void 0 ? { content_type: opts.contentType } : {},
5410
- ...opts.description !== void 0 ? { description: opts.description } : {}
5841
+ content,
5842
+ ...plan.filename !== void 0 ? { filename: plan.filename } : {},
5843
+ ...plan.contentType !== void 0 ? { content_type: plan.contentType } : {},
5844
+ ...withDescription
5411
5845
  });
5412
- } else if (opts.link !== void 0) {
5846
+ } else if (plan.type === "link") {
5847
+ const link = plan.source;
5413
5848
  artifact = await api.putArtifact(issue.id, name2, {
5414
5849
  type: "link",
5415
- url: opts.link,
5416
- ...opts.title !== void 0 ? { title: opts.title } : {},
5417
- ...opts.description !== void 0 ? { description: opts.description } : {}
5850
+ url: link.url,
5851
+ ...link.title !== void 0 ? { title: link.title } : {},
5852
+ ...withDescription
5418
5853
  });
5419
5854
  } else {
5420
- const parsed = parsePrSpec(opts.pr);
5421
- if (!parsed) {
5422
- die(`--pr takes owner/repo#N or a GitHub PR URL, got "${opts.pr}"`);
5423
- }
5855
+ const { pr } = plan.source;
5424
5856
  artifact = await api.putArtifact(issue.id, name2, {
5425
5857
  type: "pr",
5426
- pr_repo_url: parsed.repo_url,
5427
- pr_number: parsed.number,
5428
- ...opts.description !== void 0 ? { description: opts.description } : {}
5858
+ pr_repo_url: pr.repo_url,
5859
+ pr_number: pr.number,
5860
+ ...withDescription
5429
5861
  });
5430
5862
  }
5431
5863
  if (opts.json) return printJson(artifact);
5864
+ const { satisfies, rejects } = satisfiedBy(artifact, gates);
5865
+ const gateNote = satisfies.length > 0 ? `; satisfies ${satisfies.map((t) => `"${t}"`).join(", ")}` : rejects.length > 0 ? `, but does not satisfy ${rejects.map((r) => `"${r.transition}" (${r.wants})`).join(", ")}` : "";
5432
5866
  console.log(
5433
- `attached "${artifact.name}" v${artifact.current_version.version} (${artifactSummary(artifact)}) to ${issue.project_name}/${issue.number} \u2014 fresh`
5867
+ `attached "${artifact.name}" v${artifact.current_version.version} (${artifactTypeLabel(artifact)}) to ${issue.project_name}/${issue.number} \u2014 fresh${gateNote}`
5434
5868
  );
5435
5869
  }
5436
5870
  );
@@ -5476,7 +5910,7 @@ files (v${artifact.current_version.version}):`);
5476
5910
  if (opts.out === void 0) {
5477
5911
  die(`artifact "${name2}" is a folder \u2014 pass --out <dir> to write its tree`);
5478
5912
  }
5479
- if (existsSync2(opts.out) && !statSync(opts.out).isDirectory()) {
5913
+ if (existsSync3(opts.out) && !statSync2(opts.out).isDirectory()) {
5480
5914
  die(`--out for a folder must be a directory, and "${opts.out}" is a file`);
5481
5915
  }
5482
5916
  const files = version.files ?? [];
@@ -5499,7 +5933,7 @@ files (v${artifact.current_version.version}):`);
5499
5933
  const bytes = Buffer.from(content.bytes);
5500
5934
  if (opts.out !== void 0) {
5501
5935
  let target2 = opts.out;
5502
- if (existsSync2(target2) && statSync(target2).isDirectory()) {
5936
+ if (existsSync3(target2) && statSync2(target2).isDirectory()) {
5503
5937
  target2 = join2(target2, version.filename ?? name2);
5504
5938
  }
5505
5939
  writeFileSync2(target2, bytes);
@@ -6103,12 +6537,12 @@ import { hostname as hostname2 } from "node:os";
6103
6537
  import { spawn as spawn2 } from "node:child_process";
6104
6538
  import {
6105
6539
  createWriteStream,
6106
- existsSync as existsSync4,
6540
+ existsSync as existsSync5,
6107
6541
  mkdirSync as mkdirSync4,
6108
6542
  readFileSync as readFileSync9,
6109
6543
  realpathSync,
6110
6544
  rmSync as rmSync2,
6111
- statSync as statSync3,
6545
+ statSync as statSync4,
6112
6546
  unlinkSync as unlinkSync2,
6113
6547
  writeFileSync as writeFileSync3
6114
6548
  } from "node:fs";
@@ -6224,7 +6658,7 @@ var ClaudeStreamRenderer = class {
6224
6658
 
6225
6659
  // src/daemon/cli-refresh.ts
6226
6660
  import { spawn } from "node:child_process";
6227
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync7 } from "node:fs";
6661
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync7 } from "node:fs";
6228
6662
  import { dirname as dirname3, join as join3 } from "node:path";
6229
6663
 
6230
6664
  // src/daemon/support.ts
@@ -6550,7 +6984,7 @@ function installedVersion(prefix) {
6550
6984
  }
6551
6985
  function lastGood(prefix) {
6552
6986
  const binDir = binDirOf(prefix);
6553
- if (!existsSync3(join3(binDir, PACKAGE))) return AMBIENT_CLI;
6987
+ if (!existsSync4(join3(binDir, PACKAGE))) return AMBIENT_CLI;
6554
6988
  return { binDir, version: installedVersion(prefix), source: "stale" };
6555
6989
  }
6556
6990
  function runNpm(file, args, cwd, timeoutMs) {
@@ -6596,7 +7030,7 @@ async function installAgentCli(opts) {
6596
7030
  let result = await runNpm("npm", args, prefix, timeoutMs);
6597
7031
  if (result.spawnError?.code === "ENOENT") {
6598
7032
  const sibling = join3(dirname3(process.execPath), "npm");
6599
- if (existsSync3(sibling)) result = await runNpm(sibling, args, prefix, timeoutMs);
7033
+ if (existsSync4(sibling)) result = await runNpm(sibling, args, prefix, timeoutMs);
6600
7034
  }
6601
7035
  if (result.code !== 0) {
6602
7036
  const fallback = lastGood(prefix);
@@ -6618,7 +7052,7 @@ function message(err) {
6618
7052
  }
6619
7053
 
6620
7054
  // src/daemon/store.ts
6621
- import { readdirSync as readdirSync2, readFileSync as readFileSync8, rmSync, statSync as statSync2 } from "node:fs";
7055
+ import { readdirSync as readdirSync2, readFileSync as readFileSync8, rmSync, statSync as statSync3 } from "node:fs";
6622
7056
  import { join as join4 } from "node:path";
6623
7057
  function credentialsKey(url, name2) {
6624
7058
  return `${url.replace(/\/+$/, "")}#${name2}`;
@@ -6739,7 +7173,7 @@ function directorySizeBytes(path2) {
6739
7173
  if (entry.isDirectory()) total += directorySizeBytes(child);
6740
7174
  else if (entry.isFile()) {
6741
7175
  try {
6742
- total += statSync2(child).size;
7176
+ total += statSync3(child).size;
6743
7177
  } catch {
6744
7178
  }
6745
7179
  }
@@ -6756,7 +7190,7 @@ async function uploadRawLog(run) {
6756
7190
  run.rawSpoolPath = void 0;
6757
7191
  try {
6758
7192
  await new Promise((resolve) => run.rawSpool?.end(resolve) ?? resolve());
6759
- const size = statSync3(path2).size;
7193
+ const size = statSync4(path2).size;
6760
7194
  if (size > 0) {
6761
7195
  let body = readFileSync9(path2);
6762
7196
  if (body.byteLength > RUN_LOG_RAW_MAX_BYTES) {
@@ -6819,7 +7253,7 @@ async function runDaemon(opts) {
6819
7253
  rmSync2(workspace, { recursive: true, force: true });
6820
7254
  return;
6821
7255
  }
6822
- if (!existsSync4(workspace)) return;
7256
+ if (!existsSync5(workspace)) return;
6823
7257
  try {
6824
7258
  writeKeptMarker(workspace, { ...marker, kept_at: (/* @__PURE__ */ new Date()).toISOString() });
6825
7259
  log(`run ${marker.run_id}: workspace kept at ${workspace}`);
@@ -6852,6 +7286,9 @@ async function runDaemon(opts) {
6852
7286
  creds = { runner_id: registered.runner.id, token: registered.runner_token };
6853
7287
  saveRunnerCredentials(opts.configDir, opts.url, opts.name, creds);
6854
7288
  log(`registered runner "${opts.name}" (${creds.runner_id}); token stored in ${opts.configDir}`);
7289
+ log(
7290
+ `next: route work to "${opts.name}" and turn automation on \u2014 ${opts.url}/agents (or: tines routing set ${opts.name} && tines supervisor enable)`
7291
+ );
6855
7292
  }
6856
7293
  const client2 = createApiClient({ baseUrl: opts.url, apiKey: creds.token });
6857
7294
  const statePath = daemonStatePath(opts.configDir, creds.runner_id);
@@ -7440,7 +7877,10 @@ function register8(program3) {
7440
7877
  withCommon(
7441
7878
  runnerCmd.command("daemon").description(
7442
7879
  "Run the local runner daemon: register/reconnect, poll for assigned runs, execute them"
7443
- ).option("--name <name>", "runner name, unique per user (default: this hostname)").option("--harness <harness>", "claude-code | codex | custom", "claude-code").option(
7880
+ ).option(
7881
+ "--name <name>",
7882
+ "runner name, unique per user; name it machine-plus-harness, e.g. macbook-claude (default: this hostname)"
7883
+ ).option("--harness <harness>", "claude-code | codex | custom", "claude-code").option(
7444
7884
  "--command <template>",
7445
7885
  "custom harness command template ({prompt_file}, {workspace}, {model})"
7446
7886
  ).option("--max-concurrent <n>", "maximum simultaneous runs", (v) => Number.parseInt(v, 10), 1).option(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tines",
3
- "version": "0.0.140",
3
+ "version": "0.0.142",
4
4
  "description": "CLI for Tines, an orchestration layer for AI agents",
5
5
  "repository": {
6
6
  "type": "git",