tines 0.0.140 → 0.0.141

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 +486 -61
  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) => {
@@ -5259,7 +5662,7 @@ skills: ${context.skills.map((s) => s.name).join(", ")}`);
5259
5662
  `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
5663
  );
5261
5664
  }
5262
- if (existsSync2(opts.out) && readdirSync(opts.out).length > 0 && !opts.force) {
5665
+ if (existsSync3(opts.out) && readdirSync(opts.out).length > 0 && !opts.force) {
5263
5666
  die(`refusing to write into non-empty directory ${opts.out} (pass --force to override)`);
5264
5667
  }
5265
5668
  mkdirSync2(opts.out, { recursive: true });
@@ -5314,13 +5717,17 @@ skills: ${context.skills.map((s) => s.name).join(", ")}`);
5314
5717
  const res = await api.listArtifacts(issue.id);
5315
5718
  if (opts.json) return printJson(res);
5316
5719
  if (res.items.length === 0) return console.log("no artifacts attached");
5720
+ const gates = gatesFor(issue);
5721
+ const labels = res.items.map((a) => gateLabel(a, gates));
5722
+ const gated = labels.some((l) => l !== "");
5317
5723
  table([
5318
- ["NAME", "TYPE", "VERSION", "FRESH", "SUMMARY", "ATTACHED"],
5319
- ...res.items.map((a) => [
5724
+ ["NAME", "TYPE", "VERSION", "FRESH", ...gated ? ["GATE"] : [], "SUMMARY", "ATTACHED"],
5725
+ ...res.items.map((a, i) => [
5320
5726
  a.name,
5321
5727
  a.artifact_type,
5322
5728
  `v${a.current_version.version}`,
5323
5729
  a.fresh ? "yes" : "no",
5730
+ ...gated ? [labels[i] || "ok"] : [],
5324
5731
  artifactSummary(a),
5325
5732
  timestamp(a.current_version.created_at)
5326
5733
  ])
@@ -5358,79 +5765,97 @@ files (v${artifact.current_version.version}):`);
5358
5765
  }
5359
5766
  });
5360
5767
  withCommon(
5361
- artifactsCmd.command("attach <ref> <name>").description(
5768
+ artifactsCmd.command("attach <ref> <name> [source]").description(
5362
5769
  "Attach content to a named artifact slot (creates it, or appends the next version)"
5363
5770
  ).option("-f, --file <path>", "upload a file (MIME sniffed from the extension)").option(
5364
5771
  "--folder <dir>",
5365
5772
  "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")
5773
+ ).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(
5774
+ "--ignore-gates",
5775
+ "attach this type even when a transition requirement rejects it (skips inference and the pre-flight checks)"
5776
+ ).addHelpText("after", ATTACH_SOURCE_HELP)
5367
5777
  ).action(
5368
- async (ref, name2, opts) => {
5778
+ async (ref, name2, source, opts) => {
5369
5779
  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
- );
5780
+ try {
5781
+ assertOneSource(opts, source);
5782
+ } catch (err) {
5783
+ die(err instanceof Error ? err.message : String(err));
5377
5784
  }
5378
5785
  const issue = await resolveIssue(api, ref);
5786
+ const gates = gatesFor(issue, name2);
5787
+ const plan = planAttach(
5788
+ {
5789
+ ref: `${issue.project_name}/${issue.number}`,
5790
+ name: name2,
5791
+ positional: source,
5792
+ flags: opts,
5793
+ gates,
5794
+ probe: fsProbe
5795
+ },
5796
+ sniffContentType
5797
+ );
5798
+ const withDescription = opts.description !== void 0 ? { description: opts.description } : {};
5379
5799
  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`);
5800
+ if (plan.type === "folder") {
5801
+ const dir = plan.source.dir;
5802
+ const files = walkFolder(dir);
5803
+ if (files.length === 0) die(`${dir} contains no files to snapshot`);
5386
5804
  artifact = await api.uploadArtifactFolder(issue.id, name2, files);
5387
5805
  if (opts.description !== void 0) {
5388
5806
  artifact = await api.putArtifact(issue.id, name2, { description: opts.description });
5389
5807
  }
5390
- } else if (opts.file !== void 0) {
5808
+ } else if (plan.type === "file") {
5809
+ const fromPath = plan.source.kind === "file-path" ? plan.source.path : null;
5391
5810
  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)}`);
5811
+ if (fromPath === null) {
5812
+ if (process.stdin.isTTY) die('"-" reads the file from stdin, but stdin is a terminal');
5813
+ bytes = readFileSync4(0);
5814
+ } else {
5815
+ try {
5816
+ bytes = readFileSync4(fromPath);
5817
+ } catch (err) {
5818
+ die(`cannot read ${fromPath}: ${err instanceof Error ? err.message : String(err)}`);
5819
+ }
5396
5820
  }
5397
5821
  artifact = await api.uploadArtifactFile(issue.id, name2, bytes, {
5398
- filename: opts.filename ?? basename(opts.file),
5399
- contentType: opts.contentType ?? sniffContentType(opts.file)
5822
+ filename: plan.filename ?? (fromPath === null ? name2 : basename(fromPath)),
5823
+ contentType: plan.contentType ?? (fromPath === null ? "application/octet-stream" : sniffContentType(fromPath))
5400
5824
  });
5401
5825
  if (opts.description !== void 0) {
5402
5826
  artifact = await api.putArtifact(issue.id, name2, { description: opts.description });
5403
5827
  }
5404
- } else if (opts.text !== void 0) {
5828
+ } else if (plan.type === "text") {
5829
+ const content = readTextSource(plan.source);
5405
5830
  artifact = await api.putArtifact(issue.id, name2, {
5406
5831
  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 } : {}
5832
+ content,
5833
+ ...plan.filename !== void 0 ? { filename: plan.filename } : {},
5834
+ ...plan.contentType !== void 0 ? { content_type: plan.contentType } : {},
5835
+ ...withDescription
5411
5836
  });
