@tenonhq/dovetail-core 0.0.103 → 0.0.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commander.js CHANGED
@@ -12,6 +12,7 @@ const schemaCommand_1 = require("./schemaCommand");
12
12
  const claudeCommand_1 = require("./claudeCommand");
13
13
  const createRecordCommand_1 = require("./createRecordCommand");
14
14
  const deleteRecordCommand_1 = require("./deleteRecordCommand");
15
+ const reconcileCommand_1 = require("./reconcileCommand");
15
16
  const migrateCommand_1 = require("./migrateCommand");
16
17
  const clickupCommands_1 = require("./clickupCommands");
17
18
  const loginCommand_1 = require("./loginCommand");
@@ -386,6 +387,39 @@ async function initCommands() {
386
387
  else if (args.subcommand === "snapshots") {
387
388
  await (0, schemaCommand_1.schemaSnapshotsCommand)(args);
388
389
  }
390
+ })
391
+ .command("reconcile", "Make your personal instance match the checked-out branch (diff/report by default; --apply to apply UPDATE + DELETE)", (cmdArgs) => {
392
+ cmdArgs.options({
393
+ ...sharedOptions,
394
+ scope: {
395
+ alias: "s",
396
+ type: "string",
397
+ describe: "Single scope (default: all scopes from dove.config.js)",
398
+ },
399
+ schema: {
400
+ type: "boolean",
401
+ default: true,
402
+ describe: "Include schema drift (report-only). --no-schema to skip. Requires a `dove schema pull --snapshot`.",
403
+ },
404
+ "write-baseline": {
405
+ type: "boolean",
406
+ default: false,
407
+ describe: "Establish the per-instance baseline (merge-base) from current live state. Run once before --apply.",
408
+ },
409
+ apply: {
410
+ type: "boolean",
411
+ default: false,
412
+ describe: "Apply the safe subset: record UPDATE (branch -> instance) + tracked DELETE. Refuses on drift unless --force. CREATE is Phase 3.",
413
+ },
414
+ force: {
415
+ type: "boolean",
416
+ default: false,
417
+ describe: "With --apply: discard instance drift (edits to tracked records since baseline). Never deletes the dev's own local records.",
418
+ },
419
+ });
420
+ return cmdArgs;
421
+ }, async (args) => {
422
+ await (0, reconcileCommand_1.reconcileCommand)(args);
389
423
  })
