@tacuchi/agent-workflow-cli 25.4.0 → 25.6.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.
Files changed (67) hide show
  1. package/README.md +2 -0
  2. package/dist/application/cut-intent-ledger.js +164 -0
  3. package/dist/application/cut-intent-ledger.js.map +1 -0
  4. package/dist/application/export-service.js +253 -37
  5. package/dist/application/export-service.js.map +1 -1
  6. package/dist/application/history-publications.js +76 -0
  7. package/dist/application/history-publications.js.map +1 -0
  8. package/dist/application/lineage-service.js +119 -0
  9. package/dist/application/lineage-service.js.map +1 -0
  10. package/dist/application/paths-service.js +8 -0
  11. package/dist/application/paths-service.js.map +1 -1
  12. package/dist/application/persist-service.js +9 -3
  13. package/dist/application/persist-service.js.map +1 -1
  14. package/dist/application/release-pass-ledger.js +327 -0
  15. package/dist/application/release-pass-ledger.js.map +1 -0
  16. package/dist/application/resume-service.js +9 -5
  17. package/dist/application/resume-service.js.map +1 -1
  18. package/dist/application/retirement/prepare.js +2 -2
  19. package/dist/application/retirement/prepare.js.map +1 -1
  20. package/dist/application/retirement/resolve.js.map +1 -1
  21. package/dist/application/session-resolver.js +8 -2
  22. package/dist/application/session-resolver.js.map +1 -1
  23. package/dist/application/{retirement/graph.js → workline-graph.js} +18 -12
  24. package/dist/application/workline-graph.js.map +1 -0
  25. package/dist/application/workline-index-service.js +200 -16
  26. package/dist/application/workline-index-service.js.map +1 -1
  27. package/dist/application/workspace-materialization-service.js +40 -4
  28. package/dist/application/workspace-materialization-service.js.map +1 -1
  29. package/dist/application/worktree-service.js +276 -6
  30. package/dist/application/worktree-service.js.map +1 -1
  31. package/dist/cli/commands/cut-intent.js +176 -0
  32. package/dist/cli/commands/cut-intent.js.map +1 -0
  33. package/dist/cli/commands/export.js +64 -15
  34. package/dist/cli/commands/export.js.map +1 -1
  35. package/dist/cli/commands/index.js +10 -0
  36. package/dist/cli/commands/index.js.map +1 -1
  37. package/dist/cli/commands/release-pass.js +228 -0
  38. package/dist/cli/commands/release-pass.js.map +1 -0
  39. package/dist/cli/commands/resume.js +3 -0
  40. package/dist/cli/commands/resume.js.map +1 -1
  41. package/dist/cli/commands/status.js +7 -0
  42. package/dist/cli/commands/status.js.map +1 -1
  43. package/dist/cli/commands/worktree.js +19 -5
  44. package/dist/cli/commands/worktree.js.map +1 -1
  45. package/dist/cli/help-groups.js +11 -0
  46. package/dist/cli/help-groups.js.map +1 -1
  47. package/dist/cli/main.js +27 -3
  48. package/dist/cli/main.js.map +1 -1
  49. package/dist/cli/parser.js +4 -0
  50. package/dist/cli/parser.js.map +1 -1
  51. package/dist/cli/tui/data/recommended-skills.js +39 -10
  52. package/dist/cli/tui/data/recommended-skills.js.map +1 -1
  53. package/dist/domain/cut-intent.js +79 -0
  54. package/dist/domain/cut-intent.js.map +1 -0
  55. package/dist/domain/flow/authority.js +8 -0
  56. package/dist/domain/flow/authority.js.map +1 -1
  57. package/dist/domain/release-pass.js +104 -0
  58. package/dist/domain/release-pass.js.map +1 -0
  59. package/dist/runtime/namespace-resolver.js +3 -10
  60. package/dist/runtime/namespace-resolver.js.map +1 -1
  61. package/dist/runtime/workline-marker.js +70 -0
  62. package/dist/runtime/workline-marker.js.map +1 -0
  63. package/package.json +1 -1
  64. package/skills/w/commands/export-scripts.md +13 -12
  65. package/skills/w/exports/README.md +4 -2
  66. package/skills/w/exports/export-scripts/EXPORT.md +39 -45
  67. package/dist/application/retirement/graph.js.map +0 -1