5412
- } else if (opts.link !== void 0) {
5837
+ } else if (plan.type === "link") {
5838
+ const link = plan.source;
5413
5839
  artifact = await api.putArtifact(issue.id, name2, {
5414
5840
  type: "link",
5415
- url: opts.link,
5416
- ...opts.title !== void 0 ? { title: opts.title } : {},
5417
- ...opts.description !== void 0 ? { description: opts.description } : {}
5841
+ url: link.url,
5842
+ ...link.title !== void 0 ? { title: link.title } : {},
5843
+ ...withDescription
5418
5844
  });
5419
5845
  } 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
- }
5846
+ const { pr } = plan.source;
5424
5847
  artifact = await api.putArtifact(issue.id, name2, {
5425
5848
  type: "pr",
5426
- pr_repo_url: parsed.repo_url,
5427
- pr_number: parsed.number,
5428
- ...opts.description !== void 0 ? { description: opts.description } : {}
5849
+ pr_repo_url: pr.repo_url,
5850
+ pr_number: pr.number,
5851
+ ...withDescription
5429
5852
  });
5430
5853
  }
5431
5854
  if (opts.json) return printJson(artifact);
5855
+ const { satisfies, rejects } = satisfiedBy(artifact, gates);
5856
+ 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
5857
  console.log(
5433
- `attached "${artifact.name}" v${artifact.current_version.version} (${artifactSummary(artifact)}) to ${issue.project_name}/${issue.number} \u2014 fresh`
5858
+ `attached "${artifact.name}" v${artifact.current_version.version} (${artifactTypeLabel(artifact)}) to ${issue.project_name}/${issue.number} \u2014 fresh${gateNote}`
5434
5859
  );
5435
5860
  }
5436
5861
  );
@@ -5476,7 +5901,7 @@ files (v${artifact.current_version.version}):`);
5476
5901
  if (opts.out === void 0) {
5477
5902
  die(`artifact "${name2}" is a folder \u2014 pass --out <dir> to write its tree`);
5478
5903
  }
5479
- if (existsSync2(opts.out) && !statSync(opts.out).isDirectory()) {
5904
+ if (existsSync3(opts.out) && !statSync2(opts.out).isDirectory()) {
5480
5905
  die(`--out for a folder must be a directory, and "${opts.out}" is a file`);
5481
5906
  }
5482
5907
  const files = version.files ?? [];
@@ -5499,7 +5924,7 @@ files (v${artifact.current_version.version}):`);
5499
5924
  const bytes = Buffer.from(content.bytes);
