@tablation/crew 0.53.4 → 0.53.6

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.
package/dist/cli.js CHANGED
@@ -6475,11 +6475,16 @@ function materializeBranchForTicket(dir, cfg, t, role, onProblem) {
6475
6475
  import { writeFileSync as writeFileSync7 } from "fs";
6476
6476
  import { join as join11 } from "path";
6477
6477
 
6478
+ // src/callout.ts
6479
+ var calloutHeader = (kind, label) => `> [!${kind}]
6480
+ > **Crew** \u2014 ${label}`;
6481
+ var calloutLabelLine = (label) => `> **Crew** \u2014 ${label}`;
6482
+
6478
6483
  // src/stalled.ts
6479
6484
  var STALLED_AFTER_CYCLES = 30;
6480
6485
  var CYCLE_MS = 120 * 1e3;
6481
6486
  var STALLED_AFTER_MS = STALLED_AFTER_CYCLES * CYCLE_MS;
6482
- var STALLED_MARKER = "<!-- crew:stalled -->";
6487
+ var STALLED_MARKER = calloutHeader("WARNING", "Stalled");
6483
6488
  var stalledKey = (issueId) => `stalled:${issueId}`;
6484
6489
  var ms = (iso) => {
6485
6490
  const n = iso ? Date.parse(iso) : NaN;
@@ -6501,7 +6506,7 @@ function planStalled(tickets, comments, ships, contract, holds2 = /* @__PURE__ *
6501
6506
  const lastMarker = Math.max(0, ...events.filter((e) => e.record_id === t.id && e.event_type === "stalled").map((e) => ms(e.occurred_at ?? e.created_at ?? "")));
6502
6507
  const lastActivityAt = Math.max(
6503
6508
  ms(t.updated_at),
6504
- ...mine.filter((c) => !(c.body ?? "").includes(STALLED_MARKER)).map((c) => ms(c.created_at))
6509
+ ...mine.filter((c) => !(c.body ?? "").includes(calloutLabelLine("Stalled"))).map((c) => ms(c.created_at))
6505
6510
  );
6506
6511
  if (now - lastActivityAt < STALLED_AFTER_MS) continue;
6507
6512
  if (ships.some((s) => s.engaged && s.engaged_ticket_id === t.id)) continue;
@@ -6518,6 +6523,7 @@ function stalledComment(s, ships, now = Date.now()) {
6518
6523
  const mins = Math.round((now - s.lastActivityAt) / 6e4);
6519
6524
  const who2 = s.holderShipId ? `Held by ${shipName(ships, s.holderShipId)}, which is not engaged on it. That ship has an attention item open; the ticket is otherwise left as it is, and resumes the moment the ship picks it back up.` : `No ship holds it and none is engaged on it. It is waiting for the next ship that polls its lane; its status and assignee are left as they are.`;
6520
6525
  return `${STALLED_MARKER}
6526
+
6521
6527
  **Stalled \u2014 no activity on \`${s.ticket.issue_id}\` for ${mins} minutes (\`${s.ticket.status}\`).**
6522
6528
 
6523
6529
  ${who2}`;
@@ -6917,7 +6923,7 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
6917
6923
 
6918
6924
  // src/conflict.ts
6919
6925
  import { StaleWriteError as StaleWriteError5 } from "@tablation/client";
6920
- var BOUNCE_MARKER = "<!-- crew:merge-conflict -->";
6926
+ var BOUNCE_MARKER = calloutHeader("WARNING", "Merge Conflict");
6921
6927
  function worktreeForBranch(cwd, branch) {
6922
6928
  for (const w of worktrees(cwd)) {
6923
6929
  if (w.branch === branch && !w.prunable) return w.path;
@@ -6965,6 +6971,7 @@ function refreshBranch(worktree, base) {
6965
6971
  function handBackComment(b, merged) {
6966
6972
  const list = b.paths.length ? b.paths.map((p) => `\`${p}\``).join(", ") : "files this branch and the base both changed";
6967
6973
  return `${BOUNCE_MARKER}
6974
+
6968
6975
  **Back with the dev lane \u2014 this branch no longer merges.**
6969
6976
 
6970
6977
  \`${b.branch}\` was verified, but squash-merging it into \`${b.base}\` conflicts in ${list}. Work that landed on \`${b.base}\` after this branch was cut has changed the same lines.
@@ -6975,6 +6982,7 @@ Nothing was written to \`${b.base}\`: the failed merge was rewound, and the rest
6975
6982
  }
6976
6983
  function escalateComment(b) {
6977
6984
  return `${BOUNCE_MARKER}
6985
+
6978
6986
  **Conflicting again after a hand-back \u2014 stopping rather than looping.**
6979
6987
 
6980
6988
  \`${b.branch}\` was handed back once for a merge conflict, verified again, and conflicts against \`${b.base}\` a second time${b.paths.length ? ` (${b.paths.map((p) => `\`${p}\``).join(", ")})` : ""}.
@@ -7050,7 +7058,7 @@ async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
7050
7058
 
7051
7059
  // src/build-failure.ts
7052
7060
  import { StaleWriteError as StaleWriteError6 } from "@tablation/client";
7053
- var BUILD_FAILURE_MARKER = "<!-- crew:build-failure -->";
7061
+ var BUILD_FAILURE_MARKER = calloutHeader("CAUTION", "Build Failure");
7054
7062
  function failingPaths(output, cwd) {
7055
7063
  const found = /* @__PURE__ */ new Set();
7056
7064
  const ext = String.raw`[\w.-]+\.[A-Za-z]{1,6}`;
@@ -7086,6 +7094,7 @@ function planBuildFailureBounce(cwd, c, base, remote) {
7086
7094
  function buildFailureComment(b) {
7087
7095
  const how = b.how === "files" ? `The failing output names files that only \`${b.branch}\` changed among the branches in that release.` : `Built alone on the previous \`${b.base}\`, \`${b.branch}\` fails the same hook.`;
7088
7096
  return `${BUILD_FAILURE_MARKER}
7097
+
7089
7098
  **Back with the dev lane \u2014 this branch builds on its own but not on current \`${b.base}\`.**
7090
7099
 
7091
7100
  \`${b.branch}\` was verified, but the release's \`${b.hook}\` hook failed once it was merged with the other branches in the same release. ${how}
@@ -7125,7 +7134,7 @@ async function applyBuildFailureBounce(writer, b, contract, memberId, log, dryRu
7125
7134
  }
7126
7135
 
7127
7136
  // src/stranded-verified.ts
7128
- var STRANDED_MARKER = "<!-- crew:stranded-verified -->";
7137
+ var STRANDED_MARKER = calloutHeader("WARNING", "Stranded Verified");
7129
7138
  var STRANDED_ESCALATE_AFTER_MS = 30 * 60 * 1e3;
7130
7139
  var priorStrandedFlags = (ticket, events) => events.filter((e) => e.record_id === ticket.id && e.event_type === "stranded_flagged").length;
7131
7140
  function planStrandedVerified(candidate, base, remote, events) {
@@ -7141,6 +7150,7 @@ function planStrandedVerified(candidate, base, remote, events) {
7141
7150
  function flagComment(s) {
7142
7151
  const key = s.candidate.ticket.issue_id;
7143
7152
  return `${STRANDED_MARKER}
7153
+
7144
7154
  **Verified, but nothing to merge \u2014 flagging before this repeats forever.**
7145
7155
 
7146
7156
  \`${key}\` is \`verified\` with no branch on this ship, and its key names no commit anywhere in this ship's \`${s.base}\` history (searched all of it, local commits included, for a bounded \`${key}\` in a commit subject or body). One of three things is true: it landed under a commit that never named the key, the branch was deleted before it merged, or it was verified in error \u2014 the crew cannot tell these apart and will not guess.
@@ -7150,6 +7160,7 @@ If it is still unplaceable next cycle, this ticket is escalated to a person. If
7150
7160
  function escalateComment2(s) {
7151
7161
  const key = s.candidate.ticket.issue_id;
7152
7162
  return `${STRANDED_MARKER}
7163
+
7153
7164
  **Still nothing to merge \u2014 stopping rather than repeating.**
7154
7165
 
7155
7166
  \`${key}\` was flagged as stranded at \`verified\` on an earlier cycle and still has no branch on this ship or on \`${s.remote}\` and no commit in this ship's \`${s.base}\` history naming its key (searched all of it, local commits included). This needs a person: either the work landed under a commit that doesn't name the key and this ticket can simply close, or the branch is gone and the work needs redoing.
@@ -7197,7 +7208,8 @@ async function applyStrandedVerified(writer, s, contract, operatorId, memberId,
7197
7208
  }
7198
7209
 
7199
7210
  // src/gate-red.ts
7200
- var GATE_RED_MARKER = "<!-- crew:test-gate-red";
7211
+ var GATE_RED_MARKER = calloutHeader("CAUTION", "Test Gate Red");
7212
+ var shaLine = (g) => `Head \`${g.sha.slice(0, 8)}\`.`;
7201
7213
  var GATE_RED_AFTER = 3;
7202
7214
  var lastLines = (output, n) => output.replace(/\u001b\[[0-9;]*m/g, "").trimEnd().split("\n").slice(-n).join("\n");
7203
7215
  function gateRedTickets(merged, merges) {
@@ -7211,7 +7223,9 @@ function gateRedTickets(merged, merges) {
7211
7223
  return out;
7212
7224
  }
7213
7225
  function gateRedComment(g) {
7214
- return `${GATE_RED_MARKER} ${g.sha.slice(0, 8)} -->
7226
+ return `${GATE_RED_MARKER}
7227
+ > ${shaLine(g)}
7228
+
7215
7229
  **This ticket is merged on the base, but the release test gate is red \u2014 nothing has shipped.**
7216
7230
 
7217
7231
  The \`test\` hook (\`${g.hook}\`) has failed ${g.count} cycles running on the same head (\`${g.sha.slice(0, 8)}\`). The ticket's commit is on the base and will ship once the gate passes; it is not stranded, and re-setting it to \`verified\` will not help. Last lines of the hook's output:
@@ -7221,7 +7235,7 @@ ${g.tail}
7221
7235
  \`\`\`
7222
7236
  `;
7223
7237
  }
7224
- var alreadyReported = (ticket, g, events) => events.some((e) => e.record_id === ticket.id && e.event_type === "gate_red" && (e.message ?? "").includes(`${GATE_RED_MARKER} ${g.sha.slice(0, 8)}`));
7238
+ var alreadyReported = (ticket, g, events) => events.some((e) => e.record_id === ticket.id && e.event_type === "gate_red" && (e.message ?? "").includes(shaLine(g)));
7225
7239
 
7226
7240
  // src/agent.ts
7227
7241
  import { spawn as spawn2, spawnSync as spawnSync5 } from "child_process";
@@ -9115,7 +9129,7 @@ async function finishRunWorktree(dir, cfg, branch) {
9115
9129
  }
9116
9130
 
9117
9131
  // src/handoff-guard.ts
9118
- var HANDOFF_BOUNCED_MARKER = "<!-- crew:handoff-bounced -->";
9132
+ var HANDOFF_BOUNCED_MARKER = calloutHeader("WARNING", "Hand-off Bounced");
9119
9133
  var pushedEventBody = (sha, remote, branch) => `pushed ${sha} to ${remote}/${branch}`;
9120
9134
  function remoteTip(dir, remote, branch) {
9121
9135
  const out = gitOk(dir, ["ls-remote", "--heads", remote, `refs/heads/${branch}`]);
@@ -9164,6 +9178,7 @@ function inspectHandoff(dir, cfg, ticket, role) {
9164
9178
  }
9165
9179
  function bounceComment(v, shipName2) {
9166
9180
  return `${HANDOFF_BOUNCED_MARKER}
9181
+
9167
9182
  **Hand-off bounced: the branch is not on \`${v.remote}\` at the tip this ticket was built to.**
9168
9183
 
9169
9184
  ${shipName2 ? `Ship: ${shipName2}
@@ -9298,7 +9313,7 @@ function repoConfigFor(route, t) {
9298
9313
  }, target.dir);
9299
9314
  return { target, cfg };
9300
9315
  }
9301
- var STRAY_HANDOFF_MARKER = "<!-- crew:stray-handoff -->";
9316
+ var STRAY_HANDOFF_MARKER = calloutHeader("WARNING", "Stray Hand-off");
9302
9317
  async function guardStrayHandoffs(i) {
9303
9318
  const st = i.contract.statuses;
9304
9319
  if (i.role === "qa") return;
@@ -9311,6 +9326,7 @@ async function guardStrayHandoffs(i) {
9311
9326
  await i.writer.postEvent(
9312
9327
  t.id,
9313
9328
  `${STRAY_HANDOFF_MARKER}
9329
+
9314
9330
  **Hand-off bounced: this ticket is held by another ship.**
9315
9331
 
9316
9332
  ${i.shipName ? `${i.shipName}'s ` : "A "}${i.role} run set it to \`${st.handoff}\` without having claimed it. Set back to \`${st.building}\`; the holding ship's own hand-off is the one that counts.`,
@@ -10796,12 +10812,13 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch, isolated = fal
10796
10812
  }
10797
10813
 
10798
10814
  // src/failure-alert.ts
10799
- var FAILURE_ALERT_MARKER = "<!-- crew:failure-alert -->";
10815
+ var FAILURE_ALERT_MARKER = calloutHeader("CAUTION", "Failure Alert");
10800
10816
  function findOpenFailureAlert(tickets, headline) {
10801
10817
  return tickets.find((t) => t.title === headline);
10802
10818
  }
10803
10819
  function ticketBody(n, route) {
10804
10820
  return `${FAILURE_ALERT_MARKER}
10821
+
10805
10822
  ${n.detail ?? n.headline}
10806
10823
 
10807
10824
  Filed automatically \u2014 no \`notify\` hook is configured for route \`${route}\`, so this is the crew's built-in fallback rather than a silent log line. Configure \`hooks.notify\` on this route to replace it.`;
@@ -10816,6 +10833,7 @@ async function applyFailureAlert(writer, n, route, openTickets, memberId, log, d
10816
10833
  }
10817
10834
  try {
10818
10835
  await writer.postEvent(existing.id, `${FAILURE_ALERT_MARKER}
10836
+
10819
10837
  Still failing: ${n.detail ?? n.headline}`, memberId, "failure_alert");
10820
10838
  log.warn(`release failure recurring \u2014 commented on ${label}`, { step: "release" });
10821
10839
  return { kind: "commented", issueId: label };
@@ -13287,10 +13305,11 @@ import { join as join25 } from "path";
13287
13305
  // src/interrupted.ts
13288
13306
  import { existsSync as existsSync25, readdirSync as readdirSync9, readFileSync as readFileSync18 } from "fs";
13289
13307
  import { join as join24 } from "path";
13290
- var INTERRUPTED_MARKER = "<!-- crew:interrupted -->";
13308
+ var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
13291
13309
  function interruptedComment(b) {
13292
13310
  const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
13293
13311
  return `${INTERRUPTED_MARKER}
13312
+
13294
13313
  The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
13295
13314
  }
13296
13315
  var handled = /* @__PURE__ */ new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.53.4",
3
+ "version": "0.53.6",
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",
@@ -31,9 +31,23 @@ prior conversation history exists.
31
31
  on the Maps table), ordered by `version`. If one exists with
32
32
  `status: active`, that's the current settled PRD — treat this session
33
33
  as a **revision** interview, not a fresh one (see §5). If the most
34
- recent one is `status: draft` and was never promoted, you're likely
35
- continuing or restarting that same attempt — ask the interviewee
36
- which they intend.
34
+ recent one is `status: draft` (never promoted) or `status: planning`
35
+ (an interview that was interrupted), you're likely continuing or
36
+ restarting that same attempt — ask the interviewee which they intend.
37
+ Ignore `abandoned` Maps when picking what to continue; they still count
38
+ when you compute the next `version`.
39
+ 4. Before your first question, make sure a Map record for this session
40
+ exists and is at `status: planning`, so a grill in progress is visible
41
+ on the Maps table:
42
+ - Fresh grill: create the Map now (`epic_id`, a short `title`, the
43
+ `version` from §3 step 1, empty `content` and `conversation`) at
44
+ `planning`.
45
+ - Continuing a `draft` or an interrupted `planning` Map: set that same
46
+ record to `planning`.
47
+ - Restarting instead: set the old Map to `abandoned`, then create a new
48
+ version at `planning`.
49
+ - Revising an `active` Map: create the next version at `planning` now.
50
+ Leave the active one as it is until promotion.
37
51
 
38
52
  ## 2. Run the interview
39
53
 
@@ -52,11 +66,13 @@ and don't manufacture more questions once you've genuinely converged.
52
66
 
53
67
  Once you've reached mutual agreement the session is done:
54
68
 
55
- 1. Compute the new Map's `version`: default to
69
+ 1. Compute the Map's `version`: default to
56
70
  `max(existing versions for this epic_id) + 1`. If the interviewee
57
71
  wants a different version label (e.g. a deliberate jump for a major
58
- revision), use what they say instead.
59
- 2. Write a new Maps record:
72
+ revision), use what they say instead. (You normally settled this when
73
+ you created the record in §1; change it there if the interviewee asks.)
74
+ 2. Fill in the Maps record you created in §1 — update that same record,
75
+ do not create a second one:
60
76
  - `epic_id` → this Epic
61
77
  - `title` → short, human-readable
62
78
  - `content` → the PRD itself, in Markdown. Write this as a real
@@ -66,8 +82,8 @@ Once you've reached mutual agreement the session is done:
66
82
  actual back-and-forth (questions asked, answers given, points where
67
83
  you changed your recommendation based on pushback). This is the
68
84
  record of *how* you got to the PRD, not a duplicate of it.
69
- - `status` → always `draft` on creation. Never create a Map as
70
- `active` directly.
85
+ - `status` → `draft`, now that the PRD is written. Never create a Map
86
+ as `active` directly, and never leave a finished PRD at `planning`.
71
87
 
72
88
  ## 4. Proposing child tickets
73
89
 
@@ -94,6 +110,9 @@ Alongside the draft Map, propose the child Issues implied by the PRD:
94
110
  Before ending the session, ask the interviewee directly: **should this
95
111
  Map go active now, or stay a draft?**
96
112
 
113
+ Only a `draft` Map can be promoted. A Map still at `planning` is an
114
+ unfinished interview and is never activated.
115
+
97
116
  - **If they say stay draft:** leave everything as-is. Nothing else to
98
117
  do. A later session (yours or someone else's) picks this back up.
99
118
  - **If they say go active:**
@@ -124,6 +143,26 @@ Map go active now, or stay a draft?**
124
143
  Do all of this yourself via direct MCP writes. There is no automation
125
144
  watching for this — if you don't do it, it doesn't happen.
126
145
 
146
+ ## 6. Abandoning a Map
147
+
148
+ A Map moves through `planning` (interview running) → `draft` (PRD written,
149
+ awaiting the promotion decision) → `active` (promoted) → `superseded` (a
150
+ later version went active). `abandoned` is the terminal state for a Map
151
+ that never got that far.
152
+
153
+ If the interviewee says to stop working on a Map that is at `planning` or
154
+ `draft`:
155
+
156
+ 1. Set that Map to `status: abandoned`.
157
+ 2. Ask whether its `draft` Issues should go to `closed_obsolete`, and
158
+ recommend yes. Apply their answer; leave Issues that have already left
159
+ `draft` alone.
160
+ 3. Leave the Epic's status alone.
161
+
162
+ An abandoned Map is never reactivated. If work on the Epic resumes later,
163
+ that is a new Map at the next `version`. Do these writes directly via MCP,
164
+ as in §5.
165
+
127
166
  ## What this mode is never responsible for
128
167
 
129
168
  - Writing or editing code.
@@ -31,9 +31,23 @@ prior conversation history exists.
31
31
  on the Maps table), ordered by `version`. If one exists with
32
32
  `status: active`, that's the current settled PRD — treat this session
33
33
  as a **revision** interview, not a fresh one (see §5). If the most
34
- recent one is `status: draft` and was never promoted, you're likely
35
- continuing or restarting that same attempt — ask the interviewee
36
- which they intend.
34
+ recent one is `status: draft` (never promoted) or `status: planning`
35
+ (an interview that was interrupted), you're likely continuing or
36
+ restarting that same attempt — ask the interviewee which they intend.
37
+ Ignore `abandoned` Maps when picking what to continue; they still count
38
+ when you compute the next `version`.
39
+ 4. Before your first question, make sure a Map record for this session
40
+ exists and is at `status: planning`, so a grill in progress is visible
41
+ on the Maps table:
42
+ - Fresh grill: create the Map now (`epic_id`, a short `title`, the
43
+ `version` from §3 step 1, empty `content` and `conversation`) at
44
+ `planning`.
45
+ - Continuing a `draft` or an interrupted `planning` Map: set that same
46
+ record to `planning`.
47
+ - Restarting instead: set the old Map to `abandoned`, then create a new
48
+ version at `planning`.
49
+ - Revising an `active` Map: create the next version at `planning` now.
50
+ Leave the active one as it is until promotion.
37
51
 
38
52
  ## 2. Run the interview
39
53
 
@@ -52,11 +66,13 @@ and don't manufacture more questions once you've genuinely converged.
52
66
 
53
67
  Once you've reached mutual agreement the session is done:
54
68
 
55
- 1. Compute the new Map's `version`: default to
69
+ 1. Compute the Map's `version`: default to
56
70
  `max(existing versions for this epic_id) + 1`. If the interviewee
57
71
  wants a different version label (e.g. a deliberate jump for a major
58
- revision), use what they say instead.
59
- 2. Write a new Maps record:
72
+ revision), use what they say instead. (You normally settled this when
73
+ you created the record in §1; change it there if the interviewee asks.)
74
+ 2. Fill in the Maps record you created in §1 — update that same record,
75
+ do not create a second one:
60
76
  - `epic_id` → this Epic
61
77
  - `title` → short, human-readable
62
78
  - `content` → the PRD itself, in Markdown. Write this as a real
@@ -66,8 +82,8 @@ Once you've reached mutual agreement the session is done:
66
82
  actual back-and-forth (questions asked, answers given, points where
67
83
  you changed your recommendation based on pushback). This is the
68
84
  record of *how* you got to the PRD, not a duplicate of it.
69
- - `status` → always `draft` on creation. Never create a Map as
70
- `active` directly.
85
+ - `status` → `draft`, now that the PRD is written. Never create a Map
86
+ as `active` directly, and never leave a finished PRD at `planning`.
71
87
 
72
88
  ## 4. Proposing child tickets
73
89
 
@@ -94,6 +110,9 @@ Alongside the draft Map, propose the child Issues implied by the PRD:
94
110
  Before ending the session, ask the interviewee directly: **should this
95
111
  Map go active now, or stay a draft?**
96
112
 
113
+ Only a `draft` Map can be promoted. A Map still at `planning` is an
114
+ unfinished interview and is never activated.
115
+
97
116
  - **If they say stay draft:** leave everything as-is. Nothing else to
98
117
  do. A later session (yours or someone else's) picks this back up.
99
118
  - **If they say go active:**
@@ -124,6 +143,26 @@ Map go active now, or stay a draft?**
124
143
  Do all of this yourself via direct MCP writes. There is no automation
125
144
  watching for this — if you don't do it, it doesn't happen.
126
145
 
146
+ ## 6. Abandoning a Map
147
+
148
+ A Map moves through `planning` (interview running) → `draft` (PRD written,
149
+ awaiting the promotion decision) → `active` (promoted) → `superseded` (a
150
+ later version went active). `abandoned` is the terminal state for a Map
151
+ that never got that far.
152
+
153
+ If the interviewee says to stop working on a Map that is at `planning` or
154
+ `draft`:
155
+
156
+ 1. Set that Map to `status: abandoned`.
157
+ 2. Ask whether its `draft` Issues should go to `closed_obsolete`, and
158
+ recommend yes. Apply their answer; leave Issues that have already left
159
+ `draft` alone.
160
+ 3. Leave the Epic's status alone.
161
+
162
+ An abandoned Map is never reactivated. If work on the Epic resumes later,
163
+ that is a new Map at the next `version`. Do these writes directly via MCP,
164
+ as in §5.
165
+
127
166
  ## What this mode is never responsible for
128
167
 
129
168
  - Writing or editing code.