390
424
  .command("init-claude", "Install Dovetail Claude Code skills to .claude/commands/", (cmdArgs) => {
391
425
  cmdArgs.options({
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ // Pure decision layer for the reconcile apply phase: given a classified diff,
3
+ // the dirty set, and the flags, decide whether to refuse and exactly which
4
+ // changes are eligible to apply. No I/O — the command layer executes the plan.
5
+ //
6
+ // Safety model (the git-checkout analogy):
7
+ // - No baseline -> refuse. You must establish the merge-base first
8
+ // (`dove reconcile --write-baseline`); without it
9
+ // there is no way to tell the dev's edits from the
10
+ // branch's, so an apply could clobber local work.
11
+ // - Drift, no --force -> refuse. Tracked records changed on the instance
12
+ // since baseline; refresh to keep them or --force.
13
+ // - Clean, or --force -> apply.
14
+ //
15
+ // What applies, in Phase 2:
16
+ // - UPDATE every diff update (branch content overwrites the instance).
17
+ // - DELETE only "tracked" instance-only records (the branch deleted them).
18
+ // "local-new" / "no-baseline" records are the dev's own creations —
19
+ // NEVER deleted, not even with --force (force discards edits to the
20
+ // branch's records, it does not reap records the branch never owned).
21
+ // - CREATE deferred to Phase 3 — reported, never applied here.
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.buildApplyPlan = buildApplyPlan;
24
+ function buildApplyPlan(options) {
25
+ const { diff, dirty, hasBaseline, force } = options;
26
+ const deletes = [];
27
+ const skippedDeletes = [];
28
+ for (const change of diff.deletes) {
29
+ if (change.deleteDisposition === "tracked") {
30
+ deletes.push(change);
31
+ }
32
+ else {
33
+ skippedDeletes.push(change);
34
+ }
35
+ }
36
+ let refuse = false;
37
+ let refuseReason = "";
38
+ if (!hasBaseline) {
39
+ refuse = true;
40
+ refuseReason =
41
+ "no baseline for this instance — establish the merge-base first with " +
42
+ "`dove reconcile --write-baseline`, then re-run apply.";
43
+ }
44
+ else if (dirty.length > 0 && !force) {
45
+ refuse = true;
46
+ refuseReason =
47
+ dirty.length +
48
+ " record(s) changed on the instance since baseline. Keep them with " +
49
+ "`dove refresh`, or discard them with --force.";
50
+ }
51
+ return {
52
+ refuse,
53
+ refuseReason,
54
+ updates: diff.updates.slice(),
55
+ deletes,
56
+ skippedDeletes,
57
+ deferredCreates: diff.creates.slice(),
58
+ };
59
+ }
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ // The reconcile baseline — the "merge-base / index" in the git analogy. It
3
+ // records the {sys_id -> sys_updated_on} the instance was known to hold at the
4
+ // last reconcile (or refresh), namespaced by instance host. It MUST live
5
+ // outside the git tree: after a `git checkout`, the on-disk
6
+ // `metaData._lastUpdatedOn` is the *committer's* refresh time, not this
7
+ // developer's instance state, so the branch files cannot answer "did the dev
8
+ // change the instance since we last synced?". The baseline can.
9
+ //
10
+ // Phase 1 only READS the baseline (to disambiguate deletes and surface drift).
11
+ // `writeBaseline` / `baselineFromLive` are the apply-phase write path and the
12
+ // pure dirty-check is shared by both.
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.BASELINE_FILENAME = void 0;
18
+ exports.baselinePath = baselinePath;
19
+ exports.readBaseline = readBaseline;
20
+ exports.writeBaseline = writeBaseline;
21
+ exports.baselineFromLive = baselineFromLive;
22
+ exports.baselineSysIds = baselineSysIds;
23
+ exports.computeDirty = computeDirty;
24
+ const fs_1 = __importDefault(require("fs"));
25
+ const path_1 = __importDefault(require("path"));
26
+ exports.BASELINE_FILENAME = ".dove-reconcile-baseline.json";
27
+ function baselinePath(rootDir) {
28
+ return path_1.default.join(rootDir, exports.BASELINE_FILENAME);
29
+ }
30
+ function isStringRecordMap(value) {
31
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
32
+ return false;
33
+ }
34
+ for (const key of Object.keys(value)) {
35
+ if (typeof value[key] !== "string") {
36
+ return false;
37
+ }
38
+ }
39
+ return true;
40
+ }
41
+ /**
42
+ * Read the baseline for `instance`. Returns null when the file is absent,
43
+ * unparseable, malformed, or was captured against a different instance (a
44
+ * mismatched baseline is useless and must not silently disambiguate the wrong
45
+ * instance's records). All failure modes degrade to "no baseline", never throw.
46
+ */
47
+ function readBaseline(rootDir, instance) {
48
+ const filePath = baselinePath(rootDir);
49
+ let raw;
50
+ try {
51
+ if (!fs_1.default.existsSync(filePath)) {
52
+ return null;
53
+ }
54
+ raw = fs_1.default.readFileSync(filePath, "utf8");
55
+ }
56
+ catch (e) {
57
+ return null;
58
+ }
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(raw);
62
+ }
63
+ catch (e) {
64
+ return null;
65
+ }
66
+ if (typeof parsed !== "object" || parsed === null) {
67
+ return null;
68
+ }
69
+ const candidate = parsed;
70
+ if (candidate.version !== 1 || !isStringRecordMap(candidate.records)) {
71
+ return null;
72
+ }
73
+ if (instance && candidate.instance && candidate.instance !== instance) {
74
+ return null;
75
+ }
76
+ return {
77
+ version: 1,
78
+ instance: candidate.instance || instance,
79
+ records: candidate.records,
80
+ };
81
+ }
82
+ function writeBaseline(rootDir, baseline) {
83
+ fs_1.default.writeFileSync(baselinePath(rootDir), JSON.stringify(baseline, null, 2) + "\n", "utf8");
84
+ }
85
+ /** Build a fresh baseline snapshot from the current live record set. */
86
+ function baselineFromLive(instance, live) {
87
+ const records = {};
88
+ for (const record of live) {
89
+ records[record.sys_id] = record.updatedOn || "";
90
+ }
91
+ return { version: 1, instance, records };
92
+ }
93
+ function baselineSysIds(baseline) {
94
+ if (!baseline) {
95
+ return null;
96
+ }
97
+ return new Set(Object.keys(baseline.records));
98
+ }
99
+ /**
100
+ * The dev's own uncommitted instance work: records present in the baseline
101
+ * whose live `sys_updated_on` has moved since. This is the refuse-if-dirty
102
+ * signal — Phase 1 reports it, Phase 2 blocks apply on it (unless `--force`).
103
+ * Pure. With no baseline there is nothing to compare against, so it returns [].
104
+ */
105
+ function computeDirty(options) {
106
+ const { baseline, live } = options;
107
+ if (!baseline) {
108
+ return [];
109
+ }
110
+ const dirty = [];
111
+ for (const record of live) {
112
+ const baselineStamp = baseline.records[record.sys_id];
113
+ if (baselineStamp !== undefined &&
114
+ baselineStamp !== "" &&
115
+ record.updatedOn !== "" &&
116
+ record.updatedOn !== baselineStamp) {
117
+ dirty.push({
118
+ sys_id: record.sys_id,
119
+ table: record.table,
120
+ name: record.name,
121
+ reason: "changed-since-baseline",
122
+ });
123
+ }
124
+ }
125
+ return dirty;
126
+ }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ // FK-safe deletion without building a dependency graph. ServiceNow refuses to
3
+ // delete a record another record still references; the safe order is therefore
4
+ // children before parents. Rather than model the foreign-key graph, delete in
5
+ // passes: attempt every pending record, keep whatever succeeded, and retry the
6
+ // failures on the next pass. A record blocked by a still-present dependent
7
+ // succeeds once that dependent is gone. Stop when a full pass makes no progress
8
+ // (a genuine, non-ordering failure — e.g. ACL) and report the stragglers.
9
+ //
10
+ // Pure control flow: the actual delete is injected, so the loop is unit-tested
11
+ // with a fake executor that encodes "B cannot delete until A is gone."
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.runMultiPassDeletes = runMultiPassDeletes;
14
+ async function runMultiPassDeletes(changes, attemptDelete) {
15
+ const succeeded = [];
16
+ let pending = changes.slice();
17
+ let lastError = new Map();
18
+ while (pending.length > 0) {
19
+ const stillPending = [];
20
+ let progressed = false;
21
+ lastError = new Map();
22
+ for (const change of pending) {
23
+ let result;
24
+ try {
25
+ result = await attemptDelete(change);
26
+ }
27
+ catch (e) {
28
+ result = { ok: false, error: e instanceof Error ? e.message : String(e) };
29
+ }
30
+ if (result.ok) {
31
+ succeeded.push({ change, ok: true });
32
+ progressed = true;
33
+ }
34
+ else {
35
+ stillPending.push(change);
36
+ lastError.set(change.sys_id, result.error || "delete failed");
37
+ }
38
+ }
39
+ pending = stillPending;
40
+ if (!progressed) {
41
+ // A full pass deleted nothing — the remaining failures are not ordering
42
+ // problems. Report them and stop.
43
+ break;
44
+ }
45
+ }
46
+ const failures = pending.map((change) => ({
47
+ change,
48
+ ok: false,
49
+ error: lastError.get(change.sys_id) || "delete failed",
50
+ }));
51
+ return succeeded.concat(failures);
52
+ }
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ // Field-comparison rules for reconcile. A record's on-disk representation is a
3
+ // directory of per-field files plus a `metaData.json` snapshot. metaData is
4
+ // Dovetail bookkeeping — it carries `_lastUpdatedOn`, a host-stripped
5
+ // `_record_link`, and a full field dump, and it is re-stamped on every touch —
6
+ // so including it in a content comparison would make every record read as
7
+ // modified. Excluding it leaves the genuine, content-bearing field files
8
+ // (script.js, etc.), which is exactly what `dove refresh` writes verbatim from
9
+ // the instance. Comparing those raw is therefore consistent with the Phase 1
10
+ // acceptance test: "a reconcile dry-run matches a hand-diff of two refresh
11
+ // snapshots."
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.fieldKey = fieldKey;
14
+ exports.isComparableField = isComparableField;
15
+ /** Stable "<field>.<type>" key used on both the branch and live sides. */
16
+ function fieldKey(file) {
17
+ return file.name + "." + file.type;
18
+ }
19
+ /**
20
+ * True when a record file is a content-bearing field that should participate in
21
+ * the diff. Excludes the `metaData.json` bookkeeping file.
22
+ */
23
+ function isComparableField(file) {
24
+ if (file.name === "metaData" && file.type === "json") {
25
+ return false;
26
+ }
27
+ return true;
28
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ // Ensure the per-instance baseline is gitignored in the consumer project. The
3
+ // baseline is per-developer, per-instance local state (the "merge-base/index")
4
+ // and must never be committed. The compute step is pure and unit-tested; the
5
+ // wrapper does the file I/O and never throws (a gitignore it cannot write is a
6
+ // warning, not a failed reconcile).
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.hasGitignoreEntry = hasGitignoreEntry;
12
+ exports.ensureEntryContent = ensureEntryContent;
13
+ exports.ensureGitignored = ensureGitignored;
14
+ const fs_1 = __importDefault(require("fs"));
15
+ const path_1 = __importDefault(require("path"));
16
+ function lines(content) {
17
+ return content.split(/\r?\n/);
18
+ }
19
+ /**
20
+ * Returns whether `entry` is already an active (non-comment) line in the
21
+ * gitignore content. Trailing whitespace and blank lines are ignored.
22
+ */
23
+ function hasGitignoreEntry(content, entry) {
24
+ const target = entry.trim();
25
+ for (const line of lines(content)) {
26
+ const trimmed = line.trim();
27
+ if (trimmed === target) {
28
+ return true;
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+ /**
34
+ * Pure: compute the gitignore content that includes `entry`. Appends a tidy
35
+ * block (with a trailing newline) when missing; returns unchanged when present.
36
+ * `content` is null when the file does not yet exist.
37
+ */
38
+ function ensureEntryContent(content, entry) {
39
+ if (content !== null && hasGitignoreEntry(content, entry)) {
40
+ return { changed: false, content };
41
+ }
42
+ const base = content === null ? "" : content;
43
+ const needsNewline = base.length > 0 && !base.endsWith("\n");
44
+ const next = base + (needsNewline ? "\n" : "") + entry + "\n";
45
+ return { changed: true, content: next };
46
+ }
47
+ function ensureGitignored(rootDir, entry) {
48
+ const filePath = path_1.default.join(rootDir, ".gitignore");
49
+ let existing = null;
50
+ try {
51
+ if (fs_1.default.existsSync(filePath)) {
52
+ existing = fs_1.default.readFileSync(filePath, "utf8");
53
+ }
54
+ }
55
+ catch (e) {
56
+ existing = null;
57
+ }
58
+ const update = ensureEntryContent(existing, entry);
59
+ if (update.changed) {
60
+ try {
61
+ fs_1.default.writeFileSync(filePath, update.content, "utf8");
62
+ }
63
+ catch (e) {
64
+ return { changed: false, content: existing || "" };
65
+ }
66
+ }
67
+ return update;
68
+ }
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ // The pure record-diff engine: classify every tracked record into create /
3
+ // update / delete / unchanged by joining the branch and live sides on sys_id.
4
+ // No I/O — callers supply already-normalized ReconcileRecord[] (see
5
+ // recordSource.ts for the on-disk + instance adapters). Fully unit-testable.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.diffRecords = diffRecords;
8
+ function indexBySysId(records) {
9
+ const map = new Map();
10
+ for (const record of records) {
11
+ map.set(record.sys_id, record);
12
+ }
13
+ return map;
14
+ }
15
+ function toChange(kind, record, fieldDeltas) {
16
+ return {
17
+ kind,
18
+ table: record.table,
19
+ scope: record.scope,
20
+ sys_id: record.sys_id,
21
+ name: record.name,
22
+ fieldDeltas,
23
+ };
24
+ }
25
+ // Compare two sides of the same record. Emits one FieldDelta per field that is
26
+ // added, removed, or changed; identical fields produce nothing.
27
+ function computeFieldDeltas(branch, live) {
28
+ const keys = new Set();
29
+ for (const key of Object.keys(branch.fields)) {
30
+ keys.add(key);
31
+ }
32
+ for (const key of Object.keys(live.fields)) {
33
+ keys.add(key);
34
+ }
35
+ const deltas = [];
36
+ for (const key of Array.from(keys).sort()) {
37
+ const onBranch = Object.prototype.hasOwnProperty.call(branch.fields, key);
38
+ const onLive = Object.prototype.hasOwnProperty.call(live.fields, key);
39
+ const changed = onBranch && onLive && branch.fields[key] !== live.fields[key];
40
+ if (!onBranch || !onLive || changed) {
41
+ deltas.push({ field: key, onBranch, onLive, changed });
42
+ }
43
+ }
44
+ return deltas;
45
+ }
46
+ function byTableThenName(a, b) {
47
+ if (a.table !== b.table) {
48
+ return a.table < b.table ? -1 : 1;
49
+ }
50
+ if (a.name !== b.name) {
51
+ return a.name < b.name ? -1 : 1;
52
+ }
53
+ return a.sys_id < b.sys_id ? -1 : a.sys_id > b.sys_id ? 1 : 0;
54
+ }
55
+ function diffRecords(options) {
56
+ const { branch, live } = options;
57
+ const branchIndex = indexBySysId(branch);
58
+ const liveIndex = indexBySysId(live);
59
+ const hasBaseline = options.baselineSysIds !== null;
60
+ const baseline = options.baselineSysIds || new Set();
61
+ const creates = [];
62
+ const updates = [];
63
+ const deletes = [];
64
+ let unchangedCount = 0;
65
+ // CREATE + UPDATE/unchanged — walk the branch side.
66
+ for (const branchRecord of branch) {
67
+ const liveRecord = liveIndex.get(branchRecord.sys_id);
68
+ if (!liveRecord) {
69
+ creates.push(toChange("create", branchRecord, []));
70
+ continue;
71
+ }
72
+ const deltas = computeFieldDeltas(branchRecord, liveRecord);
73
+ if (deltas.length === 0) {
74
+ unchangedCount++;
75
+ }
76
+ else {
77
+ updates.push(toChange("update", branchRecord, deltas));
78
+ }
79
+ }
80
+ // DELETE — live records the branch no longer carries.
81
+ for (const liveRecord of live) {
82
+ if (branchIndex.has(liveRecord.sys_id)) {
83
+ continue;
84
+ }
85
+ let disposition;
86
+ if (!hasBaseline) {
87
+ disposition = "no-baseline";
88
+ }
89
+ else if (baseline.has(liveRecord.sys_id)) {
90
+ disposition = "tracked";
91
+ }
92
+ else {
93
+ disposition = "local-new";
94
+ }
95
+ const change = toChange("delete", liveRecord, []);
96
+ change.deleteDisposition = disposition;
97
+ deletes.push(change);
98
+ }
99
+ creates.sort(byTableThenName);
100
+ updates.sort(byTableThenName);
101
+ deletes.sort(byTableThenName);
102
+ return { creates, updates, deletes, unchangedCount };
103
+ }