pm-csv 2026.10.28 → 2026.10.30

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 (44) hide show
  1. package/README.md +86 -2
  2. package/dist/admission.d.ts +93 -0
  3. package/dist/admission.d.ts.map +1 -0
  4. package/dist/admission.js +362 -0
  5. package/dist/admission.js.map +1 -0
  6. package/dist/atomic-update.d.ts.map +1 -1
  7. package/dist/atomic-update.js +16 -14
  8. package/dist/atomic-update.js.map +1 -1
  9. package/dist/edit-refusal.d.ts +1 -1
  10. package/dist/edit-refusal.d.ts.map +1 -1
  11. package/dist/index.d.ts +46 -8
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +364 -117
  14. package/dist/index.js.map +1 -1
  15. package/dist/join-plans.d.ts +110 -0
  16. package/dist/join-plans.d.ts.map +1 -0
  17. package/dist/join-plans.js +557 -0
  18. package/dist/join-plans.js.map +1 -0
  19. package/dist/lineage.d.ts +3 -6
  20. package/dist/lineage.d.ts.map +1 -1
  21. package/dist/lineage.js +32 -11
  22. package/dist/lineage.js.map +1 -1
  23. package/dist/mutation-intent.d.ts +57 -0
  24. package/dist/mutation-intent.d.ts.map +1 -0
  25. package/dist/mutation-intent.js +145 -0
  26. package/dist/mutation-intent.js.map +1 -0
  27. package/dist/roundtrip.d.ts +2 -8
  28. package/dist/roundtrip.d.ts.map +1 -1
  29. package/dist/roundtrip.js +78 -24
  30. package/dist/roundtrip.js.map +1 -1
  31. package/dist/settings-snapshot.d.ts +37 -0
  32. package/dist/settings-snapshot.d.ts.map +1 -0
  33. package/dist/settings-snapshot.js +128 -0
  34. package/dist/settings-snapshot.js.map +1 -0
  35. package/dist/table-schema.d.ts +10 -7
  36. package/dist/table-schema.d.ts.map +1 -1
  37. package/dist/table-schema.js +36 -12
  38. package/dist/table-schema.js.map +1 -1
  39. package/docs/import-admission.md +199 -0
  40. package/docs/join-plans.md +201 -0
  41. package/docs/join-validation.md +240 -0
  42. package/docs/spreadsheet-roundtrip.md +17 -2
  43. package/manifest.json +1 -1
  44. package/package.json +10 -6
package/dist/index.js CHANGED
@@ -1,12 +1,17 @@
1
+ import * as nativeSdk from "@unbrained/pm-cli/sdk";
1
2
  import { readFileSync, writeFileSync, createReadStream, mkdirSync } from "node:fs";
2
3
  import { basename, resolve } from "node:path";
3
4
  import { spawnSync } from "node:child_process";
4
5
  import { createHash } from "node:crypto";
5
- import { certifyCompleteListResult, inspectCompleteListResult, readSettingsWithMetadata, resolveRuntimeFieldRegistry, runWorkspaceTransactionGc } from "@unbrained/pm-cli/sdk";
6
+ import { certifyCompleteListResult, inspectCompleteListResult, listAllComplete, readSettingsWithMetadata, resolveRuntimeFieldRegistry, runWorkspaceTransactionGc, create as createNativeCsv, update as updateNativeCsv, resolveRuntimeStatusRegistry, isTerminalStatus } from "@unbrained/pm-cli/sdk";
7
+ import { captureSettings, inSettingsTransaction, normalizePinnedStatus, pinCreateDefaults, readPinnedSettings as readSettings, rethrowSettingsChange, settingsDigest, useSettings, verifySettings, withSettings, withSettingsFence } from "./settings-snapshot.js";
6
8
  import { importLineage, writeLineageBundle, LineageRefusal } from "./lineage.js";
7
9
  import { applyEditSession, exportEditSession, EditRefusal } from "./roundtrip.js";
10
+ import { buildMutationIntent, mutationScopeArguments, matchesMutationScope } from "./mutation-intent.js";
11
+ import { registerJoinCommands } from "./join-plans.js";
8
12
  import { registerTableSchemaCommands } from "./table-schema.js";
9
- import { captureUpdateCompensation, compensateUpdate, inspectUpdate, UPDATE_COMPENSATION_VERSION } from "./atomic-update.js";
13
+ import { admitCSV, AdmissionRefusal, admissionPreviewFailure, admissionSettingsChanged, admissionTargetRevision, admissionTargetSequence, assertCSVAdmission, findAdmissionFailure, normalizeAdmissionFilter, parseAdmissionOptions, readAdmittedSource, registerAdmissionCommand, requireAdmissionCapability } from "./admission.js";
14
+ import { captureUpdateCompensation, compensateUpdate, inspectUpdate, readUpdateDocument, UPDATE_COMPENSATION_VERSION } from "./atomic-update.js";
10
15
  // ---------------------------------------------------------------------------
11
16
  // Error contract
12
17
  // ---------------------------------------------------------------------------