package/README.md CHANGED
@@ -187,6 +187,8 @@ Workspace artifacts live under `.<namespace>/`. Resolution order (first match wi
187
187
  - `self install-skill` / `self doctor` / `self update` / `mcp` — CLI maintenance.
188
188
  - `amend <apply|revert|list>` — correct the WORDING of an already closed spec or plan in one act, under the workspace lock and with the document's own digest as the compare-and-swap base; it demands an explicit declaration that no scope, criteria or rules move, records the exact pre-image in an append-only ledger, and refuses structurally whatever touches the contract (naming the refinement instead). CLI-only: there is no `/w:amend`.
189
189
  - `settle <list|prepare|apply>` — settle or acknowledge the live obligations a decision note left on a plan whose execution run is already closed. `list` shows each one with its note, position, class, whether that class was declared and the plan's CURRENT resume point; `prepare` derives the same settlement note the closure derives, writes nothing and returns the digest that authorizes it; `apply` re-derives from the live workspace, demands that digest and publishes under the lock. It refuses while an execution run holds the plan, naming that run — its closure settles its own obligations. CLI-only: there is no `/w:settle`.
190
+ - `cut-intent <declare|show>` — the intention with which a spec cut into several plans was meant to be executed: which plans travel together in one pass, in what order, and which are deferred with their cause. Correcting means declaring again — the previous record stays legible underneath, so a reordering is something a person can review instead of simply inherit. It restricts nothing: executing out of the declared order stays valid and is only warned. CLI-only: there is no `/w:cut-intent`.
191
+ - `release-pass <list|declare|arrived|applied|revert|link>` — the pass to production as its own object, because closed is not released. `declare` opens one over the sources it covers; `arrived` registers one source's arrival, and while another is still missing the pass reads partially released, naming both; `applied` registers that the SQL the pass carries RAN against a named environment — its own axis, never a fourth arrival kind, so a pass with no such record reads NO RECORD rather than nothing-applied; `revert` adds a reversion that never erases the arrivals it follows; `link` attaches a document by workspace-relative path, checking only that it exists. Nothing here checks the world: registering is DECLARING a fact somebody already knows. It feeds the `production` axis of `status`/`resume` and the `--environment` filter of `export-scripts`. CLI-only: there is no `/w:release-pass`.
190
192
 
191
193
  Run `agent-workflow --help` (or `aw --help`) for the full list, or `agent-workflow <command> --help` for per-command flags.
192
194
 
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The durable record of how somebody meant to execute a cut — and the only place
3
+ * it lives.
4
+ *
5
+ * Same shape and same reasons as the claims ledger next to it: append-only, one
6
+ * JSON record per line, under `.workflow/` and deliberately OUTSIDE `docs/`. The
7
+ * corpus is for documents somebody published; an intent is workspace state, not a
8
+ * document, and putting it in `docs/` would make the record itself look like a
9
+ * spec or a plan.
10
+ *
11
+ * Append-only is load-bearing for a different reason here than there. A
12
+ * revocation has to be irrevocable; an intent, by contrast, is *meant* to be
13
+ * corrected — plans get reordered, a group gets split, something moves to the
14
+ * next pass. So a correction is a NEW record that supersedes the previous one
15
+ * for reading, and the previous one stays exactly where it was. Rewriting it
16
+ * would destroy the one thing that makes a reorder reviewable: what the order
17
+ * used to be, and therefore that somebody changed it.
18
+ */
19
+ import { join } from "node:path";
20
+ import { assertDeclarable, positionOf, } from "../domain/cut-intent.js";
21
+ import { formatNodeId, isWorklineKind } from "../domain/workline-node.js";
22
+ /** Lives next to HISTORY.md and claims.jsonl: workspace state, never corpus. */
23
+ const LEDGER_FILE = "cut-intents.jsonl";
24
+ const LEDGER_VERSION = 1;
25
+ export function cutIntentLedgerPath(paths) {
26
+ return join(paths.cwdRoot(), LEDGER_FILE);
27
+ }
28
+ /**
29
+ * Add one record. Append-only by construction: there is no update and no delete.
30
+ *
31
+ * Validated before the write for the reason the whole file exists: a malformed
32
+ * record cannot be corrected in place, only buried, so the cheap refusal is here.
33
+ * One record is one short line, so `O_APPEND` keeps concurrent writers from
34
+ * interleaving halves of it — the same bound the claims ledger relies on, and the
35
+ * same reason a record is not pretty-printed.
36
+ */
37
+ export async function appendCutIntent(fs, paths, event) {
38
+ assertDeclarable(event.intent);
39
+ const record = { version: LEDGER_VERSION, event: "declared", ...event };
40
+ await fs.appendText(cutIntentLedgerPath(paths), `${JSON.stringify(record)}\n`);
41
+ }
42
+ /** Every record, oldest first. A missing ledger reads as empty, never as an error. */
43
+ export async function readCutIntents(fs, paths) {
44
+ const path = cutIntentLedgerPath(paths);
45
+ if (!(await fs.exists(path)))
46
+ return { events: [], unreadable: 0 };
47
+ const raw = await fs.readText(path);
48
+ const events = [];
49
+ let unreadable = 0;
50
+ for (const line of raw.split("\n")) {
51
+ const trimmed = line.trim();
52
+ if (trimmed.length === 0)
53
+ continue;
54
+ const parsed = parseEvent(trimmed);
55
+ if (parsed === null)
56
+ unreadable += 1;
57
+ else
58
+ events.push(parsed);
59
+ }
60
+ return { events, unreadable };
61
+ }
62
+ function parseEvent(line) {
63
+ let value;
64
+ try {
65
+ value = JSON.parse(line);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ if (typeof value !== "object" || value === null)
71
+ return null;
72
+ const candidate = value;
73
+ if (typeof candidate.at !== "string" || candidate.event !== "declared")
74
+ return null;
75
+ const intent = candidate.intent;
76
+ if (typeof intent !== "object" || intent === null)
77
+ return null;
78
+ if (!isNode(intent.spec) || !isNodeList(intent.order) || !isNodeList(intent.deferred)) {
79
+ return null;
80
+ }
81
+ return value;
82
+ }
83
+ function isNode(value) {
84
+ if (typeof value !== "object" || value === null)
85
+ return false;
86
+ const node = value;
87
+ return isWorklineKind(node.kind) && typeof node.key === "string" && node.key.length > 0;
88
+ }
89
+ function isNodeList(value) {
90
+ return Array.isArray(value) && value.every(isNode);
91
+ }
92
+ /**
93
+ * The intent in force for this spec: the LAST record about it, or `null`.
94
+ *
95
+ * Derived rather than stored, because the ledger is append-only and a "current"
96
+ * flag would be exactly the mutable state that makes an append-only log
97
+ * pointless. Last-wins is the whole correction mechanism: declaring again is how
98
+ * you fix an order, and the superseded record stays readable above it.
99
+ */
100
+ export function currentIntentOf(events, spec) {
101
+ const key = formatNodeId(spec);
102
+ let current = null;
103
+ for (const event of events) {
104
+ if (formatNodeId(event.intent.spec) === key)
105
+ current = event;
106
+ }
107
+ return current;
108
+ }
109
+ /**
110
+ * The intent in force that mentions this plan, or `null`.
111
+ *
112
+ * Asked by plan rather than by spec because that is the question the board has:
113
+ * it is holding a plan and wants to know where the person put it. A plan is
114
+ * mentioned by at most one cut in force — its spec's — so the first match is the
115
+ * answer, and a plan named by a superseded record only counts if the record that
116
+ * superseded it still names it.
117
+ */
118
+ export function currentIntentForPlan(events, plan) {
119
+ const specs = new Set(events.map((event) => formatNodeId(event.intent.spec)));
120
+ const key = formatNodeId(plan);
121
+ for (const spec of specs) {
122
+ const [kind, ...rest] = spec.split(":");
123
+ if (!isWorklineKind(kind))
124
+ continue;
125
+ const current = currentIntentOf(events, { kind, key: rest.join(":") });
126
+ if (current === null)
127
+ continue;
128
+ const mentioned = [...current.intent.order, ...current.intent.deferred].some((node) => formatNodeId(node) === key);
129
+ if (mentioned)
130
+ return current;
131
+ }
132
+ return null;
133
+ }
134
+ /**
135
+ * What the workspace answers about one plan — the explicit reading, never a list.
136
+ *
137
+ * The `declared: false` branch carries its reason because the three ways a plan
138
+ * can have no declared place are not the same thing to the person reading it:
139
+ * nobody ever declared a cut for its spec, the ledger could not be fully read, or
140
+ * a cut exists and simply does not mention this plan. Collapsing them into an
141
+ * empty list is what would let a caller fill the silence with the correlative.
142
+ */
143
+ export function readingForPlan(read, plan) {
144
+ const current = currentIntentForPlan(read.events, plan);
145
+ if (current === null) {
146
+ if (read.unreadable > 0) {
147
+ return {
148
+ declared: false,
149
+ reason: `no hay intención declarada legible para '${formatNodeId(plan)}': el libro tiene ${read.unreadable} línea(s) que no se pueden leer, así que su ausencia no está probada`,
150
+ };
151
+ }
152
+ return {
153
+ declared: false,
154
+ reason: `nadie declaró una intención de corte que nombre a '${formatNodeId(plan)}'`,
155
+ };
156
+ }
157
+ return {
158
+ declared: true,
159
+ intent: current.intent,
160
+ at: current.at,
161
+ position: positionOf(current.intent, plan),
162
+ };
163
+ }
164
+ //# sourceMappingURL=cut-intent-ledger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cut-intent-ledger.js","sourceRoot":"","sources":["../../src/application/cut-intent-ledger.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,12 @@
1
+ import { basename, relative, sep } from "node:path";
1
2
  import { CORRELATIVE_SOURCE, isCorrelative } from "../domain/correlative.js";
2
3
  import { localDateIso } from "./dates.js";
3
4
  import { runNextNumber } from "./dev-only-services.js";
4
5
  import { resolveDocsCanon } from "./docs-canon-service.js";
6
+ import { appendPublications, publicationRows } from "./history-publications.js";
5
7
  import { withCwdLock } from "./lock-service.js";
6
8
  import { runReleaseData } from "./release-data-service.js";
9
+ import { derivePasses, readReleasePasses } from "./release-pass-ledger.js";
7
10
  import { approvalDigest, buildSemanticRequest, parseSemanticResponse, readEnvelopeScope, } from "./semantic-operation/protocol.js";
8
11
  import { publishArtifacts } from "./semantic-operation/publish.js";
9
12
  export const EXPORT_CATEGORIES = [
@@ -17,11 +20,12 @@ export const EXPORT_CATEGORIES = [
17
20
  * command guide. Keep its semantic anchors exported so a small parity guard
18
21
  * catches doctrine that drifts away from what the CLI actually sends.
19
22
  */
20
- export const SCRIPTS_FINAL_STATE_CONTRACT = "Un dossier con 00-ROLLBACK.sql y README.md obligatorios, más los forwards NN-<nombre>.sql numerados de forma continua desde 01. El CLI NUNCA ejecuta SQL. El bundle publica el ESTADO FINAL NETO de la secuencia, no una réplica por sesión: lo que nace y muere dentro de la secuencia se omite; lo migrado va directo a su forma final; lo que el contexto declara retirado se omite aunque ningún script lo elimine. 00-ROLLBACK.sql invierte ese ESTADO FINAL en orden seguro para las dependencias, no el reverso literal de los forwards. Reconciliá contra el código además de las sesiones y la base. Excluí identidades concretas y semillas de prueba; conservá sólo objetos compartidos y necesarios para el estado final.";
23
+ export const SCRIPTS_FINAL_STATE_CONTRACT = "Un dossier con 00-ROLLBACK.sql y README.md obligatorios, más los forwards NN-<nombre>.sql numerados de forma continua desde 01. El CLI NUNCA ejecuta SQL. El bundle publica el ESTADO FINAL NETO de la secuencia, no una réplica por sesión: lo que nace y muere dentro de la secuencia se omite; lo migrado va directo a su forma final; lo que el contexto declara retirado se omite aunque ningún script lo elimine. 00-ROLLBACK.sql invierte ese ESTADO FINAL en orden seguro para las dependencias, no el reverso literal de los forwards. Reconciliá contra el código además de las sesiones y la base. Excluí identidades concretas y semillas de prueba; conservá sólo objetos compartidos y necesarios para el estado final. Un bundle previo que entra al origen es MATERIAL A RECONCILIAR, no historia intocable: dos bundles que se contradicen publican el estado final neto resultante, nunca su suma cronológica.";
21
24
  export const SCRIPTS_FINAL_STATE_CONTRACT_ANCHORS = [
22
25
  "ESTADO FINAL NETO",
23
26
  "orden seguro para las dependencias",
24
27
  "objetos compartidos y necesarios para el estado final",
28
+ "MATERIAL A RECONCILIAR",
25
29
  ];
26
30
  const POLICIES = {
27
31
  diagrams: {
@@ -57,6 +61,7 @@ const POLICIES = {
57
61
  };
58
62
  const LIMITS = { max_artifacts: 64, max_artifact_bytes: 512 * 1024 };
59
63
  const FORWARD_RE = /^(\d{2})-[^/]+\.sql$/;
64
+ const EXPORT_BASES = ["sessions", "bundles", "workspace"];
60
65
  // ── prepare ──────────────────────────────────────────────────────────────────
61
66
  export async function prepareExport(fs, env, paths, category, selection = {},
62
67
  // Injected so the midnight boundary is testable: a preparation that outlives
@@ -74,27 +79,10 @@ now = () => new Date()) {
74
79
  };
75
80
  }
76
81
  const policy = resolvePolicy(category, canon.canon[category]);
77
- const corpus = await readCorpus(fs, env, paths, selection);
78
- if ("error" in corpus) {
79
- return {
80
- ok: false,
81
- failure: {
82
- code: "EXPORT_CORPUS_UNAVAILABLE",
83
- message: corpus.error,
84
- action: "revisá el workspace y los filtros --sessions/--since/--source",
85
- },
86
- };
87
- }
88
- if (corpus.sessions.length === 0) {
89
- return {
90
- ok: false,
91
- failure: {
92
- code: "EXPORT_CORPUS_EMPTY",
93
- message: "ninguna sesión coincide con los filtros",
94
- action: "ampliá --since, quitá --sessions, o revisá que existan sesiones cerradas",
95
- },
96
- };
97
- }
82
+ const resolved = await resolveMaterial(fs, env, paths, category, selection);
83
+ if (!resolved.ok)
84
+ return resolved;
85
+ const material = resolved.value;
98
86
  // Pinned when the answer echoed them, derived only on a first preparation:
99
87
  // re-deriving either at `validate` renames the very unit the answer wrote to,
100
88
  // and neither is workspace state that a stale check should be defending.
@@ -120,6 +108,9 @@ now = () => new Date()) {
120
108
  ...(selection.sessions !== undefined ? { sessions: selection.sessions } : {}),
121
109
  ...(selection.since !== undefined ? { since: selection.since } : {}),
122
110
  ...(selection.source !== undefined ? { source: selection.source } : {}),
111
+ ...(selection.from !== undefined ? { from: selection.from } : {}),
112
+ ...(selection.exclude !== undefined ? { exclude: selection.exclude } : {}),
113
+ ...(selection.environment !== undefined ? { environment: selection.environment } : {}),
123
114
  date,
124
115
  next,
125
116
  };
@@ -130,18 +121,34 @@ now = () => new Date()) {
130
121
  required: policy.required,
131
122
  extensions: policy.extensions,
132
123
  overwritable: policy.overwritable,
133
- sessions: corpus.sessions,
124
+ // What the material was composed from, and what stayed in and out of it —
125
+ // declared BEFORE anything is composed, which is the only moment at which
126
+ // the person can still disagree with the origin.
127
+ origins: material.origins,
128
+ sessions: material.sessions,
129
+ bundles: material.bundles,
130
+ standalone_sql: material.standalone,
131
+ excluded: material.excluded,
132
+ exclude_unmatched: material.unmatched,
133
+ environment: material.environment,
134
134
  date,
135
135
  };
136
- const readSet = corpus.sessions.map((s) => s.path ?? s.folder);
136
+ const readSet = materialPaths(material);
137
137
  const request = buildSemanticRequest({
138
138
  operation: `export-${category}`,
139
- // What the seal defends is workspace state: the corpus the scope covers (a
140
- // session appearing or closing changes what the dossier should have
141
- // contained) and the folder this workspace publishes to. The scope rides
142
- // along so an altered echo cannot pass as the original one.
143
- inputs: { corpus: corpus.sessions, dir: policy.dir, scope },
144
- sealed: "el corpus de sesiones del alcance o el destino declarado de la categoría",
139
+ // What the seal defends is workspace state: the MATERIAL the scope covers —
140
+ // sessions, loose SQL and previously published bundles alike, since any of
141
+ // them appearing or changing changes what the dossier should have contained
142
+ // — and the folder this workspace publishes to. The scope rides along so an
143
+ // altered echo cannot pass as the original one.
144
+ inputs: {
145
+ corpus: material.sessions,
146
+ bundles: material.bundles,
147
+ standalone: material.standalone,
148
+ dir: policy.dir,
149
+ scope,
150
+ },
151
+ sealed: "el material del alcance o el destino declarado de la categoría",
145
152
  scope,
146
153
  contract: `${policy.contract} Respondé artifacts con paths dentro de ${unit}${policy.overwritable === null ? "" : ` (o exactamente ${policy.overwritable})`}. El NNN es consultivo: el CLI reasigna el número dentro del lock. Copiá 'scope' TAL CUAL en tu respuesta: validate y apply lo leen en vez de re-derivarlo.`,
147
154
  inventory,
@@ -162,12 +169,104 @@ function resolvePolicy(category, dir) {
162
169
  overwritable: overwritable === undefined ? null : `${resolved}/${overwritable}`,
163
170
  };
164
171
  }
165
- async function readCorpus(fs, env, paths, selection) {
172
+ /**
173
+ * The composable origin belongs to the SQL bundle and to nothing else.
174
+ *
175
+ * The other three categories publish documents an author writes; there is no
176
+ * previous manual to re-consolidate and no environment a diagram ran against.
177
+ * Accepting the flags there would answer a question those categories never ask.
178
+ */
179
+ function checkComposableSelection(category, selection) {
180
+ // Belonging comes FIRST: telling a category that does not compose its origin
181
+ // which bases exist would send it to fix a value that was never going to be
182
+ // read, and it would be rejected again on the next invocation.
183
+ const named = ["from", "exclude", "environment"].filter((key) => selection[key] !== undefined);
184
+ if (category !== "scripts" && named.length > 0) {
185
+ return {
186
+ code: "EXPORT_SCOPE_INVALID",
187
+ message: `${named.map((k) => `--${k}`).join(", ")} es del bundle de SQL: export-${category} parte siempre del corpus de sesiones`,
188
+ action: "quitá esos flags, o usá aw export-scripts si lo que querés componer es el bundle",
189
+ };
190
+ }
191
+ // Rejected HERE and not when the base is read, for the same reason a malformed
192
+ // `--date` is: the invocation that supplied it is the one that can fix it, and
193
+ // an unknown base silently read as one of the three would compose a different
194
+ // origin than the one that was asked for.
195
+ if (selection.from !== undefined && !EXPORT_BASES.includes(selection.from)) {
196
+ return {
197
+ code: "EXPORT_SCOPE_INVALID",
198
+ message: `--from '${selection.from}' no es una base: ${EXPORT_BASES.join(", ")}`,
199
+ action: "repetí la invocación con una de las tres bases, o sin --from para partir de las sesiones",
200
+ };
201
+ }
202
+ return null;
203
+ }
204
+ /**
205
+ * The material this preparation covers, or the reason there is none.
206
+ *
207
+ * The three ways it can fail — a base that is not one, an origin this category
208
+ * does not compose, and an origin that came back empty — answer the same
209
+ * question and travel together, so `prepare` reads as the sequence it is.
210
+ */
211
+ async function resolveMaterial(fs, env, paths, category, selection) {
212
+ const invalid = checkComposableSelection(category, selection);
213
+ if (invalid !== null)
214
+ return { ok: false, failure: invalid };
215
+ const material = await composeMaterial(fs, env, paths, selection);
216
+ if ("error" in material) {
217
+ return {
218
+ ok: false,
219
+ failure: {
220
+ code: "EXPORT_CORPUS_UNAVAILABLE",
221
+ message: material.error,
222
+ action: "revisá el workspace y los filtros --sessions/--since/--source",
223
+ },
224
+ };
225
+ }
226
+ if (materialCount(material) === 0)
227
+ return { ok: false, failure: emptyOrigin(material) };
228
+ return { ok: true, value: material };
229
+ }
230
+ /**
231
+ * Why the origin came back empty — and "everything already ran" is its own answer.
232
+ *
233
+ * Proposing a bundle with nothing in it would be the wrong outcome twice over:
234
+ * there is nothing to deliver, and the reason there is nothing is good news the
235
+ * person asked for. Folding it into the generic empty corpus would send them
236
+ * looking for a filter to widen.
237
+ */
238
+ function emptyOrigin(material) {
239
+ const applied = material.excluded.filter((item) => item.reason === "applied");
240
+ if (applied.length > 0 && material.environment !== null) {
241
+ return {
242
+ code: "EXPORT_ORIGIN_ALREADY_APPLIED",
243
+ message: `todo el material que quedaba en el origen ya consta aplicado en '${material.environment.name}': no hay nada que consolidar`,
244
+ action: `nada que hacer; si igual querés reconsolidarlo, repetí la invocación sin --environment ${material.environment.name}`,
245
+ };
246
+ }
247
+ return {
248
+ code: "EXPORT_CORPUS_EMPTY",
249
+ message: `ningún material del origen (${material.origins.join(", ")}) coincide con los filtros`,
250
+ action: "ampliá --since, quitá --sessions o --exclude, probá otro --from, o revisá que existan sesiones cerradas",
251
+ };
252
+ }
253
+ /**
254
+ * The material of this preparation: a base brings, the exclusions subtract.
255
+ *
256
+ * Both halves in one place, because "where did this come from" and "why is this
257
+ * not here" are the two questions `prepare` has to answer together — and the
258
+ * listings the bases read are the ones `release-data` already produces, walked
259
+ * once by it rather than twice by this.
260
+ */
261
+ async function composeMaterial(fs, env, paths, selection) {
262
+ const base = selection.from ?? "sessions";
166
263
  const input = {
167
264
  includeClosed: true,
168
- // Graduated bundles are previous exports: re-exporting them would duplicate
169
- // what already lives in docs/.
170
- includeGraduated: false,
265
+ // A base of `sessions` is the behavior that always was: graduated bundles are
266
+ // previous exports and re-exporting them would duplicate what already lives
267
+ // in docs/. The other bases are asking for exactly that material.
268
+ includeGraduated: base !== "sessions",
269
+ includeStandaloneSql: base === "workspace",
171
270
  ...(selection.sessions !== undefined ? { sessions: selection.sessions } : {}),
172
271
  ...(selection.since !== undefined ? { since: selection.since } : {}),
173
272
  ...(selection.source !== undefined ? { sourceAlias: selection.source } : {}),
@@ -175,7 +274,104 @@ async function readCorpus(fs, env, paths, selection) {
175
274
  const data = await runReleaseData(fs, env, paths, input);
176
275
  if ("error" in data)
177
276
  return { error: data.error };
178
- return { sessions: data.sessions };
277
+ const sessions = base === "bundles" ? [] : data.sessions;
278
+ const standalone = base === "workspace" ? (data.standalone_sql ?? []) : [];
279
+ const bundles = base === "sessions" ? [] : (data.graduated_bundles ?? []);
280
+ const origins = [
281
+ ...(base === "bundles" ? [] : ["sessions"]),
282
+ ...(base === "workspace" ? ["standalone-sql"] : []),
283
+ ...(base === "sessions" ? [] : ["bundles"]),
284
+ ];
285
+ const named = new Set(selection.exclude ?? []);
286
+ const manual = [
287
+ ...sessions.map((s) => piece("sessions", s.folder, s.path ?? s.folder)),
288
+ ...standalone.map((f) => piece("standalone-sql", f.name, f.path)),
289
+ ...bundles.map((b) => piece("bundles", bundleName(b), b.path)),
290
+ ]
291
+ .filter((item) => named.has(item.name))
292
+ .map((item) => ({ ...item, reason: "manual" }));
293
+ // A name that subtracted nothing is DECLARED, not rejected: the base or the
294
+ // filters may have left that piece out already, and refusing an invocation
295
+ // whose intent is served would be hostile. But a typo looks identical from
296
+ // here, and staying silent would ship the very material the person believed
297
+ // they had taken out — which is the failure this whole command exists against.
298
+ const matched = new Set(manual.map((item) => item.name));
299
+ const unmatched = [...named].filter((name) => !matched.has(name));
300
+ // The environment subtracts from what the manual exclusions already left, and
301
+ // by the SAME road: both are exclusions of pieces and differ only in the
302
+ // reason the inventory declares, which is the whole of D-05.
303
+ const kept = bundles.filter((b) => !named.has(bundleName(b)));
304
+ const environment = await filterByEnvironment(fs, paths, selection.environment, kept);
305
+ const excluded = [...manual, ...environment.excluded];
306
+ const out = new Set(excluded.map((item) => item.name));
307
+ return {
308
+ origins,
309
+ sessions: sessions.filter((s) => !out.has(s.folder)),
310
+ bundles: bundles.filter((b) => !out.has(bundleName(b))),
311
+ standalone: standalone.filter((f) => !out.has(f.name)),
312
+ excluded,
313
+ unmatched,
314
+ environment: environment.filter,
315
+ };
316
+ }
317
+ /**
318
+ * Which of these bundles the book says already ran against this environment.
319
+ *
320
+ * The chain adds no new piece: a pass LINKS the bundle by workspace-relative
321
+ * path and that same pass has an application for the environment. One record is
322
+ * enough — a bundle linked to two passes where only one ran there did run, and
323
+ * demanding unanimity would re-deliver SQL that is already in place, which is
324
+ * the error this filter exists to prevent.
325
+ */
326
+ async function filterByEnvironment(fs, paths, environment, bundles) {
327
+ if (environment === undefined)
328
+ return { excluded: [], filter: null };
329
+ const passes = derivePasses((await readReleasePasses(fs, paths)).events);
330
+ const there = passes.filter((derived) => derived.application.axis === "applied" &&
331
+ derived.application.environments.includes(environment));
332
+ // Both sides normalized to `/`: the book stores the path a person typed and
333
+ // this one comes from the filesystem, so on Windows the same bundle would be
334
+ // `docs/scripts/…` in one and `docs\\scripts\\…` in the other and nothing would
335
+ // ever match — every bundle would read as pending and be delivered twice.
336
+ const linked = new Set(there.flatMap((derived) => derived.artifacts).map(slashed));
337
+ const excluded = bundles
338
+ .filter((bundle) => linked.has(slashed(relative(paths.workspaceDir(), bundle.path))))
339
+ .map((bundle) => ({
340
+ ...piece("bundles", bundleName(bundle), bundle.path),
341
+ reason: "applied",
342
+ }));
343
+ return {
344
+ excluded,
345
+ filter: {
346
+ name: environment,
347
+ axis: there.length === 0 ? "no-record" : "applied",
348
+ scanned: bundles.length,
349
+ excluded: excluded.length,
350
+ },
351
+ };
352
+ }
353
+ /** One spelling for a path that two different producers wrote. */
354
+ function slashed(path) {
355
+ return path.split(sep).join("/");
356
+ }
357
+ /** What names a bundle in `--exclude` and in the inventory: its directory. */
358
+ function bundleName(bundle) {
359
+ return basename(bundle.path);
360
+ }
361
+ function piece(origin, name, path) {
362
+ return { origin, name, path };
363
+ }
364
+ /** How many pieces stayed in. Zero is an empty origin, whatever the base was. */
365
+ function materialCount(material) {
366
+ return material.sessions.length + material.bundles.length + material.standalone.length;
367
+ }
368
+ /** Everything the composer has to read, across the three origins. */
369
+ function materialPaths(material) {
370
+ return [
371
+ ...material.sessions.map((s) => s.path ?? s.folder),
372
+ ...material.standalone.map((f) => f.path),
373
+ ...material.bundles.map((b) => b.path),
374
+ ];
179
375
  }
180
376
  // ── the scope, travelling between stages ─────────────────────────────────────
181
377
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -205,6 +401,9 @@ export function readExportScope(raw) {
205
401
  ...(scope.sessions !== undefined ? { sessions: scope.sessions } : {}),
206
402
  ...(scope.since !== undefined ? { since: scope.since } : {}),
207
403
  ...(scope.source !== undefined ? { source: scope.source } : {}),
404
+ ...(scope.from !== undefined ? { from: scope.from } : {}),
405
+ ...(scope.exclude !== undefined ? { exclude: scope.exclude } : {}),
406
+ ...(scope.environment !== undefined ? { environment: scope.environment } : {}),
208
407
  date: scope.date,
209
408
  next: scope.next,
210
409
  },
@@ -221,7 +420,13 @@ function scopeShapeError(scope) {
221
420
  if (scope.sessions !== undefined && !isStringArray(scope.sessions)) {
222
421
  return "'sessions' tiene que ser una lista de códigos de texto";
223
422
  }
224
- for (const key of ["since", "source"]) {
423
+ if (scope.exclude !== undefined && !isStringArray(scope.exclude)) {
424
+ return "'exclude' tiene que ser una lista de nombres de texto";
425
+ }
426
+ if (scope.from !== undefined && !EXPORT_BASES.includes(scope.from)) {
427
+ return `'from' tiene que ser ${EXPORT_BASES.join(", ")}`;
428
+ }
429
+ for (const key of ["since", "source", "environment"]) {
225
430
  if (scope[key] !== undefined && typeof scope[key] !== "string") {
226
431
  return `'${key}' tiene que ser texto`;
227
432
  }
@@ -255,10 +460,15 @@ export function conflictingScopeFlags(echoed, flags) {
255
460
  if (flags.sessions !== undefined && !same(flags.sessions, echoed.sessions)) {
256
461
  conflicts.push("--sessions");
257
462
  }
463
+ if (flags.exclude !== undefined && !same(flags.exclude, echoed.exclude)) {
464
+ conflicts.push("--exclude");
465
+ }
258
466
  for (const [flag, key] of [
259
467
  ["--since", "since"],
260
468
  ["--source", "source"],
261
469
  ["--date", "date"],
470
+ ["--from", "from"],
471
+ ["--environment", "environment"],
262
472
  ]) {
263
473
  if (flags[key] !== undefined && flags[key] !== echoed[key])
264
474
  conflicts.push(flag);
@@ -367,9 +577,15 @@ export async function applyExport(fs, env, paths, input) {
367
577
  const artifacts = (parsed.value.artifacts ?? []).map((artifact) => renumber(artifact, input.prepared, minted, policy));
368
578
  // Whole dossier or nothing: `publishArtifacts` restores every previous
369
579
  // state on the first failure.
370
- return await publishArtifacts(fs, paths.workspaceDir(), artifacts, {
580
+ const published = await publishArtifacts(fs, paths.workspaceDir(), artifacts, {
371
581
  overwrite: input.allowOverwrite === true,
372
582
  });
583
+ // Under the SAME lock as the write, for the same reason as in `persist`: two
584
+ // concurrent publications outside it would lose one of the two rows.
585
+ if (published.ok) {
586
+ await appendPublications(fs, paths.cwdHistoryFile(), publicationRows(published.value.written, `export-${input.prepared.category}`));
587
+ }
588
+ return published;
373
589
  });
374
590
  if ("error" in result) {
375
591
  return {