intentdocs 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,16 @@ import "./chunk-UYTVTYNB.js";
9
9
 
10
10
  // src/commands/keys.ts
11
11
  var KEYS_DOC_URL = "https://console.anthropic.com/settings/keys";
12
+ var XAI_KEYS_URL = "https://console.x.ai/";
13
+ var ANTHROPIC_RE = /^sk-ant-[A-Za-z0-9_-]{24,250}$/;
14
+ var XAI_RE = /^xai-[A-Za-z0-9_-]{20,250}$/;
15
+ var OPENAI_PROJ_RE = /^sk-proj-[A-Za-z0-9_-]{20,250}$/;
16
+ var OPENAI_SK_RE = /^sk-[A-Za-z0-9_-]{20,250}$/;
17
+ function acceptedLocally(value) {
18
+ if (ANTHROPIC_RE.test(value) || XAI_RE.test(value) || OPENAI_PROJ_RE.test(value)) return true;
19
+ if (!OPENAI_SK_RE.test(value)) return false;
20
+ return !/^(sk-ant-|sk-proj-|sk-admin-|sk-svcacct-)/.test(value);
21
+ }
12
22
  async function byokRequest(method, body) {
13
23
  const auth = await requireValidAuth();
14
24
  const res = await fetch(`${auth.apiBase}/api/settings/byok`, {
@@ -25,17 +35,22 @@ async function byokRequest(method, body) {
25
35
  async function keysSet(key) {
26
36
  let value = key?.trim();
27
37
  if (!value) {
28
- console.log("\n Optionally add your Anthropic API key to unlock AI features on any plan.");
38
+ console.log("\n Optionally add an Anthropic, xAI, or OpenAI API key to unlock AI features on any plan.");
29
39
  console.log(" Your key is encrypted at rest and only used for your AI operations.");
30
- console.log(` Get one at: ${KEYS_DOC_URL}
31
- `);
32
- value = (await ask(" Anthropic API key (sk-ant-...): ")).trim();
40
+ console.log(` Anthropic keys start with sk-ant-. Get one at: ${KEYS_DOC_URL}`);
41
+ console.log(` xAI keys start with xai-. Get one at: ${XAI_KEYS_URL}`);
42
+ console.log(" OpenAI keys start with sk- or sk-proj-. Get one at: https://platform.openai.com/api-keys\n");
43
+ value = (await ask(" API key (sk-ant-..., xai-..., sk-..., or sk-proj-...): ")).trim();
33
44
  closePrompt();
34
45
  }
35
46
  if (!value) {
36
47
  console.error(" No key provided. Run `npx intentdocs keys set` to try again.");
37
48
  process.exit(1);
38
49
  }
50
+ if (!acceptedLocally(value)) {
51
+ console.error("\n \u2717 That key is not accepted. Use an sk-ant- key, an xai- key, an sk- key, or an sk-proj- key.");
52
+ process.exit(1);
53
+ }
39
54
  try {
40
55
  const { ok, data } = await byokRequest("POST", { key: value });
41
56
  if (!ok) {
@@ -59,12 +74,18 @@ async function keysStatus() {
59
74
  process.exit(1);
60
75
  }
61
76
  if (data?.enabled) {
77
+ const anthropic = data.anthropic?.mask || "not set";
78
+ const xai = data.xai?.mask || "not set";
79
+ const openai = data.openai?.mask || "not set";
62
80
  console.log(`
63
- Anthropic API key: ${data.maskedKey ?? "(stored but unreadable \u2014 re-run `npx intentdocs keys set`)"}`);
64
- console.log(" AI features run on your key.\n");
81
+ Anthropic API key: ${anthropic}`);
82
+ console.log(` xAI API key: ${xai}`);
83
+ console.log(` OpenAI API key: ${openai}`);
84
+ if (data.active_provider) console.log(` Active: ${data.active_provider}`);
85
+ console.log(" AI features run on your active key.\n");
65
86
  } else {
66
- console.log("\n No Anthropic API key set.");
67
- console.log(" Add one with `npx intentdocs keys set` to unlock AI features on any plan.\n");
87
+ console.log("\n No API key set.");
88
+ console.log(" Add an sk-ant-, xai-, sk-, or sk-proj- key with `npx intentdocs keys set` to unlock AI features on any plan.\n");
68
89
  }
69
90
  } catch {
70
91
  console.error(" \u2717 Could not reach intentdocs. Check your connection and try again.");
@@ -78,7 +99,7 @@ async function keysRemove() {
78
99
  console.error(" \u2717 Failed to remove your key.");
79
100
  process.exit(1);
80
101
  }
81
- console.log("\n Key removed. AI features fall back to your plan's platform limits.\n");
102
+ console.log("\n Key removed. Revoke it at the provider. AI features fall back when no key remains.\n");
82
103
  } catch {
83
104
  console.error(" \u2717 Could not reach intentdocs. Check your connection and try again.");
84
105
  process.exit(1);
@@ -1,7 +1,10 @@
1
+ import {
2
+ appendToNotes
3
+ } from "./chunk-GFKZ5CRL.js";
1
4
  import {
2
5
  appendNoteToContext,
3
6
  storyContextPath
4
- } from "./chunk-F52HPVNQ.js";
7
+ } from "./chunk-BNEU6TLJ.js";
5
8
  import {
6
9
  matchesStoryRef
7
10
  } from "./chunk-LQTPZL2Y.js";
@@ -9,11 +12,12 @@ import {
9
12
  amendDecision,
10
13
  apiFetch,
11
14
  listDecisions,
15
+ listStories,
12
16
  retractDecision
13
- } from "./chunk-34JCN66H.js";
17
+ } from "./chunk-KTGQO3AE.js";
14
18
  import {
15
19
  requireConfig
16
- } from "./chunk-JX2RLGOM.js";
20
+ } from "./chunk-RGFGTIIH.js";
17
21
  import {
18
22
  ask,
19
23
  closePrompt,
@@ -38,6 +42,18 @@ async function logDecision(title, opts) {
38
42
  closePrompt();
39
43
  return;
40
44
  }
45
+ let storyId;
46
+ if (opts?.story) {
47
+ const { stories } = await listStories(config);
48
+ const match = stories.find((s) => matchesStoryRef(s, opts.story));
49
+ if (!match) {
50
+ console.error(` No story found matching "${opts.story}" \u2014 decision not logged.`);
51
+ closePrompt();
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+ storyId = match.id;
56
+ }
41
57
  const res = await apiFetch(config, "/decisions", {
42
58
  method: "POST",
43
59
  headers: { "Content-Type": "application/json" },
@@ -45,7 +61,7 @@ async function logDecision(title, opts) {
45
61
  title: title.trim(),
46
62
  description: description.trim(),
47
63
  category: opts?.category ?? "technical",
48
- story_id: opts?.story ?? void 0
64
+ story_id: storyId
49
65
  })
50
66
  });
51
67
  if (res.ok) {
@@ -182,7 +198,7 @@ async function logNote(storyId, note) {
182
198
  method: "PATCH",
183
199
  headers: { "Content-Type": "application/json" },
184
200
  body: JSON.stringify({
185
- stories: [{ id: match.id, notes: note.trim() }]
201
+ stories: [{ id: match.id, notes: appendToNotes(match.notes, note) }]
186
202
  })
187
203
  });
188
204
  if (res.ok) {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  requireConfig
3
- } from "./chunk-JX2RLGOM.js";
3
+ } from "./chunk-RGFGTIIH.js";
4
4
  import "./chunk-UYTVTYNB.js";
5
5
 
6
6
  // src/commands/map-extract.ts
@@ -5,10 +5,10 @@ import {
5
5
  import {
6
6
  apiUrl,
7
7
  syncHeaders
8
- } from "./chunk-34JCN66H.js";
8
+ } from "./chunk-KTGQO3AE.js";
9
9
  import {
10
10
  requireConfig
11
- } from "./chunk-JX2RLGOM.js";
11
+ } from "./chunk-RGFGTIIH.js";
12
12
  import {
13
13
  closePrompt,
14
14
  confirm
@@ -322,11 +322,12 @@ async function mapImport(filePath, opts) {
322
322
  return;
323
323
  }
324
324
  if (res.status === 401) {
325
- uploadSpinner.fail(" Sync token expired or invalid");
325
+ uploadSpinner.fail(" Your login is no longer valid");
326
326
  console.error(
327
- "\n Your sync token has expired. Run \x1B[36mstc link\x1B[0m to generate a fresh one.\n"
327
+ "\n Run \x1B[36mnpx intentdocs login\x1B[0m to log in again, then re-run the import.\n"
328
328
  );
329
329
  closePrompt();
330
+ process.exitCode = 1;
330
331
  return;
331
332
  }
332
333
  uploadSpinner.fail(" Upload failed");
@@ -0,0 +1,476 @@
1
+ import {
2
+ listStories
3
+ } from "./chunk-KTGQO3AE.js";
4
+ import {
5
+ classify,
6
+ featureFiles,
7
+ findIntentDir,
8
+ newFeature,
9
+ parseFeature,
10
+ readIntentFolder,
11
+ readSyncBase,
12
+ renderFeature,
13
+ requireConfig,
14
+ slug,
15
+ specOfPushStory,
16
+ specOfStory,
17
+ writeSyncBase
18
+ } from "./chunk-RGFGTIIH.js";
19
+ import {
20
+ choose,
21
+ closePrompt,
22
+ confirm,
23
+ isInteractive
24
+ } from "./chunk-WRRUCCVZ.js";
25
+ import "./chunk-XZE2XP6O.js";
26
+ import "./chunk-UYTVTYNB.js";
27
+
28
+ // src/commands/pull.ts
29
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
30
+ import { dirname, join, relative } from "path";
31
+ import { parse } from "yaml";
32
+
33
+ // src/lib/pull-files.ts
34
+ import { isMap, isScalar, isSeq, parseDocument, stringify } from "yaml";
35
+ var YAML_OUT = { lineWidth: 0, flowCollectionPadding: false };
36
+ function edit(text, change) {
37
+ const doc = parseDocument(text);
38
+ change(doc);
39
+ return doc.toString(YAML_OUT);
40
+ }
41
+ var seqOf = (doc, key) => {
42
+ const node = doc.get(key, true);
43
+ if (isSeq(node)) return node;
44
+ const seq = doc.createNode([]);
45
+ doc.set(key, seq);
46
+ return seq;
47
+ };
48
+ var itemsOf = (seq) => seq.items.filter((i) => isMap(i));
49
+ function releaseItem(doc, number, title) {
50
+ const releases = seqOf(doc, "releases");
51
+ const found = itemsOf(releases).find((r) => r.get("number") === number);
52
+ if (found) return found;
53
+ const created = doc.createNode({ number, title, stories: [] });
54
+ created.get("stories", true).flow = true;
55
+ const after = itemsOf(releases).findIndex((r) => Number(r.get("number")) > number);
56
+ if (after < 0) releases.items.push(created);
57
+ else releases.items.splice(releases.items.indexOf(itemsOf(releases)[after]), 0, created);
58
+ return created;
59
+ }
60
+ function setReleaseTitle(text, number, title) {
61
+ return edit(text, (doc) => releaseItem(doc, number, title).set("title", title));
62
+ }
63
+ function setActivityTitle(text, id, title) {
64
+ return edit(text, (doc) => {
65
+ const activities = seqOf(doc, "activities");
66
+ const found = itemsOf(activities).find((a) => a.get("id") === id);
67
+ if (found) found.set("title", title);
68
+ else activities.items.push(doc.createNode({ id, title }));
69
+ });
70
+ }
71
+ function ensureActivity(text, id, title) {
72
+ const has = itemsOf(seqOf(parseDocument(text), "activities")).some((a) => a.get("id") === id);
73
+ return has ? text : setActivityTitle(text, id, title);
74
+ }
75
+ function placeStory(text, key, number, releaseTitle) {
76
+ return edit(text, (doc) => {
77
+ const target = releaseItem(doc, number, releaseTitle);
78
+ for (const release of itemsOf(seqOf(doc, "releases"))) {
79
+ const stories = release.get("stories", true);
80
+ if (!isSeq(stories)) continue;
81
+ const at = stories.items.findIndex((s) => isScalar(s) && s.value === key);
82
+ if (at >= 0 && release !== target) stories.items.splice(at, 1);
83
+ }
84
+ const existing = target.get("stories", true);
85
+ let seq;
86
+ if (isSeq(existing)) {
87
+ seq = existing;
88
+ } else {
89
+ seq = doc.createNode([]);
90
+ seq.flow = true;
91
+ target.set("stories", seq);
92
+ }
93
+ if (!seq.items.some((s) => isScalar(s) && s.value === key)) seq.items.push(doc.createNode(key));
94
+ });
95
+ }
96
+ function setState(text, key, fields) {
97
+ return edit(text, (doc) => {
98
+ for (const [field, value] of Object.entries(fields)) if (value !== void 0) doc.setIn(["stories", key, field], value);
99
+ });
100
+ }
101
+ function planVersions(names, key) {
102
+ const re = new RegExp(`^${key.replace(/[-]/g, "\\-")}\\.v(\\d+)\\.md$`);
103
+ return names.map((n) => re.exec(n)?.[1]).filter((v) => !!v).map(Number).sort((a, b) => a - b);
104
+ }
105
+ function planBody(text) {
106
+ return text.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
107
+ }
108
+ function renderPlan(key, version, plan, labelOf, today) {
109
+ const meta = { story: key, version, created: today };
110
+ if (version > 1) meta.supersedes = version - 1;
111
+ if (plan.summary) meta.summary = plan.summary;
112
+ const steps = plan.steps.map((step, i) => {
113
+ const title = step.title ?? step.text.split("\n")[0] ?? `Step ${i + 1}`;
114
+ const lines = [`## ${i + 1}. ${title}`];
115
+ if (step.why) lines.push(`**Why:** ${step.why}`);
116
+ const criteria = step.criteria.map((id) => labelOf(id) ?? id);
117
+ if (criteria.length) lines.push(`**Criteria:** ${criteria.join(", ")}`);
118
+ if (step.files.length) lines.push(`**Files:** ${step.files.join(", ")}`);
119
+ if (step.tests.length) lines.push(`**Tests:** ${step.tests.join(", ")}`);
120
+ if (step.needs_you) lines.push(`**Needs you:** ${step.needs_you}`);
121
+ const notes = step.title ? step.agent_notes : step.text;
122
+ if (notes) lines.push("", notes.trim());
123
+ return lines.join("\n");
124
+ });
125
+ return `---
126
+ ${stringify(meta, YAML_OUT)}---
127
+
128
+ ${steps.join("\n\n")}
129
+ `;
130
+ }
131
+
132
+ // src/lib/text-diff.ts
133
+ function ops(a, b) {
134
+ const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
135
+ for (let i2 = a.length - 1; i2 >= 0; i2--) {
136
+ for (let j2 = b.length - 1; j2 >= 0; j2--) {
137
+ lcs[i2][j2] = a[i2] === b[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
138
+ }
139
+ }
140
+ const out = [];
141
+ let i = 0;
142
+ let j = 0;
143
+ while (i < a.length || j < b.length) {
144
+ if (i < a.length && j < b.length && a[i] === b[j]) {
145
+ out.push({ kind: " ", line: a[i], a: i++, b: j++ });
146
+ } else if (i < a.length && (j >= b.length || lcs[i + 1][j] >= lcs[i][j + 1])) {
147
+ out.push({ kind: "-", line: a[i], a: i++, b: j });
148
+ } else {
149
+ out.push({ kind: "+", line: b[j], a: i, b: j++ });
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+ function unifiedDiff(before, after, label, context = 3) {
155
+ if (before === after) return "";
156
+ const all = ops(before.split("\n"), after.split("\n"));
157
+ const changed = all.map((o, n) => o.kind === " " ? -1 : n).filter((n) => n >= 0);
158
+ const hunks = [];
159
+ for (const n of changed) {
160
+ const start = Math.max(0, n - context);
161
+ const end = Math.min(all.length - 1, n + context);
162
+ const last = hunks[hunks.length - 1];
163
+ if (last && start <= last[1] + 1) last[1] = end;
164
+ else hunks.push([start, end]);
165
+ }
166
+ const lines = [`--- ${label} (in the repo)`, `+++ ${label} (from intentdocs)`];
167
+ for (const [start, end] of hunks) {
168
+ const slice = all.slice(start, end + 1);
169
+ const aLen = slice.filter((o) => o.kind !== "+").length;
170
+ const bLen = slice.filter((o) => o.kind !== "-").length;
171
+ lines.push(`@@ -${slice[0].a + 1},${aLen} +${slice[0].b + 1},${bLen} @@`);
172
+ for (const o of slice) lines.push(`${o.kind}${o.line}`);
173
+ }
174
+ return lines.join("\n");
175
+ }
176
+
177
+ // src/commands/pull.ts
178
+ var Files = class _Files {
179
+ constructor(read) {
180
+ this.read = read;
181
+ }
182
+ changed = /* @__PURE__ */ new Map();
183
+ get(path) {
184
+ return this.changed.has(path) ? this.changed.get(path) : this.read(path);
185
+ }
186
+ set(path, text) {
187
+ this.changed.set(path, text);
188
+ }
189
+ clone() {
190
+ const copy = new _Files(this.read);
191
+ for (const [p, t] of this.changed) copy.set(p, t);
192
+ return copy;
193
+ }
194
+ /** Paths whose text differs from the file on disk. */
195
+ edits() {
196
+ return [...this.changed].filter(([p, t]) => this.read(p) !== t);
197
+ }
198
+ };
199
+ var PullError = class extends Error {
200
+ constructor(message, details = []) {
201
+ super(message);
202
+ this.details = details;
203
+ this.name = "PullError";
204
+ }
205
+ };
206
+ var KIND_LABEL = {
207
+ web: "From intentdocs",
208
+ clash: "Changed in intentdocs AND in the repo since the last sync",
209
+ unrecorded: "Differs from intentdocs, with no sync record to say which side changed"
210
+ };
211
+ var STATUSES = /* @__PURE__ */ new Set(["todo", "in_progress", "needs_rework", "done"]);
212
+ function planUnits(deps, response, base, nextBase, outcome) {
213
+ const intentDir = findIntentDir(deps.cwd);
214
+ const read = readIntentFolder(deps.cwd);
215
+ if (!read.ok) throw new PullError(`intent/ needs fixing before a pull (${read.dir}):`, read.errors);
216
+ const storymapPath = join(intentDir, "storymap.yaml");
217
+ const statePath = join(intentDir, "state.yaml");
218
+ const plansDir = join(intentDir, "plans");
219
+ const today = deps.now().toISOString().slice(0, 10);
220
+ const files = featureFiles(intentDir);
221
+ const local = new Map(read.payload.stories.map((s) => [s.key, s]));
222
+ const storymap = parse(deps.readFile(storymapPath) ?? "");
223
+ const state = parse(deps.readFile(statePath) ?? "") ?? {};
224
+ const remoteReleases = new Map((response.releases ?? []).map((r) => [r.number, r.title]));
225
+ const releaseTitle = (n) => remoteReleases.get(n) ?? storymap.releases?.find((r) => r.number === n)?.title ?? `Release ${n}`;
226
+ const units = [];
227
+ for (const [number, title] of remoteReleases) {
228
+ const mine = storymap.releases?.find((r) => r.number === number)?.title;
229
+ const was = base.releases[String(number)];
230
+ const kind = mine === void 0 && was === void 0 ? "web" : classify(mine, title, was);
231
+ if (kind === null) nextBase.releases[String(number)] = title;
232
+ else if (kind !== "local") {
233
+ units.push({
234
+ title: mine === void 0 ? `New release ${number}: ${title}` : `Release ${number} renamed to "${title}"`,
235
+ kind,
236
+ apply: (f) => f.set(storymapPath, setReleaseTitle(f.get(storymapPath), number, title)),
237
+ record: (b) => b.releases[String(number)] = title
238
+ });
239
+ }
240
+ }
241
+ for (const activity of response.activities ?? []) {
242
+ if (!activity.ref?.startsWith("nikos:")) continue;
243
+ const id = activity.ref.slice("nikos:".length);
244
+ const mine = storymap.activities?.find((a) => a.id === id)?.title;
245
+ if (mine === void 0) continue;
246
+ const kind = classify(mine, activity.title, base.activities[id]);
247
+ if (kind === null) nextBase.activities[id] = activity.title;
248
+ else if (kind !== "local") {
249
+ units.push({
250
+ title: `Activity ${id} renamed to "${activity.title}"`,
251
+ kind,
252
+ apply: (f) => f.set(storymapPath, setActivityTitle(f.get(storymapPath), id, activity.title)),
253
+ record: (b) => b.activities[id] = activity.title
254
+ });
255
+ }
256
+ }
257
+ const live = response.stories.filter((s) => s.key);
258
+ outcome.cancelled = live.filter((s) => s.cancelled_at && files.has(s.key)).map((s) => s.key);
259
+ for (const story of live.filter((s) => !s.cancelled_at)) {
260
+ const key = story.key;
261
+ const theirs = specOfStory(story, storymap.activities ?? []);
262
+ const mine = local.get(key);
263
+ const path = files.get(key);
264
+ if (mine && path) {
265
+ const kind = classify(specOfPushStory(mine), theirs, base.stories[key]);
266
+ if (kind === null) nextBase.stories[key] = theirs;
267
+ else if (kind === "local") outcome.localOnly.push(key);
268
+ else units.push(storyUnit(key, path, mine, theirs, story, kind));
269
+ } else if (!mine) {
270
+ units.push(newStoryUnit(key, theirs, story));
271
+ }
272
+ }
273
+ function placeIn(f, key, theirs, story) {
274
+ let map = f.get(storymapPath);
275
+ map = ensureActivity(map, theirs.activity, story.activity);
276
+ map = placeStory(map, key, theirs.release, releaseTitle(theirs.release));
277
+ f.set(storymapPath, map);
278
+ }
279
+ function storyUnit(key, path, mine, theirs, story, kind) {
280
+ return {
281
+ title: `${key} ${theirs.title}`,
282
+ kind,
283
+ apply: (f) => {
284
+ f.set(path, renderFeature(f.get(path), theirs));
285
+ if (mine.release !== theirs.release || mine.activity_id !== theirs.activity) placeIn(f, key, theirs, story);
286
+ },
287
+ record: (b) => b.stories[key] = theirs
288
+ };
289
+ }
290
+ function newStoryUnit(key, theirs, story) {
291
+ const dir = join(intentDir, "features", theirs.activity);
292
+ const name = slug(theirs.title);
293
+ const path = deps.readFile(join(dir, `${name}.feature`)) === null ? join(dir, `${name}.feature`) : join(dir, `${name}-${key.toLowerCase()}.feature`);
294
+ const status = STATUSES.has(story.status) ? story.status : "todo";
295
+ return {
296
+ title: `New story ${key} ${theirs.title}`,
297
+ kind: "web",
298
+ apply: (f) => {
299
+ f.set(path, newFeature(key, theirs));
300
+ placeIn(f, key, theirs, story);
301
+ f.set(statePath, setState(f.get(statePath), key, { status, updated: today }));
302
+ if (story.agent_plan) writePlan(f, key, path, story, story.agent_plan);
303
+ },
304
+ record: (b) => {
305
+ b.stories[key] = theirs;
306
+ b.statuses[key] = status;
307
+ if (story.agent_plan) b.plans[key] = story.agent_plan.version;
308
+ }
309
+ };
310
+ }
311
+ function writePlan(f, key, featurePath, story, plan) {
312
+ const existing = planVersions(deps.listDir(plansDir), key);
313
+ let next = (existing[existing.length - 1] ?? 0) + 1;
314
+ while (f.get(join(plansDir, `${key}.v${next}.md`)) !== null) next++;
315
+ const criteria = parseFeature(f.get(featurePath) ?? "")?.criteria ?? [];
316
+ const textOf = new Map(story.acceptance_criteria.map((c) => [c.id, c.text.trim()]));
317
+ const labelOf = (id) => criteria.find((c) => c.text.trim() === textOf.get(id))?.label || void 0;
318
+ f.set(join(plansDir, `${key}.v${next}.md`), renderPlan(key, next, plan, labelOf, today));
319
+ f.set(statePath, setState(f.get(statePath), key, { plan_version: next }));
320
+ }
321
+ const statusChanges = [];
322
+ const notMoved = [];
323
+ for (const s of live.filter((s2) => !s2.cancelled_at && STATUSES.has(s2.status) && state.stories?.[s2.key])) {
324
+ const key = s.key;
325
+ const mine = state.stories[key].status ?? "todo";
326
+ const was = base.statuses[key];
327
+ if (mine === s.status) nextBase.statuses[key] = s.status;
328
+ else if (was === s.status || was === void 0 && s.status === "todo") {
329
+ nextBase.statuses[key] = s.status;
330
+ notMoved.push(`${key} (${mine} here)`);
331
+ } else statusChanges.push({ key, from: mine, to: s.status });
332
+ }
333
+ outcome.statusNotMoved = notMoved;
334
+ if (statusChanges.length) {
335
+ units.push({
336
+ title: `Statuses: ${statusChanges.map((c) => `${c.key} ${c.from} \u2192 ${c.to}`).join(", ")}`,
337
+ kind: "web",
338
+ apply: (f) => {
339
+ let text = f.get(statePath);
340
+ for (const c of statusChanges) text = setState(text, c.key, { status: c.to, updated: today });
341
+ f.set(statePath, text);
342
+ },
343
+ record: (b) => {
344
+ for (const c of statusChanges) b.statuses[c.key] = c.to;
345
+ }
346
+ });
347
+ }
348
+ for (const story of live.filter((s) => !s.cancelled_at && s.agent_plan && files.has(s.key) && local.has(s.key))) {
349
+ const key = story.key;
350
+ const plan = story.agent_plan;
351
+ if ((base.plans[key] ?? 0) >= plan.version) continue;
352
+ const versions = planVersions(deps.listDir(plansDir), key);
353
+ const latest = versions[versions.length - 1];
354
+ const path = files.get(key);
355
+ const preview = new Files(deps.readFile);
356
+ writePlan(preview, key, path, story, plan);
357
+ const drafted = preview.edits().find(([p]) => p.startsWith(plansDir))?.[1] ?? "";
358
+ const current = latest ? deps.readFile(join(plansDir, `${key}.v${latest}.md`)) : null;
359
+ if (current && planBody(current) === planBody(drafted)) {
360
+ nextBase.plans[key] = plan.version;
361
+ continue;
362
+ }
363
+ units.push({
364
+ title: `Plan for ${key}: intentdocs plan v${plan.version} as ${key}.v${(latest ?? 0) + 1}.md`,
365
+ kind: "web",
366
+ apply: (f) => writePlan(f, key, path, story, plan),
367
+ record: (b) => b.plans[key] = plan.version
368
+ });
369
+ }
370
+ return units;
371
+ }
372
+ async function runPull(deps) {
373
+ const response = await deps.listStories();
374
+ const base = deps.readSyncBase();
375
+ const nextBase = structuredClone(base);
376
+ const outcome = { units: [], written: [], localOnly: [], cancelled: [], statusNotMoved: [] };
377
+ outcome.units = planUnits(deps, response, base, nextBase, outcome);
378
+ const disk = new Files(deps.readFile);
379
+ for (const unit of outcome.units) {
380
+ deps.log(`
381
+ ${KIND_LABEL[unit.kind]}: ${unit.title}`);
382
+ const preview = disk.clone();
383
+ unit.apply(preview);
384
+ for (const [path, after] of preview.edits()) {
385
+ const rel = relative(deps.cwd, path);
386
+ const before = deps.readFile(path);
387
+ deps.log(before === null ? `+++ ${rel} (new file)
388
+ ${after.trimEnd().split("\n").map((l) => `+${l}`).join("\n")}` : unifiedDiff(before, after, rel));
389
+ }
390
+ }
391
+ if (outcome.localOnly.length) deps.log(`
392
+ Changed only in the repo, so nothing to pull: ${outcome.localOnly.join(", ")}. \`intentdocs push\` sends them.`);
393
+ if (outcome.statusNotMoved.length)
394
+ deps.log(`
395
+ intentdocs hasn't moved these since the last sync, so their status here is kept: ${outcome.statusNotMoved.join(", ")}.`);
396
+ if (outcome.cancelled.length) deps.log(`Cancelled in intentdocs, left as they are here: ${outcome.cancelled.join(", ")}.`);
397
+ const safe = outcome.units.filter((u) => u.kind === "web");
398
+ const risky = outcome.units.filter((u) => u.kind !== "web");
399
+ const apply = /* @__PURE__ */ new Set();
400
+ if (outcome.units.length === 0) {
401
+ if (!outcome.localOnly.length && !outcome.statusNotMoved.length) deps.log("\nThe repo already matches intentdocs.");
402
+ } else if (deps.yes) {
403
+ for (const u of safe) apply.add(u);
404
+ if (risky.length) deps.log(`
405
+ --yes doesn't settle: ${risky.map((u) => u.title).join("; ")}. Run \`intentdocs pull\` in a terminal to choose.`);
406
+ } else if (!deps.interactive) {
407
+ deps.log("\nNothing written. Run `intentdocs pull` in a terminal to choose, or `intentdocs pull --yes` once you've agreed to the changes above.");
408
+ } else {
409
+ if (safe.length) {
410
+ const pick = await deps.choose(`Write ${safe.length === 1 ? "this change" : `these ${safe.length} changes`} from intentdocs?`, [
411
+ "Write nothing",
412
+ `Write ${safe.length === 1 ? "it" : `all ${safe.length}`}`,
413
+ "Choose one by one"
414
+ ]);
415
+ if (pick === 1) for (const u of safe) apply.add(u);
416
+ if (pick === 2) {
417
+ for (const u of safe) if (await deps.confirm(`Write: ${u.title}?`)) apply.add(u);
418
+ }
419
+ }
420
+ for (const u of risky) {
421
+ if (await deps.confirm(`${u.title}: replace the repo's version with intentdocs' (your local edits to it are lost)?`)) apply.add(u);
422
+ }
423
+ }
424
+ const result = new Files(deps.readFile);
425
+ for (const unit of outcome.units.filter((u) => apply.has(u))) {
426
+ unit.apply(result);
427
+ unit.record(nextBase);
428
+ }
429
+ for (const [path, text] of result.edits()) {
430
+ deps.writeFile(path, text);
431
+ outcome.written.push(relative(deps.cwd, path));
432
+ }
433
+ deps.writeSyncBase(nextBase);
434
+ if (outcome.written.length) deps.log(`
435
+ \u2713 Wrote ${outcome.written.join(", ")}`);
436
+ else if (outcome.units.length) deps.log("\nNothing was written.");
437
+ return outcome;
438
+ }
439
+ async function pull(opts = {}) {
440
+ const config = requireConfig();
441
+ try {
442
+ await runPull({
443
+ cwd: process.cwd(),
444
+ listStories: () => listStories(config),
445
+ readSyncBase,
446
+ writeSyncBase,
447
+ readFile: (p) => existsSync(p) ? readFileSync(p, "utf-8") : null,
448
+ writeFile: (p, t) => {
449
+ mkdirSync(dirname(p), { recursive: true });
450
+ writeFileSync(p, t);
451
+ },
452
+ listDir: (p) => existsSync(p) ? readdirSync(p) : [],
453
+ interactive: isInteractive(),
454
+ yes: opts.yes ?? false,
455
+ choose,
456
+ confirm,
457
+ log: (line) => console.log(line),
458
+ now: () => /* @__PURE__ */ new Date()
459
+ });
460
+ } catch (err) {
461
+ if (err instanceof PullError) {
462
+ console.error(`\u2717 ${err.message}`);
463
+ for (const d of err.details) console.error(` - ${d}`);
464
+ } else {
465
+ console.error(`\u2717 Pull failed: ${err.message}`);
466
+ }
467
+ process.exitCode = 1;
468
+ } finally {
469
+ closePrompt();
470
+ }
471
+ }
472
+ export {
473
+ PullError,
474
+ pull,
475
+ runPull
476
+ };