@@ -31,10 +36,14 @@ const EXIT_CODE = {
31
36
  export class CommandError extends Error {
32
37
  /** Numeric exit code the runtime propagates to the shell. */
33
38
  exitCode;
34
- constructor(message, exitCode = EXIT_CODE.GENERIC_FAILURE) {
39
+ /** Optional stable category for snapshot-sensitive mutation refusals. */
40
+ code;
41
+ constructor(message, exitCode = EXIT_CODE.GENERIC_FAILURE, code) {
35
42
  super(message);
36
43
  this.name = "CommandError";
37
44
  this.exitCode = exitCode;
45
+ if (code)
46
+ this.code = code;
38
47
  }
39
48
  }
40
49
  // Columns accepted on import (order independent — driven by header row).
@@ -199,16 +208,6 @@ function serializeCSV(rows, delimiterOrOpts) {
199
208
  .map((row) => row.map((f) => serializeField(f, opts.delimiter)).join(opts.delimiter))
200
209
  .join(eol);
201
210
  }
202
- // ---------------------------------------------------------------------------
203
- // Streaming CSV parser — for large files that should not be held in memory.
204
- // Same RFC-4180 state machine as parseCSV, but fed incrementally so quoted
205
- // fields spanning chunk boundaries are handled correctly.
206
- // ---------------------------------------------------------------------------
207
- /**
208
- * Incremental CSV state machine. Feed text via {@link push} and call
209
- * {@link end} when the input is exhausted. Each complete row is emitted to the
210
- * `onRow` callback exactly as in {@link parseCSV}.
211
- */
212
211
  class StreamingCSVParser {
213
212
  delimiter;
214
213
  onRow;
@@ -217,9 +216,27 @@ class StreamingCSVParser {
217
216
  inQuotes = false;
218
217
  recordStarted = false;
219
218
  pendingBoundaryChar = "";
220
- constructor(delimiter, onRow) {
219
+ limits;
220
+ recordCodeUnits = 0;
221
+ constructor(delimiter, onRow, limits) {
221
222
  this.delimiter = delimiter;
222
223
  this.onRow = onRow;
224
+ this.limits = limits;
225
+ }
226
+ /** Bound allocations before appending even an unterminated adversarial field. */
227
+ appendField(value) {
228
+ if (this.limits && (this.field.length + value.length > this.limits.maxFieldCodeUnits ||
229
+ this.recordCodeUnits + value.length > this.limits.maxRecordCodeUnits))
230
+ throw new RangeError("csv_record_limit");
231
+ this.field += value;
232
+ this.recordCodeUnits += value.length;
233
+ }
234
+ /** Bound empty fields too, so delimiter-only input cannot grow an unbounded array. */
235
+ finishField() {
236
+ if (this.limits && this.row.length >= this.limits.maxColumns)
237
+ throw new RangeError("csv_column_limit");
238
+ this.row.push(this.field);
239
+ this.field = "";
223
240
  }
224
241
  push(text) {
225
242
  text = this.pendingBoundaryChar + text;
@@ -235,7 +252,7 @@ class StreamingCSVParser {
235
252
  return;
236
253
  }
237
254
  if (text[i + 1] === '"') {
238
- this.field += '"';
255
+ this.appendField('"');
239
256
  i += 2;
240
257
  continue;
241
258
  }
@@ -243,7 +260,7 @@ class StreamingCSVParser {
243
260
  i++;
244
261
  continue;
245
262
  }
246
- this.field += ch;
263
+ this.appendField(ch);
247
264
  i++;
248
265
  continue;
249
266
  }
@@ -253,16 +270,15 @@ class StreamingCSVParser {
253
270
  continue;
254
271
  }
255
272
  if (ch === this.delimiter) {
256
- this.row.push(this.field);
257
- this.field = "";
273
+ this.finishField();
258
274
  i++;
259
275
  continue;
260
276
  }
261
277
  if (ch === "\r" && text[i + 1] === "\n") {
262
- this.row.push(this.field);
263
- this.field = "";
278
+ this.finishField();
264
279
  this.onRow(this.row);
265
280
  this.row = [];
281
+ this.recordCodeUnits = 0;
266
282
  this.recordStarted = false;
267
283
  i += 2;
268
284
  continue;
@@ -272,15 +288,15 @@ class StreamingCSVParser {
272
288
  return;
273
289
  }
274
290
  if (ch === "\n") {
275
- this.row.push(this.field);
276
- this.field = "";
291
+ this.finishField();
277
292
  this.onRow(this.row);
278
293
  this.row = [];
294
+ this.recordCodeUnits = 0;
279
295
  this.recordStarted = false;
280
296
  i++;
281
297
  continue;
282
298
  }
283
- this.field += ch;
299
+ this.appendField(ch);
284
300
  i++;
285
301
  }
286
302
  }
@@ -289,15 +305,18 @@ class StreamingCSVParser {
289
305
  this.inQuotes = false;
290
306
  }
291
307
  else if (this.pendingBoundaryChar !== "") {
292
- this.field += this.pendingBoundaryChar;
308
+ this.appendField(this.pendingBoundaryChar);
293
309
  }
294
310
  this.pendingBoundaryChar = "";
311
+ if (this.limits && this.inQuotes)
312
+ throw new SyntaxError("csv_unterminated_quote");
295
313
  if (this.recordStarted) {
296
- this.row.push(this.field);
314
+ this.finishField();
297
315
  this.onRow(this.row);
298
316
  this.field = "";
299
317
  this.row = [];
300
318
  this.recordStarted = false;
319
+ this.recordCodeUnits = 0;
301
320
  }
302
321
  }
303
322
  }
@@ -339,7 +358,7 @@ function streamCSVFile(filePath, delimiter, encoding, onRow) {
339
358
  */
340
359
  function readBoolOption(options, ...keys) {
341
360
  for (const key of keys) {
342
- if (options[key] !== undefined)
361
+ if (Object.hasOwn(options, key) && options[key] !== undefined)
343
362
  return Boolean(options[key]);
344
363
  }
345
364
  return false;
@@ -491,7 +510,7 @@ function completeListArgs(pmRoot) {
491
510
  /** Narrow an unknown JSON value to a non-array record. */
492
511
  function asRecord(value) {
493
512
  return value !== null && typeof value === "object" && !Array.isArray(value)
494
- ? value
513
+ ? Object.assign(Object.create(null), value)
495
514
  : undefined;
496
515
  }
497
516
  /** Render a receipt value while preserving a missing field as evidence. */
@@ -656,9 +675,9 @@ function errorMessage(error) {
656
675
  * Example: `--map "Summary=title,Owner=tags"`.
657
676
  */
658
677
  function parseFieldMap(spec) {
659
- const map = {};
678
+ const map = Object.create(null);
660
679
  if (spec === undefined)
661
- return map;
680
+ return { ...map };
662
681
  const parts = (Array.isArray(spec) ? spec : [spec]).flatMap((s) => s.split(","));
663
682
  for (const part of parts) {
664
683
  const trimmed = part.trim();
@@ -675,14 +694,14 @@ function parseFieldMap(spec) {
675
694
  }
676
695
  map[from] = to;
677
696
  }
678
- return map;
697
+ return { ...map };
679
698
  }
680
699
  /**
681
700
  * Apply a field map to a list of header names, producing the effective
682
701
  * (canonical) header used for column lookup.
683
702
  */
684
703
  function applyFieldMap(headers, fieldMap) {
685
- return headers.map((h) => fieldMap[h] ?? h);
704
+ return headers.map((h) => Object.hasOwn(fieldMap, h) ? fieldMap[h] : h);
686
705
  }
687
706
  /**
688
707
  * Compute a simple Levenshtein edit distance between two lowercase strings.
@@ -788,9 +807,9 @@ const AUTO_MAP_ALIASES = {
788
807
  * alias mappings and never overrides an already-claimed canonical field.
789
808
  */
790
809
  function resolveImportFieldMap(headers, explicitMap, autoMap) {
791
- const fieldMap = { ...explicitMap };
810
+ const fieldMap = Object.assign(Object.create(null), explicitMap);
792
811
  if (!autoMap || headers.length === 0)
793
- return { fieldMap, autoMappings: [] };
812
+ return { fieldMap: { ...fieldMap }, autoMappings: [] };
794
813
  const headerCounts = new Map();
795
814
  for (const header of headers) {
796
815
  headerCounts.set(header, (headerCounts.get(header) ?? 0) + 1);
@@ -813,22 +832,25 @@ function resolveImportFieldMap(headers, explicitMap, autoMap) {
813
832
  claimedTargets.add(target);
814
833
  autoMappings.push({ from, to: target });
815
834
  }
816
- return { fieldMap, autoMappings };
835
+ return { fieldMap: { ...fieldMap }, autoMappings };
817
836
  }
818
837
  function formatAutoMappings(mappings) {
819
838
  return mappings.map((m) => `${m.from}->${m.to}`).join(", ");
820
839
  }
821
840
  function autoMappingsToRecord(mappings) {
822
- const record = {};
841
+ const record = Object.create(null);
823
842
  for (const m of mappings)
824
843
  record[m.from] = m.to;
825
- return record;
844
+ return { ...record };
826
845
  }
827
846
  /**
828
847
  * Map an arbitrary status string (from the CSV) to a valid SDK status.
829
848
  * Falls back to "open".
830
849
  */
831
850
  function normalizeStatus(raw) {
851
+ const pinned = normalizePinnedStatus(raw);
852
+ if (pinned !== undefined)
853
+ return pinned;
832
854
  const s = raw.trim().toLowerCase();
833
855
  const map = {
834
856
  open: "open",
@@ -849,7 +871,7 @@ function normalizeStatus(raw) {
849
871
  cancelled: "canceled",
850
872
  draft: "draft",
851
873
  };
852
- return map[s] ?? "open";
874
+ return Object.hasOwn(map, s) ? map[s] : "open";
853
875
  }
854
876
  /**
855
877
  * Parse a comma-separated tags string into an array, stripping whitespace.
@@ -885,10 +907,11 @@ function resolveEncoding(raw) {
885
907
  * Read rows from a CSV file, returning header and data rows separately.
886
908
  * Skips fully-empty rows. Decodes with the given encoding (default utf-8).
887
909
  */
888
- function readCSVFile(filePath, delimiter, encoding = "utf-8", skipHeaders = false) {
910
+ function readCSVFile(filePath, delimiter, encoding = "utf-8", skipHeaders = false, admittedDigest, admittedMaxBytes) {
889
911
  // Node's BufferEncoding spells utf-8 as "utf8"; normalize.
890
912
  const bufEnc = encoding === "utf-8" ? "utf8" : encoding;
891
- const text = stripBOM(readFileSync(filePath, bufEnc));
913
+ const bytes = admittedDigest === undefined ? readFileSync(filePath) : readAdmittedSource(filePath, admittedDigest, admittedMaxBytes);
914
+ const text = stripBOM(bytes.toString(bufEnc));
892
915
  const rows = parseCSV(text, delimiter).filter((r) => r.some((f) => f.trim() !== ""));
893
916
  if (rows.length === 0) {
894
917
  return { headers: [], dataRows: [] };
@@ -931,13 +954,13 @@ function rowFields(headers, row) {
931
954
  get,
932
955
  parsed: {
933
956
  title: get("title"),
934
- status: rawStatus ? normalizeStatus(rawStatus) : "open",
957
+ status: rawStatus ? normalizeStatus(rawStatus) : undefined,
935
958
  priority: priority !== undefined && !isNaN(priority) ? priority : undefined,
936
959
  tags: parseTags(get("tags")),
937
960
  type: get("type") || undefined,
938
961
  // pm has no milestone/due_date fields; map deadline (accept legacy header).
939
962
  deadline: get("deadline") || get("due_date") || undefined,
940
- body: get("body") || undefined,
963
+ body: get("body") ? row[col("body")] : undefined,
941
964
  parent: get("parent") || undefined,
942
965
  assignee: get("assignee") || undefined,
943
966
  sprint: get("sprint") || undefined,
@@ -977,10 +1000,10 @@ function parseImportFilter(statusRaw, typeRaw, priorityRaw) {
977
1000
  * Pure predicate: does a parsed row satisfy every set filter criterion?
978
1001
  * Unset criteria are wildcards. Exposed for unit testing.
979
1002
  */
980
- function rowMatchesFilter(row, filter) {
1003
+ function rowMatchesFilter(row, filter, defaultStatus = "open") {
981
1004
  if (!filter)
982
1005
  return true;
983
- if (filter.status !== undefined && row.status !== filter.status)
1006
+ if (filter.status !== undefined && (row.status ?? defaultStatus) !== filter.status)
984
1007
  return false;
985
1008
  if (filter.type !== undefined && (row.type ?? "").toLowerCase() !== filter.type)
986
1009
  return false;
@@ -995,7 +1018,6 @@ function rowMatchesFilter(row, filter) {
995
1018
  * creates — so the envelope's receipt is checked before any key is read.
996
1019
  */
997
1020
  function loadKeyIndex(pmRoot, spawn = spawnPm) {
998
- const index = new Map();
999
1021
  const result = spawn(completeListArgs(pmRoot), { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
1000
1022
  // Route a stdout overrun (status: null, empty stderr, ENOBUFS) through
1001
1023
  // assertPmOutputFit as a hard, named error instead of the generic
@@ -1025,6 +1047,11 @@ function loadKeyIndex(pmRoot, spawn = spawnPm) {
1025
1047
  + "which would make --upsert create duplicates.");
1026
1048
  }
1027
1049
  items = assertListAllComplete(parsed);
1050
+ return csvKeyIndex(items);
1051
+ }
1052
+ /** Build only the package-owned provenance-key index from a certified native read. */
1053
+ function csvKeyIndex(items) {
1054
+ const index = new Map();
1028
1055
  for (const item of items) {
1029
1056
  for (const tag of item.tags ?? []) {
1030
1057
  if (tag.startsWith(KEY_TAG_PREFIX)) {
@@ -1060,6 +1087,7 @@ function processImportRow(pmRoot, headers, row, lineNo, opts, keyIndex, result)
1060
1087
  result.previews.push({
1061
1088
  action: existingId ? "update" : "create",
1062
1089
  ...parsed,
1090
+ status: parsed.status ?? (existingId ? undefined : "open"),
1063
1091
  ...(opts.keyField ? { key: keyValue } : {}),
1064
1092
  ...(opts.source ? { csv_source: opts.source } : {}),
1065
1093
  });
@@ -1122,8 +1150,8 @@ function resolveImportHeaders(rawHeaders, opts) {
1122
1150
  return { headers, autoMappings: mapResolution.autoMappings, fieldMapWarnings };
1123
1151
  }
1124
1152
  /** Read and resolve the shared in-memory prelude for normal and atomic imports. */
1125
- function prepareInMemoryImport(filePath, opts) {
1126
- const { headers: rawHeaders, dataRows } = readCSVFile(filePath, opts.delimiter, opts.encoding ?? "utf-8", opts.skipHeaders ?? false);
1153
+ function prepareInMemoryImport(filePath, opts, admittedDigest) {
1154
+ const { headers: rawHeaders, dataRows } = readCSVFile(filePath, opts.delimiter, opts.encoding ?? "utf-8", opts.skipHeaders ?? false, admittedDigest, opts.admission?.maxAtomicBytes);
1127
1155
  return {
1128
1156
  rawHeaders,
1129
1157
  dataRows,
@@ -1153,7 +1181,21 @@ function processImportRows(pmRoot, dataRows, headers, opts, result) {
1153
1181
  * @returns The import outcome; rejects with a {@link CommandError} on a usage
1154
1182
  * conflict or an unreadable file.
1155
1183
  */
1156
- function importCSV(pmRoot, filePath, opts) {
1184
+ async function importCSV(pmRoot, filePath, opts) {
1185
+ if (opts.admission !== undefined) {
1186
+ if (!opts.atomic || opts.stream)
1187
+ throw new AdmissionRefusal("csv_admission_options", "CSV admission apply requires --atomic without --stream.");
1188
+ const admission = parseAdmissionOptions({ ...parseAdmissionOptions(opts.admission), mode: "full", delimiter: opts.delimiter,
1189
+ fieldMap: opts.fieldMap, autoMap: opts.autoMap ?? false, encoding: opts.encoding ?? "utf-8", skipHeaders: opts.skipHeaders ?? false, filter: opts.filter });
1190
+ const captured = { ...opts, delimiter: admission.delimiter, fieldMap: admission.fieldMap, encoding: admission.encoding,
1191
+ autoMap: admission.autoMap, skipHeaders: admission.skipHeaders, filter: admission.filter, admission };
1192
+ return withSettings(pmRoot, admissionSettingsChanged, async () => {
1193
+ captured.filter = normalizeAdmissionFilter(admission.filter, await readSettings(pmRoot));
1194
+ const receipt = await admitCSV(pmRoot, filePath, admission);
1195
+ assertCSVAdmission(receipt);
1196
+ return importCSVAtomic(pmRoot, filePath, captured, receipt.digest);
1197
+ });
1198
+ }
1157
1199
  if (opts.atomic && opts.stream) {
1158
1200
  return Promise.reject(new CommandError("--atomic cannot be combined with --stream: an unbounded stream cannot be committed as one bounded transaction.", EXIT_CODE.USAGE));
1159
1201
  }
@@ -1236,9 +1278,9 @@ export function atomicTransactionId(filePath, opts = {}) {
1236
1278
  return deriveTransactionId(resolve(filePath), fingerprintContent(rawHeaders, dataRows));
1237
1279
  }
1238
1280
  /** Bind a CSV step to its format, operation, target, and original row. */
1239
- function atomicStepId(rowIndex, existingId) {
1240
- const operation = existingId ? `update-${existingId}` : "create";
1241
- return `csv-import-v${UPDATE_COMPENSATION_VERSION}-${operation}-row-${rowIndex}`;
1281
+ function atomicStepId(rowIndex, existingId, revision, creation) {
1282
+ const operation = existingId ? `update-${existingId}` : creation ? `create-${creation}` : "create";
1283
+ return `csv-import-v${UPDATE_COMPENSATION_VERSION}-${operation}-row-${rowIndex}${revision === undefined ? "" : `-revision-${revision}`}`;
1242
1284
  }
1243
1285
  /**
1244
1286
  * Recover the original plan's update targets and create roles from SDK journal
@@ -1249,30 +1291,42 @@ function atomicStepId(rowIndex, existingId) {
1249
1291
  * The public read-only inventory supplies the path, including after SDK layout
1250
1292
  * changes. Infinite retention and dry-run prevent recovery receipts being pruned.
1251
1293
  */
1252
- async function loadAtomicBindings(pmRoot, transactionId) {
1294
+ export async function loadAtomicBindings(pmRoot, transactionId) {
1253
1295
  const bindings = new Map();
1254
1296
  const inventory = await runWorkspaceTransactionGc(pmRoot, { dryRun: true, retentionDays: Infinity });
1255
1297
  const entry = inventory.entries.find(candidate => basename(candidate.file) === `${transactionId}.json`);
1256
1298
  if (!entry)
1257
- return bindings;
1258
- const journal = JSON.parse(readFileSync(resolve(pmRoot, entry.file), "utf8"));
1299
+ return { bindings };
1300
+ let journal;
1301
+ try {
1302
+ journal = JSON.parse(readFileSync(resolve(pmRoot, entry.file), "utf8"));
1303
+ }
1304
+ catch {
1305
+ throw new CommandError("The recorded atomic journal cannot be read. Preserve its audit and reconcile the original attempt before retrying or previewing this import.", EXIT_CODE.USAGE);
1306
+ }
1259
1307
  if (journal === null || typeof journal !== "object" || Array.isArray(journal)
1260
- || !("stepIds" in journal) || !Array.isArray(journal.stepIds)
1308
+ || !Object.hasOwn(journal, "stepIds") || !("stepIds" in journal) || !Array.isArray(journal.stepIds)
1261
1309
  || !journal.stepIds.every((id) => typeof id === "string")) {
1262
1310
  throw new CommandError(`Workspace transaction ${transactionId} journal does not match the supplied plan: incompatible CSV step identity.`);
1263
1311
  }
1264
- if (!("schemaVersion" in journal) || journal.schemaVersion !== 1
1265
- || !("transactionId" in journal) || journal.transactionId !== transactionId) {
1312
+ if (!Object.hasOwn(journal, "schemaVersion") || !("schemaVersion" in journal) || journal.schemaVersion !== 1
1313
+ || !Object.hasOwn(journal, "transactionId") || !("transactionId" in journal) || journal.transactionId !== transactionId) {
1266
1314
  throw new CommandError(`Workspace transaction ${transactionId} journal does not match the supplied plan: incompatible CSV journal identity. Refusing before transaction coordination.`);
1267
1315
  }
1268
- const pattern = new RegExp(String.raw `^csv-import-v${UPDATE_COMPENSATION_VERSION}-(?:create|update-(.+))-row-(\d+)$`);
1316
+ const revisions = new Map();
1317
+ const creations = new Map();
1318
+ const pattern = new RegExp(String.raw `^csv-import-v${UPDATE_COMPENSATION_VERSION}-(?:create(?:-(.+))?|update-(.+))-row-(\d+)(?:-revision-(absent|[a-f0-9]{64}))?$`);
1269
1319
  for (const id of journal.stepIds) {
1270
1320
  const match = pattern.exec(id);
1271
1321
  if (!match)
1272
1322
  throw new CommandError(`Workspace transaction ${transactionId} journal does not match the supplied plan: incompatible CSV step identity.`);
1273
- bindings.set(Number(match[2]), match[1]);
1323
+ bindings.set(Number(match[3]), match[2]);
1324
+ if (match[4] !== undefined)
1325
+ revisions.set(Number(match[3]), match[4]);
1326
+ if (match[1] !== undefined)
1327
+ creations.set(Number(match[3]), match[1]);
1274
1328
  }
1275
- return bindings;
1329
+ return { bindings, status: entry.status, ...(revisions.size ? { revisions } : {}), ...(creations.size ? { creations } : {}) };
1276
1330
  }
1277
1331
  /**
1278
1332
  * Load every item stamped with this transaction's per-row ownership marker
@@ -1305,7 +1359,6 @@ async function loadAtomicBindings(pmRoot, transactionId) {
1305
1359
  * compensateCreate.)
1306
1360
  */
1307
1361
  function loadAppliedByTransaction(pmRoot, transactionId, spawn = spawnPm) {
1308
- const byRowIndex = new Map();
1309
1362
  const rowMarkerPrefix = `${TX_ROW_TAG_PREFIX}${transactionId}${TX_ROW_TAG_SEPARATOR}`;
1310
1363
  const r = spawn(completeListArgs(pmRoot), {
1311
1364
  encoding: "utf-8",
@@ -1343,6 +1396,11 @@ function loadAppliedByTransaction(pmRoot, transactionId, spawn = spawnPm) {
1343
1396
  // resume scan that misses applied rows silently re-imports them (wrong
1344
1397
  // data, the exact failure this package exists to prevent).
1345
1398
  items = assertListAllComplete(parsed);
1399
+ return csvAppliedIndex(items, rowMarkerPrefix);
1400
+ }
1401
+ /** Share per-row provenance interpretation between legacy CLI and admitted SDK reads. */
1402
+ function csvAppliedIndex(items, rowMarkerPrefix) {
1403
+ const byRowIndex = new Map();
1346
1404
  for (const item of items) {
1347
1405
  for (const tag of item.tags ?? []) {
1348
1406
  if (!tag.startsWith(rowMarkerPrefix))
@@ -1446,7 +1504,7 @@ function compensateCreate(pmRoot, id, markers = [], reason = "atomic csv import
1446
1504
  }
1447
1505
  }
1448
1506
  if (markers.length > 0) {
1449
- const markerCleanup = spawnSync("pm", ["--path", pmRoot, "update", id, "--remove-tags", markers.join(",")], { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
1507
+ const markerCleanup = spawnSync("pm", ["--path", pmRoot, "update", id, "--remove-tags", markers.join(","), ...mutationScopeArguments({}, "patch")], { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
1450
1508
  if (markerCleanup.status !== 0) {
1451
1509
  console.error(`atomic import: compensation marker cleanup failed for ${id}: ${markerCleanup.stderr?.trim() || markerCleanup.stdout?.trim()}`);
1452
1510
  }
@@ -1455,7 +1513,7 @@ function compensateCreate(pmRoot, id, markers = [], reason = "atomic csv import
1455
1513
  /**
1456
1514
  * Atomic in-memory import: every row that would CREATE an item becomes one
1457
1515
  * {@link WorkspaceTransactionStep} committed under a single workspace
1458
- * writer-locked, crash-recoverable transaction. On success the same
1516
+ * SDK-coordinated, crash-recoverable transaction. On success the same
1459
1517
  * {@link ImportResult} as the non-atomic path is returned (counts derived from
1460
1518
  * the committed step results). On failure the coordinator attempts reverse-
1461
1519
  * order compensation, but callers receive a conservative reconciliation
@@ -1463,35 +1521,49 @@ function compensateCreate(pmRoot, id, markers = [], reason = "atomic csv import
1463
1521
  * prevent safe update compensation. An interrupted run resumes from the
1464
1522
  * durable journal (inspect() skips already applied rows).
1465
1523
  */
1466
- async function importCSVAtomic(pmRoot, filePath, opts) {
1467
- const { rawHeaders, dataRows, result, resolvedHeaders } = prepareInMemoryImport(filePath, opts);
1524
+ async function importCSVAtomicInternal(pmRoot, filePath, opts, prepared, bindingSnapshot) {
1525
+ const { rawHeaders, dataRows, result, resolvedHeaders } = prepared;
1468
1526
  if (!resolvedHeaders)
1469
1527
  return result;
1470
1528
  result.fieldMapWarnings = resolvedHeaders.fieldMapWarnings;
1471
1529
  result.autoMappings = resolvedHeaders.autoMappings;
1472
1530
  // A dry-run atomic import just previews like the non-atomic path (no
1473
1531
  // transaction is committed, nothing is written).
1474
- if (opts.dryRun) {
1532
+ if (opts.dryRun && opts.admission === undefined) {
1533
+ const existing = await loadAtomicBindings(pmRoot, deriveTransactionId(resolve(filePath), fingerprintContent(rawHeaders, dataRows)));
1534
+ if (existing.status === "compensating")
1535
+ throw new CommandError("Pending atomic compensation must be recovered with the original import options before previewing new work; dry-run did not modify the journal.", EXIT_CODE.USAGE);
1475
1536
  processImportRows(pmRoot, dataRows, resolvedHeaders.headers, opts, result);
1476
1537
  return result;
1477
1538
  }
1478
1539
  const commitTransaction = opts.commitTransaction;
1479
- if (!commitTransaction) {
1540
+ if (!commitTransaction && !opts.dryRun) {
1480
1541
  throw new CommandError("--atomic requires the host-injected commitWorkspaceTransaction SDK primitive. Upgrade the pm CLI and invoke pm-csv through the pm host.", EXIT_CODE.USAGE);
1481
1542
  }
1482
1543
  // Fingerprint the exact parsed content (raw headers + all data rows) so that
1483
1544
  // editing the file in place yields a fresh transaction id instead of reusing
1484
1545
  // stale per-row markers from a previous import of different content.
1485
- const transactionId = deriveTransactionId(resolve(filePath), fingerprintContent(rawHeaders, dataRows));
1546
+ const admissionPreview = opts.admission === undefined ? undefined : requireAdmissionCapability(nativeSdk.previewItemMutations);
1547
+ const transactionId = opts.admission === undefined ? deriveTransactionId(resolve(filePath), fingerprintContent(rawHeaders, dataRows))
1548
+ : `csv-import-${requireAdmissionCapability(nativeSdk.deriveMutationTransactionId)(JSON.stringify([
1549
+ "admitted-v1", resolve(filePath), rawHeaders, dataRows, Object.entries(opts.fieldMap).sort(), opts.autoMap, opts.keyField ?? "",
1550
+ [opts.filter?.status, opts.filter?.type, opts.filter?.priority], opts.skipHeaders, opts.source ?? "", settingsDigest(await readSettings(pmRoot)),
1551
+ ]))}`;
1486
1552
  const author = opts.atomicAuthor ?? "pm-csv";
1553
+ let rollback;
1487
1554
  const ownershipTag = `${TX_TAG_PREFIX}${transactionId}`;
1488
1555
  // Per-row ownership marker source of truth: `csv-txrow:<transactionId>#<rowIndex>`.
1489
1556
  const rowTagFor = (rowIndex) => `${TX_ROW_TAG_PREFIX}${transactionId}${TX_ROW_TAG_SEPARATOR}${rowIndex}`;
1490
1557
  // Detect rows a prior interrupted run already applied (resumability) plus
1491
1558
  // the dedup index for --key upsert decisions, both from one fresh scan.
1492
- const bindings = await loadAtomicBindings(pmRoot, transactionId);
1493
- const applied = loadAppliedByTransaction(pmRoot, transactionId);
1494
- const keyIndex = opts.keyField ? loadKeyIndex(pmRoot) : new Map();
1559
+ const { bindings, status: journalStatus, revisions, creations } = bindingSnapshot ?? await loadAtomicBindings(pmRoot, transactionId);
1560
+ if (opts.admission !== undefined && opts.dryRun && journalStatus === "compensating") {
1561
+ throw new AdmissionRefusal("csv_admission_target_changed", "Recover the retained atomic compensation with the original options before previewing this import; dry-run did not modify the journal.");
1562
+ }
1563
+ const admittedItems = opts.admission === undefined ? undefined : certifyCompleteListResult(await listAllComplete({}, { pmRoot })).items;
1564
+ const applied = admittedItems === undefined ? loadAppliedByTransaction(pmRoot, transactionId)
1565
+ : csvAppliedIndex(admittedItems, `${TX_ROW_TAG_PREFIX}${transactionId}${TX_ROW_TAG_SEPARATOR}`);
1566
+ const keyIndex = admittedItems === undefined ? (opts.keyField ? loadKeyIndex(pmRoot) : new Map()) : csvKeyIndex(admittedItems);
1495
1567
  // In-batch duplicate-KEY guard: keys already claimed by an earlier planned
1496
1568
  // CREATE in THIS run (the keyIndex is not updated during planning, so
1497
1569
  // without this guard two rows sharing a not-yet-existing key would both
@@ -1499,6 +1571,8 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
1499
1571
  // claimed key is skipped with a clear per-row warning (see README).
1500
1572
  const claimedKeys = new Map();
1501
1573
  const planned = [];
1574
+ const planningSettings = await readSettings(pmRoot);
1575
+ const defaultStatus = opts.admission === undefined ? undefined : resolveRuntimeStatusRegistry(planningSettings.schema).open_status;
1502
1576
  for (let rowIndex = 0; rowIndex < dataRows.length; rowIndex++) {
1503
1577
  const lineNo = opts.skipHeaders ? rowIndex + 1 : rowIndex + 2;
1504
1578
  const row = dataRows[rowIndex];
@@ -1510,7 +1584,7 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
1510
1584
  result.skipped++;
1511
1585
  continue;
1512
1586
  }
1513
- if (!rowMatchesFilter(parsed, opts.filter)) {
1587
+ if (!rowMatchesFilter(parsed, opts.filter, defaultStatus)) {
1514
1588
  result.skipped++;
1515
1589
  result.filtered++;
1516
1590
  continue;
@@ -1531,7 +1605,89 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
1531
1605
  : normalizedKey ? keyIndex.get(normalizedKey) : undefined;
1532
1606
  if (normalizedKey && !existingId)
1533
1607
  claimedKeys.set(normalizedKey, rowIndex);
1534
- planned.push({ rowIndex, lineNo, parsed, keyValue, existingId, isUpdate: Boolean(existingId) });
1608
+ const revision = opts.admission === undefined ? undefined : revisions?.get(rowIndex) ??
1609
+ (existingId ? await admissionTargetRevision(pmRoot, existingId) : "absent");
1610
+ const creation = revision === undefined || existingId ? undefined : nativeSdk.normalizeItemId(requireAdmissionCapability(nativeSdk.deriveMutationTransactionId)(`${transactionId}:${rowIndex}`), (await readSettings(pmRoot)).id_prefix);
1611
+ if (creations?.has(rowIndex) && creations.get(rowIndex) !== creation)
1612
+ throw new AdmissionRefusal("csv_admission_target_changed", "A recorded creation identity differs from this admitted source domain. Reconcile the retained journal before applying.");
1613
+ planned.push({ rowIndex, lineNo, parsed, keyValue, existingId, isUpdate: Boolean(existingId), revision, creation });
1614
+ }
1615
+ if (opts.admission !== undefined) {
1616
+ await opts.onAdmissionBoundary?.("targets_read");
1617
+ if (bindings.size === 0) {
1618
+ const latest = await loadAtomicBindings(pmRoot, transactionId);
1619
+ if (latest.bindings.size > 0)
1620
+ return importCSVAtomicInternal(pmRoot, filePath, opts, { ...prepared, result: createImportResult() }, latest);
1621
+ }
1622
+ }
1623
+ // Recheck current scope before the SDK can reuse a committed journal receipt.
1624
+ if (bindings.size && (planned.length !== bindings.size || planned.some(row => !bindings.has(row.rowIndex)))) {
1625
+ throw new CommandError("An existing atomic CSV journal has a different recorded row domain. Resume with its original filters, mappings and key options to finish recovery; no new journal or successful empty receipt was produced.", EXIT_CODE.USAGE);
1626
+ }
1627
+ // In particular, legacy creates may carry an unreviewed focus-inherited parent.
1628
+ const comparisonSettings = planningSettings;
1629
+ const sequences = new Map();
1630
+ for (const row of planned)
1631
+ if (row.revision !== undefined && row.isUpdate) {
1632
+ const rows = sequences.get(row.existingId) ?? [];
1633
+ rows.push(row);
1634
+ sequences.set(row.existingId, rows);
1635
+ }
1636
+ const sequenceHeads = new Map();
1637
+ /** Share ordered target progress between preview, durable step inspection and per-write revision guards. */
1638
+ const admittedRowState = async (row) => {
1639
+ const sequence = sequences.get(row.existingId);
1640
+ if (sequence) {
1641
+ const state = await admissionTargetSequence(pmRoot, row.existingId, row.revision, sequence.map(entry => rowTagFor(entry.rowIndex)));
1642
+ sequenceHeads.set(row.existingId, state.revision);
1643
+ return { revision: state.revision, applied: state.completed >= sequence.indexOf(row) };
1644
+ }
1645
+ const revision = await admissionTargetRevision(pmRoot, row.existingId ?? row.creation, row.revision, rowTagFor(row.rowIndex));
1646
+ return { revision, applied: revision !== row.revision };
1647
+ };
1648
+ const admittedApplied = new Set();
1649
+ for (const row of planned) {
1650
+ const owned = applied.byRowIndex.get(row.rowIndex);
1651
+ if (row.revision !== undefined) {
1652
+ if ((await admittedRowState(row)).applied)
1653
+ admittedApplied.add(row.rowIndex);
1654
+ }
1655
+ if (!owned || row.isUpdate)
1656
+ continue;
1657
+ const document = await readUpdateDocument(pmRoot, owned);
1658
+ if (!matchesMutationScope(document, { parent: row.parsed.parent, assignee: row.parsed.assignee,
1659
+ sprint: row.parsed.sprint, release: row.parsed.release, blockedBy: row.parsed.blocked_by,
1660
+ ...(!row.parsed.blocked_by ? { dep: [] } : {}) }, comparisonSettings, undefined, "patch")) {
1661
+ throw new CommandError("Atomic CSV replay scope differs from the reviewed rows. Reconcile current parent, assignment, planning and dependency fields before importing again.", EXIT_CODE.USAGE);
1662
+ }
1663
+ }
1664
+ if (opts.admission !== undefined) {
1665
+ await verifySettings(pmRoot);
1666
+ const pending = planned.filter(row => !admittedApplied.has(row.rowIndex)).map(row => ({
1667
+ op: row.isUpdate ? "update" : "create", id: row.existingId ?? row.creation,
1668
+ options: nativeCsvOptions(row.parsed, comparisonSettings, row.isUpdate, row.isUpdate ? undefined : row.keyValue, opts.source, [ownershipTag, rowTagFor(row.rowIndex)]),
1669
+ }));
1670
+ if (pending.length) {
1671
+ try {
1672
+ await admissionPreview({ pmRoot, author, transactionId: `csv-admit-preview-${transactionId}`, mutations: pending });
1673
+ }
1674
+ catch (error) {
1675
+ throw admissionPreviewFailure(error);
1676
+ }
1677
+ }
1678
+ }
1679
+ if (opts.dryRun) {
1680
+ for (const row of planned) {
1681
+ result.previews.push({ action: admittedApplied.has(row.rowIndex) ? "unchanged" : row.isUpdate ? "update" : "create",
1682
+ ...nativeCsvOptions(row.parsed, comparisonSettings, row.isUpdate, row.isUpdate ? undefined : row.keyValue, opts.source, [ownershipTag, rowTagFor(row.rowIndex)]) });
1683
+ if (admittedApplied.has(row.rowIndex))
1684
+ continue;
1685
+ if (row.isUpdate)
1686
+ result.updated++;
1687
+ else
1688
+ result.imported++;
1689
+ }
1690
+ return result;
1535
1691
  }
1536
1692
  // Build one transaction step per row to be written (create or update).
1537
1693
  // - create: apply() shells out to the same `pm create` as the non-atomic
@@ -1550,12 +1706,16 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
1550
1706
  // `state: "applied"` for items created during THIS run — not just for items
1551
1707
  // left over from a prior interrupted run (which the pre-run `applied` lookup
1552
1708
  // already detects).
1709
+ const wrote = new Set();
1553
1710
  const steps = planned.map((row) => {
1554
- const stepId = atomicStepId(row.rowIndex, row.existingId);
1711
+ const stepId = atomicStepId(row.rowIndex, row.existingId, row.revision, row.creation);
1555
1712
  const rowTag = rowTagFor(row.rowIndex);
1556
1713
  // Set by apply() in this run; undefined before that or on a fresh resume.
1557
1714
  let appliedId;
1558
1715
  const inspect = async () => {
1716
+ if (row.revision !== undefined) {
1717
+ return (await admittedRowState(row)).applied ? { state: "applied", result: row.existingId ?? row.creation } : { state: "pending" };
1718
+ }
1559
1719
  if (row.isUpdate) {
1560
1720
  return inspectUpdate(pmRoot, row.existingId, rowTag, row.parsed.status === "closed" || row.parsed.status === "canceled");
1561
1721
  }
@@ -1570,8 +1730,26 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
1570
1730
  return { state: "applied", result: id };
1571
1731
  return { state: "pending" };
1572
1732
  };
1573
- const apply = async () => {
1733
+ const apply = async () => inSettingsTransaction(pmRoot, () => withSettingsFence(pmRoot, async () => {
1574
1734
  const ownershipTags = [ownershipTag, rowTag];
1735
+ if (row.revision !== undefined) {
1736
+ await opts.onAdmissionBoundary?.("before_write", row.existingId ?? row.creation);
1737
+ await verifySettings(pmRoot);
1738
+ await admissionTargetRevision(pmRoot, row.existingId ?? row.creation, sequenceHeads.get(row.existingId) ?? row.revision);
1739
+ if (!row.isUpdate && opts.keyField && row.keyValue && csvKeyIndex(certifyCompleteListResult(await listAllComplete({}, { pmRoot })).items).has(normalizeKeyValue(row.keyValue))) {
1740
+ throw new AdmissionRefusal("csv_admission_target_changed", "A key appeared after admission planning. Re-read current SDK state and preview a fresh import before writing.");
1741
+ }
1742
+ const options = nativeCsvOptions(row.parsed, comparisonSettings, row.isUpdate, row.isUpdate ? undefined : row.keyValue, opts.source, ownershipTags);
1743
+ if (row.isUpdate)
1744
+ await updateNativeCsv(row.existingId, { ...options, author, message: `${rowTag}:apply` }, { pmRoot });
1745
+ else
1746
+ await createNativeCsv({ ...options, id: row.creation, author, message: `${rowTag}:create` }, { pmRoot });
1747
+ appliedId = row.existingId ?? row.creation;
1748
+ wrote.add(row.rowIndex);
1749
+ if (sequences.has(row.existingId))
1750
+ await admittedRowState(row);
1751
+ return appliedId;
1752
+ }
1575
1753
  if (row.isUpdate) {
1576
1754
  upsertUpdate(pmRoot, row.existingId, row.parsed, opts.source, ownershipTags, `${rowTag}:apply`);
1577
1755
  appliedId = row.existingId;
@@ -1580,28 +1758,53 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
1580
1758
  const newId = upsertCreate(pmRoot, row.parsed, opts.keyField ? row.keyValue : undefined, opts.source, ownershipTags);
1581
1759
  appliedId = newId;
1582
1760
  return newId;
1583
- };
1584
- const prepareCompensation = async () => row.isUpdate ? captureUpdateCompensation(pmRoot, row.existingId, rowTag, author) : undefined;
1761
+ }));
1762
+ const prepareCompensation = async () => row.isUpdate ? inSettingsTransaction(pmRoot, () => withSettingsFence(pmRoot, () => captureUpdateCompensation(pmRoot, row.existingId, rowTag, author))) : undefined;
1585
1763
  const compensate = async (data) => {
1586
- if (row.isUpdate) {
1587
- await compensateUpdate(pmRoot, row.existingId, rowTag, author, data);
1588
- return;
1589
- }
1590
- // Prefer the id captured by apply() in this run; fall back to the
1591
- // pre-run per-row lookup (resume-compensation of a prior run's applied step).
1592
- const id = appliedId ?? applied.byRowIndex.get(row.rowIndex);
1593
- // Strip both markers so the rolled-back row is no longer recognized as
1594
- // applied on a retry (marker-presence — not status — is the applied signal).
1595
- if (typeof id === "string" && id)
1596
- compensateCreate(pmRoot, id, [rowTag, ownershipTag]);
1764
+ rollback ??= captureSettings(pmRoot, opts.admission === undefined ? importSettingsChanged : admissionSettingsChanged);
1765
+ const snapshot = await rollback;
1766
+ return inSettingsTransaction(pmRoot, () => useSettings(pmRoot, snapshot, () => withSettingsFence(pmRoot, async () => {
1767
+ if (row.isUpdate) {
1768
+ await compensateUpdate(pmRoot, row.existingId, rowTag, author, data);
1769
+ return;
1770
+ }
1771
+ // Prefer the id captured by apply() in this run; fall back to the
1772
+ // pre-run per-row lookup (resume-compensation of a prior run's applied step).
1773
+ const id = appliedId ?? row.creation ?? applied.byRowIndex.get(row.rowIndex);
1774
+ // Strip both markers so the rolled-back row is no longer recognized as
1775
+ // applied on a retry (marker-presence — not status — is the applied signal).
1776
+ if (typeof id === "string" && id) {
1777
+ if (row.revision !== undefined) {
1778
+ await opts.onAdmissionBoundary?.("before_compensate", id);
1779
+ await verifySettings(pmRoot);
1780
+ const current = await readUpdateDocument(pmRoot, id);
1781
+ const status = resolveRuntimeStatusRegistry(snapshot.settings.schema).close_status;
1782
+ const options = { ...buildMutationIntent({ parent: current.metadata.parent, assignee: current.metadata.assignee,
1783
+ sprint: current.metadata.sprint, release: current.metadata.release, blockedBy: current.metadata.blocked_by,
1784
+ status, closeReason: "Atomic CSV import rolled back", author, message: `${rowTag}:compensate` }, "patch"), removeTags: [rowTag, ownershipTag] };
1785
+ await admissionTargetRevision(pmRoot, id, "owned", rowTag);
1786
+ await updateNativeCsv(id, options, { pmRoot });
1787
+ }
1788
+ else
1789
+ compensateCreate(pmRoot, id, [rowTag, ownershipTag]);
1790
+ }
1791
+ })));
1597
1792
  };
1598
1793
  return { id: stepId, inspect, apply, prepareCompensation, compensate };
1599
1794
  });
1600
1795
  let committed;
1601
1796
  try {
1602
- committed = await commitTransaction({ transactionId, author, steps });
1797
+ committed = await commitTransaction({ transactionId, author, steps, onTransition: event => {
1798
+ if (journalStatus === "compensating" && ["compensated", "prepared"].includes(event.transition)) {
1799
+ throw new CommandError("Pending atomic compensation was recovered. Inspect the retained audit and retry the original import; this attempt did not restart forward work.", EXIT_CODE.USAGE);
1800
+ }
1801
+ } });
1603
1802
  }
1604
1803
  catch (err) {
1804
+ rethrowSettingsChange(err, opts.admission === undefined ? importSettingsChanged : admissionSettingsChanged);
1805
+ const admissionFailure = findAdmissionFailure(err);
1806
+ if (admissionFailure)
1807
+ throw admissionFailure;
1605
1808
  const msg = errorMessage(err);
1606
1809
  // The coordinator invokes compensations in reverse order, but this package
1607
1810
  // cannot prove a clean tracker: create cleanup deliberately tolerates an
@@ -1609,17 +1812,18 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
1609
1812
  // continue, and update compensation refuses concurrent-writer conflicts.
1610
1813
  throw new CommandError(`Atomic CSV import failed. The transaction coordinator attempted reverse-order compensation, but pm-csv cannot claim a clean tracker: created items are closed on a best-effort basis, and pre-existing updates restore captured prior values unless a conflict prevents safe compensation. Mutations from transaction ${transactionId} may remain; inspect and reconcile that transaction before retrying. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1611
1814
  }
1815
+ await verifySettings(pmRoot);
1612
1816
  // Tally the ImportResult from the committed step results. Each committed
1613
1817
  // result is the created/updated item id. Only rows that were NOT already
1614
1818
  // applied at the start of this run (i.e. newly committed by this attempt)
1615
1819
  // count toward imported/updated — resumed rows from a prior interrupted run
1616
1820
  // are already in the tracker and must not be double-counted.
1617
1821
  for (const row of planned) {
1618
- const stepId = atomicStepId(row.rowIndex, row.existingId);
1619
- const res = committed.results[stepId];
1822
+ const stepId = atomicStepId(row.rowIndex, row.existingId, row.revision, row.creation);
1823
+ const res = Object.hasOwn(committed.results, stepId) ? committed.results[stepId] : undefined;
1620
1824
  if (res === undefined)
1621
1825
  continue;
1622
- const alreadyApplied = applied.byRowIndex.has(row.rowIndex);
1826
+ const alreadyApplied = row.revision === undefined ? applied.byRowIndex.has(row.rowIndex) : !wrote.has(row.rowIndex);
1623
1827
  if (alreadyApplied)
1624
1828
  continue;
1625
1829
  if (row.isUpdate)
@@ -1629,6 +1833,20 @@ async function importCSVAtomic(pmRoot, filePath, opts) {
1629
1833
  }
1630
1834
  return result;
1631
1835
  }
1836
+ /** Pin atomic planning, retry inspection and row batches to one settings revision. */
1837
+ async function importCSVAtomic(pmRoot, filePath, opts, admittedDigest) {
1838
+ pmRoot = resolve(pmRoot);
1839
+ filePath = resolve(filePath);
1840
+ const captured = { ...opts, fieldMap: { ...opts.fieldMap }, filter: opts.filter ? { ...opts.filter } : undefined };
1841
+ const prepared = prepareInMemoryImport(filePath, captured, admittedDigest);
1842
+ if (captured.dryRun || !captured.commitTransaction)
1843
+ return importCSVAtomicInternal(pmRoot, filePath, captured, prepared);
1844
+ return withSettings(pmRoot, captured.admission === undefined ? importSettingsChanged : admissionSettingsChanged, () => importCSVAtomicInternal(pmRoot, filePath, captured, prepared));
1845
+ }
1846
+ /** Keep settings drift within the existing command error family. */
1847
+ function importSettingsChanged() {
1848
+ return new CommandError("csv_settings_changed: Settings changed during atomic import. Inspect compensation and preview current settings before retrying.", EXIT_CODE.USAGE, "csv_settings_changed");
1849
+ }
1632
1850
  /**
1633
1851
  * Streaming import: reads the file via a readable stream so large CSV files
1634
1852
  * are never fully loaded into memory. The header row (or positional
@@ -1677,17 +1895,15 @@ async function importCSVStreaming(pmRoot, filePath, opts) {
1677
1895
  * Flag names verified against the installed `pm create`/`pm update` contracts:
1678
1896
  * --parent, --assignee, --sprint, --release, --blocked-by.
1679
1897
  */
1680
- function appendRelationalArgs(args, p) {
1681
- if (p.parent)
1682
- args.push("--parent", p.parent);
1683
- if (p.assignee)
1684
- args.push("--assignee", p.assignee);
1685
- if (p.sprint)
1686
- args.push("--sprint", p.sprint);
1687
- if (p.release)
1688
- args.push("--release", p.release);
1689
- if (p.blocked_by)
1690
- args.push("--blocked-by", p.blocked_by);
1898
+ function csvRelationalOptions(p) {
1899
+ // Updates own only nonempty cells. Absent/empty cells preserve current state,
1900
+ // including all dependency edges; create mode still suppresses ambient defaults.
1901
+ return Object.fromEntries(Object.entries({ parent: p.parent, assignee: p.assignee,
1902
+ sprint: p.sprint, release: p.release, blockedBy: p.blocked_by }).filter(([, value]) => value !== undefined));
1903
+ }
1904
+ /** Render the same selected scope options used by admitted native mutations. */
1905
+ function appendRelationalArgs(args, p, mode) {
1906
+ args.push(...mutationScopeArguments(csvRelationalOptions(p), mode));
1691
1907
  }
1692
1908
  /** Append item fields shared by create and update mutation commands. */
1693
1909
  function appendMutableItemArgs(args, p, preserveEmptyBody) {
@@ -1699,7 +1915,7 @@ function appendMutableItemArgs(args, p, preserveEmptyBody) {
1699
1915
  args.push("--type", p.type);
1700
1916
  if (p.deadline)
1701
1917
  args.push("--deadline", p.deadline);
1702
- appendRelationalArgs(args, p);
1918
+ appendRelationalArgs(args, p, preserveEmptyBody ? "patch" : "create");
1703
1919
  }
1704
1920
  /** Build a normal validated close with factual CSV provenance and outcome fields. */
1705
1921
  function csvCloseArgs(pmRoot, id, reason, expected, actual) {
@@ -1718,6 +1934,21 @@ function importCloseReason(status, source) {
1718
1934
  const origin = source ? ` from ${source}` : "";
1719
1935
  return `Imported${origin} (source status: ${status})`;
1720
1936
  }
1937
+ /** Native options shared by admitted previews and apply; SDK owns field and lifecycle normalization. */
1938
+ function nativeCsvOptions(p, settings, update, keyValue, source, ownershipTags) {
1939
+ const tags = [...p.tags, ...(!update && keyValue ? [`${KEY_TAG_PREFIX}${encodeKeyTagValue(normalizeKeyValue(keyValue))}`] : []),
1940
+ ...(source ? [`${SOURCE_TAG_PREFIX}${encodeKeyTagValue(source)}`] : []), ...ownershipTags];
1941
+ const values = buildMutationIntent({ title: p.title, ...(p.type ? { type: p.type } : {}), ...(p.status ? { status: p.status } : {}),
1942
+ ...(!update && p.status === undefined ? { status: resolveRuntimeStatusRegistry(settings.schema).open_status } : {}),
1943
+ ...(p.priority !== undefined ? { priority: p.priority } : {}), ...(p.body !== undefined ? { body: p.body } : {}),
1944
+ ...(p.deadline ? { deadline: p.deadline } : {}), ...csvRelationalOptions(p),
1945
+ ...(update ? { addTags: tags } : { tags: tags.join(",") }) }, update ? "patch" : "create");
1946
+ const options = update ? values : pinCreateDefaults(values, settings);
1947
+ if (options.status !== undefined && isTerminalStatus(options.status, resolveRuntimeStatusRegistry(settings.schema))) {
1948
+ options.closeReason = importCloseReason(options.status, source);
1949
+ }
1950
+ return options;
1951
+ }
1721
1952
  /**
1722
1953
  * Create a new item, optionally carrying a csv-key provenance tag. Returns the
1723
1954
  * new id (empty string when `pm create` did not report one AND the row is not
@@ -1768,7 +1999,7 @@ function upsertCreate(pmRoot, p, keyValue, source, extraTags) {
1768
1999
  tags.push(...extraTags);
1769
2000
  // See the function docstring: terminal `closed` is applied via pm close
1770
2001
  // after the create, never via `pm create --status closed`.
1771
- const createStatus = p.status === "closed" ? "open" : p.status;
2002
+ const createStatus = !p.status || p.status === "closed" ? "open" : p.status;
1772
2003
  const args = ["--path", pmRoot, "create", "--title", p.title, "--status", createStatus, "--json"];
1773
2004
  appendMutableItemArgs(args, p, false);
1774
2005
  if (tags.length > 0)
@@ -1902,7 +2133,7 @@ function upsertUpdate(pmRoot, id, p, source, extraTags, historyMessage) {
1902
2133
  if (addTags.length > 0)
1903
2134
  args.push("--add-tags", addTags.join(","));
1904
2135
  // `update` cannot set a closed status; only set non-closed statuses here.
1905
- if (p.status !== "closed" && p.status !== "canceled")
2136
+ if (p.status && p.status !== "closed" && p.status !== "canceled")
1906
2137
  args.push("--status", p.status);
1907
2138
  const r = spawnSync("pm", args, { encoding: "utf-8", maxBuffer: pmListMaxBuffer() });
1908
2139
  // `pm update` is a WRITE: a null status (receipt overrun or a signal kill)
@@ -1954,7 +2185,7 @@ function validateCSV(filePath, opts) {
1954
2185
  * Core validation logic over already-parsed headers + rows. Pure and
1955
2186
  * side-effect-free so it can be unit tested directly.
1956
2187
  */
1957
- function validateParsedCSV(rawHeaders, dataRows, fieldMap) {
2188
+ function validateParsedCSV(rawHeaders, dataRows, fieldMap, statusKnown = status => KNOWN_STATUSES.has(status)) {
1958
2189
  const mappedColumns = applyFieldMap(rawHeaders, fieldMap);
1959
2190
  const hasTitleColumn = mappedColumns.includes("title");
1960
2191
  const issues = [];
@@ -1980,7 +2211,7 @@ function validateParsedCSV(rawHeaders, dataRows, fieldMap) {
1980
2211
  }
1981
2212
  if (statusIdx >= 0) {
1982
2213
  const status = (row[statusIdx] ?? "").trim().toLowerCase();
1983
- if (status && !KNOWN_STATUSES.has(status))
2214
+ if (status && !statusKnown(status))
1984
2215
  rowsWithUnknownStatus++;
1985
2216
  }
1986
2217
  if (priorityIdx >= 0) {
@@ -2085,6 +2316,13 @@ function assertStrictImportReady(filePath, opts) {
2085
2316
  }
2086
2317
  /** Parse one host option record into the canonical CSV import contract. */
2087
2318
  function parseCsvImportInvocation(rawOptions, dryRun) {
2319
+ const admitted = readBoolOption(rawOptions, "admission");
2320
+ if (admitted && ["status", "type", "priority"].some(key => rawOptions[key] !== undefined && (typeof rawOptions[key] !== "string" || !rawOptions[key].trim()))) {
2321
+ throw new AdmissionRefusal("csv_admission_options", "Supply nonempty configured status/type and integer priority filter values for admitted imports.");
2322
+ }
2323
+ const filter = parseImportFilter(rawOptions["status"], rawOptions["type"], rawOptions["priority"]);
2324
+ if (admitted && rawOptions["status"] !== undefined)
2325
+ filter.status = rawOptions["status"];
2088
2326
  return {
2089
2327
  strict: readBoolOption(rawOptions, "strict"),
2090
2328
  options: {
@@ -2095,16 +2333,17 @@ function parseCsvImportInvocation(rawOptions, dryRun) {
2095
2333
  keyField: (rawOptions["key"] ?? "").trim().toLowerCase() || undefined,
2096
2334
  encoding: resolveEncoding(rawOptions["encoding"]),
2097
2335
  source: (rawOptions["source"] ?? "").trim() || undefined,
2098
- filter: parseImportFilter(rawOptions["status"], rawOptions["type"], rawOptions["priority"]),
2336
+ filter,
2099
2337
  skipHeaders: readBoolOption(rawOptions, "skip-headers", "skipHeaders"),
2100
2338
  stream: readBoolOption(rawOptions, "stream"),
2101
2339
  atomic: readBoolOption(rawOptions, "atomic"),
2340
+ admission: admitted ? {} : undefined,
2102
2341
  },
2103
2342
  };
2104
2343
  }
2105
2344
  /** Validate strict input, then execute one fully parsed import invocation. */
2106
2345
  function executeCsvImport(pmRoot, filePath, invocation, commitTransaction, atomicAuthor) {
2107
- if (invocation.strict) {
2346
+ if (invocation.strict && invocation.options.admission === undefined) {
2108
2347
  assertStrictImportReady(filePath, {
2109
2348
  delimiter: invocation.options.delimiter,
2110
2349
  fieldMap: invocation.options.fieldMap,
@@ -2177,7 +2416,7 @@ function selectExportColumns(spec, extraValid = []) {
2177
2416
  */
2178
2417
  async function resolveExportColumns(pmRoot, columnsSpec, discover) {
2179
2418
  const discovered = discover ? await discoverCustomFields(pmRoot) : [];
2180
- const columnSource = {};
2419
+ const columnSource = Object.create(null);
2181
2420
  for (const f of discovered) {
2182
2421
  if (f.key !== f.metadataKey)
2183
2422
  columnSource[f.key] = f.metadataKey;
@@ -2253,8 +2492,8 @@ function buildCsvExport(pmRoot, opts, spawn = spawnPm) {
2253
2492
  item.csv_source = decodeKeyTagValue(sourceTag.slice(SOURCE_TAG_PREFIX.length));
2254
2493
  }
2255
2494
  const dataRows = items.map((item) => opts.columns.map((col) => {
2256
- const prop = opts.columnSource?.[col] ?? col;
2257
- const val = item[prop];
2495
+ const prop = opts.columnSource && Object.hasOwn(opts.columnSource, col) ? opts.columnSource[col] : col;
2496
+ const val = Object.hasOwn(item, prop) ? item[prop] : undefined;
2258
2497
  if (val === undefined || val === null)
2259
2498
  return "";
2260
2499
  if (Array.isArray(val)) {
@@ -2302,9 +2541,11 @@ async function prepareCsvExport(pmRoot, rawOptions) {
2302
2541
  }
2303
2542
  export default {
2304
2543
  name: "pm-csv",
2305
- version: "2026.10.28",
2544
+ version: "2026.10.30",
2306
2545
  activate(api) {
2307
2546
  registerTableSchemaCommands(api);
2547
+ registerJoinCommands(api);
2548
+ registerAdmissionCommand(api);
2308
2549
  // -----------------------------------------------------------------------
2309
2550
  // Schema: register an optional `csv_source` provenance field so imported
2310
2551
  // items can record where they came from (set via `pm csv import --source`).
@@ -2361,7 +2602,7 @@ export default {
2361
2602
  description: "Export a conflict-aware CSV edit session and immutable baseline JSON (maximum 1000 items).",
2362
2603
  flags: [
2363
2604
  { long: "--output", value_name: "file", description: "New CSV filename; baseline uses <file>.session.json" },
2364
- { long: "--ids", value_name: "ids", description: "Comma-separated canonical item IDs (default: complete tracker)" },
2605
+ { long: "--ids", value_name: "ids", description: "Comma-separated distinct canonical item IDs; aliases refuse (default: complete tracker)" },
2365
2606
  ],
2366
2607
  async run(ctx) {
2367
2608
  const output = ctx.options["output"];
@@ -2427,6 +2668,9 @@ export default {
2427
2668
  "--key for idempotent re-import (upsert), --encoding for non-UTF-8 files, " +
2428
2669
  "--source to record import provenance in the csv_source field, and " +
2429
2670
  "--status/--type/--priority to import only matching rows (others are skipped). " +
2671
+ "Keyed updates preserve absent columns and empty cells; nonempty cells set supported fields. " +
2672
+ "Use none/null to clear parent/assignee/sprint/release/blocked_by; tags are additive and dependency edges are preserved. " +
2673
+ "Custom fields are export-only. Creates suppress implicit focus and planning defaults. " +
2430
2674
  "Use --strict to fail before writing when row-level data issues are present.",
2431
2675
  arguments: [{ name: "file", required: true, description: "CSV file to import" }],
2432
2676
  intent: "import items from a CSV file into pm",
@@ -2445,7 +2689,7 @@ export default {
2445
2689
  "pm csv import items.csv --dry-run",
2446
2690
  "pm csv import headerless.csv --skip-headers # no header row, positional columns",
2447
2691
  "pm csv import big.csv --stream # stream large files without loading into memory",
2448
- "pm csv import tasks.csv --atomic # writer-locked, crash-recoverable import",
2692
+ "pm csv import tasks.csv --atomic # SDK-coordinated, crash-recoverable import",
2449
2693
  ],
2450
2694
  flags: [
2451
2695
  { long: "--delimiter", value_name: "char", description: "Field delimiter, or alias tab|comma|semicolon|pipe (default: ,)" },
@@ -2461,7 +2705,8 @@ export default {
2461
2705
  { long: "--dry-run", description: "Preview without writing" },
2462
2706
  { long: "--skip-headers", description: "The CSV file has no header row; map columns positionally to the standard import order (title, type, status, priority, tags, deadline, body, parent, assignee, sprint, release, blocked_by)" },
2463
2707
  { long: "--stream", description: "Stream the file row-by-row instead of loading it fully into memory (recommended for large CSV files)" },
2464
- { long: "--atomic", description: "Use one writer-locked, crash-recoverable transaction. On failure, attempt best-effort create compensation; pre-existing updates restore captured prior values unless a conflict prevents safe compensation. Inspect and reconcile any marked orphan before retrying. Incompatible with --stream" },
2708
+ { long: "--admission", description: "Require a full bounded validation pass and the declared atomic materialization budgets; requires --atomic" },
2709
+ { long: "--atomic", description: "Use one SDK-coordinated, crash-recoverable transaction. On failure, attempt best-effort create compensation; pre-existing updates restore captured prior values unless a conflict prevents safe compensation. Inspect and reconcile any marked orphan before retrying. Incompatible with --stream" },
2465
2710
  ],
2466
2711
  async run(ctx) {
2467
2712
  const filePath = ctx.args[0];
@@ -2479,7 +2724,7 @@ export default {
2479
2724
  ctx.sdk?.commitWorkspaceTransaction, ctx.global?.author ?? undefined);
2480
2725
  }
2481
2726
  catch (err) {
2482
- if (err instanceof CommandError)
2727
+ if (err instanceof CommandError || err instanceof AdmissionRefusal)
2483
2728
  throw err;
2484
2729
  const msg = errorMessage(err);
2485
2730
  const exitCode = /ENOENT|no such file/i.test(msg) ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE;
@@ -2697,4 +2942,6 @@ export { importCSV };
2697
2942
  export { applyEditSession, exportEditSession, parseEditSession, editSessionHeader, EDIT_FIELDS, EditRefusal } from "./roundtrip.js";
2698
2943
  export { importLineage, writeLineageBundle, lineageDigest, LINEAGE_FIELDS, LineageRefusal } from "./lineage.js";
2699
2944
  export { describeTable, previewTable, parseTableSchema, importNegotiatedTable, TableSchemaRefusal } from "./table-schema.js";
2945
+ export { previewJoins, applyJoins, JoinRefusal } from "./join-plans.js";
2946
+ export { admitCSV, assertCSVAdmission, AdmissionRefusal, ADMISSION_LIMITS } from "./admission.js";
2700
2947
  //# sourceMappingURL=index.js.map