intentdocs 0.11.0 → 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.
package/dist/index.js CHANGED
@@ -8,11 +8,11 @@ import {
8
8
  } from "./chunk-UQDMUKLP.js";
9
9
  import {
10
10
  ci
11
- } from "./chunk-65EDM7QH.js";
11
+ } from "./chunk-5GATVQEI.js";
12
12
  import {
13
13
  setup,
14
14
  sync
15
- } from "./chunk-IORMKLAQ.js";
15
+ } from "./chunk-SDXDVNNP.js";
16
16
  import {
17
17
  criterionPriority,
18
18
  criterionStatusLabel,
@@ -31,7 +31,7 @@ import {
31
31
  } from "./chunk-KPQ42YE6.js";
32
32
  import {
33
33
  scope
34
- } from "./chunk-ID4B5RZ2.js";
34
+ } from "./chunk-Q6BSSJK5.js";
35
35
  import {
36
36
  login,
37
37
  logout
@@ -57,7 +57,7 @@ import {
57
57
  readConfig,
58
58
  requireConfig,
59
59
  writeConfig
60
- } from "./chunk-K5SNF6CG.js";
60
+ } from "./chunk-RGFGTIIH.js";
61
61
  import {
62
62
  ask,
63
63
  closePrompt,
@@ -304,7 +304,7 @@ Connecting to "${map2.title}"${project.workspace ? ` in ${project.workspace.name
304
304
  await offerAllowList((q) => ask2(rl, q));
305
305
  const setUpCi = await ask2(rl, " Set up CI test reporting now? It creates a key and adds the workflow. (y/N): ");
306
306
  if (/^y(es)?$/i.test(setUpCi.trim())) {
307
- const { ci: ci2 } = await import("./ci-NOCV3XUV.js");
307
+ const { ci: ci2 } = await import("./ci-CWPLSRCQ.js");
308
308
  await ci2();
309
309
  } else {
310
310
  console.log(" You can run `npx intentdocs ci` any time to turn it on.\n");
@@ -1634,7 +1634,7 @@ async function start() {
1634
1634
  closePrompt();
1635
1635
  return;
1636
1636
  }
1637
- const { scope: scope2 } = await import("./scope-RRGJHS2G.js");
1637
+ const { scope: scope2 } = await import("./scope-ORIFXV4P.js");
1638
1638
  await scope2(projectId);
1639
1639
  }
1640
1640
 
@@ -3019,7 +3019,10 @@ program.command("projects").description("List the projects your token can reach
3019
3019
  import("./projects-AYAYVFKL.js").then((m) => void m.projects());
3020
3020
  });
3021
3021
  program.command("push").description("Upload this repo's nikOS intent/ folder: log in, create the project, and link the repo").action(() => {
3022
- import("./push-33V7ANJW.js").then((m) => m.push());
3022
+ import("./push-7LJ654MJ.js").then((m) => m.push());
3023
+ });
3024
+ program.command("pull").description("Show web edits to this repo's feature files as a diff, then write the ones you agree to").option("-y, --yes", "write web changes without asking (never settles a story changed on both sides)").action((opts) => {
3025
+ import("./pull-MDI63WI7.js").then((m) => m.pull(opts));
3023
3026
  });
3024
3027
  program.command("init [token]").description("Connect this project to a intentdocs story map").action((token) => void init(token));
3025
3028
  program.command("sync").description("Refresh INTENT.md from the live story map").option("--context", "Refresh only the CLAUDE.md/AGENTS.md context file (not stories)").action((opts) => void sync(opts));
@@ -3034,36 +3037,36 @@ program.command("done <id>").description("Mark a story as done (accepts its key
3034
3037
  program.command("stop <id>").description("Stop development of an in-progress story (resets to todo, signals agents to stop; accepts its key or an ID prefix)").action((id) => void stop(id));
3035
3038
  program.command("add <title>").description("Create a new story").option("-a, --activity <activity>", "Activity to add the story under").option("-p, --priority <priority>", "Priority: must, should, could", "should").option("-e, --effort <effort>", "Effort: S, M, L, XL", "M").action((title, opts) => void add(title, opts));
3036
3039
  var decide = program.command("decide [title]").description("Log an architectural or product decision").option("-d, --description <desc>", "Decision description").option("-c, --category <cat>", "Category: architecture, product, technical, trade-off", "technical").option("-s, --story <id>", "Link to a story (its key like PB-42, or an ID prefix)").action((title, opts) => {
3037
- import("./log-DFCCAAJN.js").then((m) => void m.logDecision(title, opts));
3040
+ import("./log-VTIAG4PC.js").then((m) => void m.logDecision(title, opts));
3038
3041
  });
3039
3042
  decide.command("list").description("List logged decisions (id, category, title)").action(() => {
3040
- import("./log-DFCCAAJN.js").then((m) => void m.listLoggedDecisions());
3043
+ import("./log-VTIAG4PC.js").then((m) => void m.listLoggedDecisions());
3041
3044
  });
3042
3045
  decide.command("amend <id>").description("Amend a logged decision's title, description, or category (accepts an ID prefix)").option("-t, --title <title>", "New title").option("-d, --description <desc>", "New description").option("-c, --category <cat>", "New category: architecture, product, technical, trade-off").action((id, opts) => {
3043
- import("./log-DFCCAAJN.js").then((m) => void m.amendDecision(id, opts));
3046
+ import("./log-VTIAG4PC.js").then((m) => void m.amendDecision(id, opts));
3044
3047
  });
3045
3048
  decide.command("delete <id>").alias("retract").description("Retract a logged decision \u2014 soft-deleted and audited, not removed (accepts an ID prefix)").option("-r, --reason <reason>", "Why this decision is being retracted").option("-y, --yes", "Skip the confirmation prompt (required when there's no TTY)").action((id, opts) => {
3046
- import("./log-DFCCAAJN.js").then((m) => void m.deleteDecision(id, opts));
3049
+ import("./log-VTIAG4PC.js").then((m) => void m.deleteDecision(id, opts));
3047
3050
  });
3048
3051
  program.command("note <storyId> [note]").description("Add an implementation note to a story (appended to its notes)").action((storyId, note) => {
3049
- import("./log-DFCCAAJN.js").then((m) => void m.logNote(storyId, note));
3052
+ import("./log-VTIAG4PC.js").then((m) => void m.logNote(storyId, note));
3050
3053
  });
3051
3054
  program.command("example").description("Add a few-shot reference pattern (before/after code) to the project").action(() => void example());
3052
3055
  program.command("complexity [storyId]").description("Analyze story complexity with AI \u2014 scores, risks, effort estimates").action((id) => {
3053
- import("./complexity-OJVM37NF.js").then((m) => void m.complexity(id));
3056
+ import("./complexity-HLJTXY55.js").then((m) => void m.complexity(id));
3054
3057
  });
3055
3058
  program.command("research [query]").description("Research a topic with AI using your project context").action((query) => {
3056
- import("./research-SHO5SXVQ.js").then((m) => void m.research(query));
3059
+ import("./research-L27DLYXC.js").then((m) => void m.research(query));
3057
3060
  });
3058
3061
  program.command("import [file]").description("Import a PRD or spec into your story map as stories (reads standard input when no file is given; needs --yes then)").option("-y, --yes", "Import without asking for confirmation (required when reading from standard input)").action((file, opts) => {
3059
- import("./import-prd-5FCVXCYO.js").then((m) => void m.importPrd(file, opts));
3062
+ import("./import-prd-FQI4J45M.js").then((m) => void m.importPrd(file, opts));
3060
3063
  });
3061
3064
  var map = program.command("map").description("Import or extract story maps from codebases");
3062
3065
  map.command("extract").description("Output a prompt for your coding agent to extract a story map from an existing codebase").action(() => {
3063
- import("./map-extract-YUCFLGJS.js").then((m) => void m.mapExtract());
3066
+ import("./map-extract-4C44YGGD.js").then((m) => void m.mapExtract());
3064
3067
  });
3065
3068
  map.command("import <file>").description("Import a storymap.json file generated by your coding agent").option("--yes", "Skip confirmation prompt").action((file, opts) => {
3066
- import("./map-import-7RN2NEUZ.js").then((m) => void m.mapImport(file, opts));
3069
+ import("./map-import-TSSDU766.js").then((m) => void m.mapImport(file, opts));
3067
3070
  });
3068
3071
  map.command("schema").description("Print the JSON Schema for storymap.json").action(() => {
3069
3072
  import("./map-schema-5BEIJM2J.js").then((m) => void m.mapSchema());
@@ -17,7 +17,7 @@ import {
17
17
  } from "./chunk-KTGQO3AE.js";
18
18
  import {
19
19
  requireConfig
20
- } from "./chunk-K5SNF6CG.js";
20
+ } from "./chunk-RGFGTIIH.js";
21
21
  import {
22
22
  ask,
23
23
  closePrompt,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  requireConfig
3
- } from "./chunk-K5SNF6CG.js";
3
+ } from "./chunk-RGFGTIIH.js";
4
4
  import "./chunk-UYTVTYNB.js";
5
5
 
6
6
  // src/commands/map-extract.ts
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-KTGQO3AE.js";
9
9
  import {
10
10
  requireConfig
11
- } from "./chunk-K5SNF6CG.js";
11
+ } from "./chunk-RGFGTIIH.js";
12
12
  import {
13
13
  closePrompt,
14
14
  confirm
@@ -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
+ };