5500
5925
  if (opts.out !== void 0) {
5501
5926
  let target2 = opts.out;
5502
- if (existsSync2(target2) && statSync(target2).isDirectory()) {
5927
+ if (existsSync3(target2) && statSync2(target2).isDirectory()) {
5503
5928
  target2 = join2(target2, version.filename ?? name2);
5504
5929
  }
5505
5930
  writeFileSync2(target2, bytes);
@@ -6103,12 +6528,12 @@ import { hostname as hostname2 } from "node:os";
6103
6528
  import { spawn as spawn2 } from "node:child_process";
6104
6529
  import {
6105
6530
  createWriteStream,
6106
- existsSync as existsSync4,
6531
+ existsSync as existsSync5,
6107
6532
  mkdirSync as mkdirSync4,
6108
6533
  readFileSync as readFileSync9,
6109
6534
  realpathSync,
6110
6535
  rmSync as rmSync2,
6111
- statSync as statSync3,
6536
+ statSync as statSync4,
6112
6537
  unlinkSync as unlinkSync2,
6113
6538
  writeFileSync as writeFileSync3
6114
6539
  } from "node:fs";
@@ -6224,7 +6649,7 @@ var ClaudeStreamRenderer = class {
6224
6649
 
6225
6650
  // src/daemon/cli-refresh.ts
6226
6651
  import { spawn } from "node:child_process";
6227
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync7 } from "node:fs";
6652
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync7 } from "node:fs";
6228
6653
  import { dirname as dirname3, join as join3 } from "node:path";
6229
6654
 
6230
6655
  // src/daemon/support.ts
@@ -6550,7 +6975,7 @@ function installedVersion(prefix) {
6550
6975
  }
6551
6976
  function lastGood(prefix) {
6552
6977
  const binDir = binDirOf(prefix);
6553
- if (!existsSync3(join3(binDir, PACKAGE))) return AMBIENT_CLI;
6978
+ if (!existsSync4(join3(binDir, PACKAGE))) return AMBIENT_CLI;
6554
6979
  return { binDir, version: installedVersion(prefix), source: "stale" };
6555
6980
  }
6556
6981
  function runNpm(file, args, cwd, timeoutMs) {
@@ -6596,7 +7021,7 @@ async function installAgentCli(opts) {
6596
7021
  let result = await runNpm("npm", args, prefix, timeoutMs);
6597
7022
  if (result.spawnError?.code === "ENOENT") {
6598
7023
  const sibling = join3(dirname3(process.execPath), "npm");
6599
- if (existsSync3(sibling)) result = await runNpm(sibling, args, prefix, timeoutMs);
7024
+ if (existsSync4(sibling)) result = await runNpm(sibling, args, prefix, timeoutMs);
6600
7025
  }
6601
7026
  if (result.code !== 0) {
6602
7027
  const fallback = lastGood(prefix);
@@ -6618,7 +7043,7 @@ function message(err) {
6618
7043
  }
6619
7044
 
6620
7045
  // src/daemon/store.ts
6621
- import { readdirSync as readdirSync2, readFileSync as readFileSync8, rmSync, statSync as statSync2 } from "node:fs";
7046
+ import { readdirSync as readdirSync2, readFileSync as readFileSync8, rmSync, statSync as statSync3 } from "node:fs";
6622
7047
  import { join as join4 } from "node:path";
6623
7048
  function credentialsKey(url, name2) {
6624
7049
  return `${url.replace(/\/+$/, "")}#${name2}`;
@@ -6739,7 +7164,7 @@ function directorySizeBytes(path2) {
6739
7164
  if (entry.isDirectory()) total += directorySizeBytes(child);
6740
7165
  else if (entry.isFile()) {
6741
7166
  try {
6742
- total += statSync2(child).size;
7167
+ total += statSync3(child).size;
6743
7168
  } catch {
6744
7169
  }
6745
7170
  }
@@ -6756,7 +7181,7 @@ async function uploadRawLog(run) {
6756
7181
  run.rawSpoolPath = void 0;
6757
7182
  try {
6758
7183
  await new Promise((resolve) => run.rawSpool?.end(resolve) ?? resolve());
6759
- const size = statSync3(path2).size;
7184
+ const size = statSync4(path2).size;
6760
7185
  if (size > 0) {
6761
7186
  let body = readFileSync9(path2);
6762
7187
  if (body.byteLength > RUN_LOG_RAW_MAX_BYTES) {
@@ -6819,7 +7244,7 @@ async function runDaemon(opts) {
6819
7244
  rmSync2(workspace, { recursive: true, force: true });
6820
7245
  return;
6821
7246
  }
6822
- if (!existsSync4(workspace)) return;
7247
+ if (!existsSync5(workspace)) return;
6823
7248
  try {
6824
7249
  writeKeptMarker(workspace, { ...marker, kept_at: (/* @__PURE__ */ new Date()).toISOString() });
6825
7250
  log(`run ${marker.run_id}: workspace kept at ${workspace}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tines",
3
- "version": "0.0.140",
3
+ "version": "0.0.141",
4
4
  "description": "CLI for Tines, an orchestration layer for AI agents",
5
5
  "repository": {
6
6
  "type": "git",