@stravica-ai/digital-operator-pa 0.1.20 → 0.1.22

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 (27) hide show
  1. package/dist/engineering-pa-cli/bin/estate-sync +0 -0
  2. package/dist/engineering-pa-cli/bin/launch +0 -0
  3. package/dist/engineering-pa-cli/bin/pa +0 -0
  4. package/dist/engineering-pa-cli/bin/pa-coordinator-hook +0 -0
  5. package/dist/engineering-pa-cli/bin/research +0 -0
  6. package/dist/engineering-pa-cli/bin/scheduler +0 -0
  7. package/dist/engineering-pa-cli/bin/secrets +0 -0
  8. package/dist/engineering-pa-cli/bin/smoke +0 -0
  9. package/dist/engineering-pa-cli/bin/sync +0 -0
  10. package/dist/engineering-pa-cli/docs/estate-sync.md +90 -9
  11. package/dist/engineering-pa-cli/scripts/migrate-doc-paths.mjs +0 -0
  12. package/dist/engineering-pa-cli/src/pa/work-cli.mjs +36 -1
  13. package/dist/engineering-pa-cli/src/pa/work.mjs +67 -27
  14. package/dist/engineering-pa-cli/src/sync/git.mjs +88 -0
  15. package/dist/engineering-pa-cli/src/sync/pending-store.mjs +318 -0
  16. package/dist/engineering-pa-cli/src/sync/run.mjs +272 -5
  17. package/dist/engineering-pa-cli/src/sync/self-heal.mjs +577 -0
  18. package/dist/engineering-pa-core/bin/desk +0 -0
  19. package/dist/engineering-pa-core/bin/distill +0 -0
  20. package/dist/engineering-pa-core/bin/memory-sync +0 -0
  21. package/dist/engineering-pa-core/bin/slice +0 -0
  22. package/dist/engineering-pa-core/schema/work-item.json +2 -2
  23. package/dist/engineering-pa-core/src/workItemId/index.mjs +219 -0
  24. package/dist/engineering-pa-migrate-inspect/bin/migrate-inspect +0 -0
  25. package/dist/engineering-pa-scaffold/bin/pa-init +0 -0
  26. package/dist/engineering-pa-workspace/src/ws.mjs +0 -0
  27. package/package.json +6 -5
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -128,11 +128,74 @@ id/tag is estate-side work, not part of this package.
128
128
  proceeds to push. The audit record adds
129
129
  `autoResolved: <N>` + `autoResolvedPaths: [...]` to the `ok` line.
130
130
 
131
- If **any** conflicted path is outside the allowlist,
132
- `git rebase --abort` runs and sync exits 2 with status `fail-rebase`
133
- as before. Real content conflicts still surface for human resolution;
134
- the audit record includes `conflictedPaths: [...]` for the disallowed
135
- paths so the on-call has a starting point.
131
+ If **any** conflicted path is outside the allowlist, the tool
132
+ attempts **self-heal on rebase-abort** (R2, w-2026-08-12-004, replaces
133
+ the pre-R2 "abort and wedge" behaviour): estate-sync's own version of
134
+ each conflicting path is queued to
135
+ `<repoRoot>/.cache/estate-sync/pending/<pathSlug>__<firstSeenTs>` with
136
+ a JSON sidecar carrying `originalPath`, `conflictingUpstreamCommitSha`,
137
+ `upstreamBlobShaAtQueue`, `attemptCount`, `firstSeen`, and
138
+ `alertLevel`. The pending content is fsynced BEFORE any git mutation
139
+ runs, so a queue failure never triggers a reset. Then four cascading
140
+ guards fire (HEAD sha equals the sha recorded immediately after
141
+ `git commit`; HEAD^ exists; exactly one commit in `HEAD^..HEAD`;
142
+ exactly one commit ahead of `origin/<branch>`); on any guard failure
143
+ the tool falls through to the classic `fail-rebase` behaviour and
144
+ stays wedged loudly. When all guards pass, `git reset --soft HEAD^`
145
+ drops estate-sync's own commit while leaving BOTH the index and the
146
+ working tree untouched — every non-conflicting path in the dropped
147
+ commit stays staged and its bytes stay on disk for the next tick to
148
+ recommit. Then surgically per conflicting path: newly-added paths get
149
+ `git rm --cached -- <path>` + `fs.unlink` (unstaged and removed from
150
+ disk; their bytes live in pending); modified paths get
151
+ `git checkout HEAD -- <path>` (restored to the parent's version in
152
+ both index and working tree). The audit record uses one of
153
+ `fail-rebase-queued` (alertLevel `warn`, attempts < 5),
154
+ `fail-rebase-stuck` (alertLevel `error`, attempts >= 5), or
155
+ `fail-rebase-needs-human` (divergence detected during drain) with
156
+ exit code **0** — the process exits healthy because the workspace
157
+ itself is fast-forwardable again, while the `fail-*` status routes
158
+ through librarian-alerts (when wired to estate-sync's runs.jsonl).
159
+
160
+ > **Why `--soft` and not `--keep`** (correction landed 2026-08-12 via
161
+ > the PR-120 fresh-context review): `--keep <ref>` updates the
162
+ > working tree to match `<ref>` on every file that differs between
163
+ > HEAD and `<ref>`. In a multi-file estate-sync commit (which is the
164
+ > design — step 6 scoops every in-scope dirty path), that means
165
+ > every non-conflicting path in the dropped commit would be silently
166
+ > destroyed (newly-added files deleted, modifications reverted).
167
+ > `--soft` leaves the index and working tree entirely alone; the
168
+ > surgical scope above touches only the conflicting paths, so
169
+ > non-conflicting content survives across the drop.
170
+
171
+ On every subsequent tick, **DRAIN** runs at the top (inside the PID
172
+ lock, after fetch so `origin/<branch>` refs are fresh), oldest-first.
173
+ For each pending entry the tool compares the sidecar's
174
+ `upstreamBlobShaAtQueue` against origin's blob NOW and picks one of
175
+ three actions:
176
+ - **`already-delivered`** — upstream's current blob bytes exactly
177
+ match the pending content (another writer pushed our version for
178
+ us). Pending removed cleanly, no workspace write.
179
+ - **`staged-for-retry`** — upstream blob is unchanged since queue
180
+ time. Copy the pending bytes back into the workspace at
181
+ `originalPath`; step 6 will stage them and step 7 will rebase
182
+ normally. Pending removed ONLY after commit + push are confirmed
183
+ on HEAD (`finaliseDrainOnPushSuccess`); a crash between drain
184
+ and commit leaves the pending file intact so the next tick
185
+ re-evaluates.
186
+ - **`divergence-held`** — upstream now has a DIFFERENT resolution
187
+ than the sidecar recorded at queue time (a third writer intervened,
188
+ a human reconciled with different bytes, etc.). The sidecar is
189
+ stamped `alertLevel: 'needs-human'` (sticky — never de-escalates
190
+ back to warn or error), and the workspace is NOT touched. This
191
+ blocks the silent-clobber hazard the earlier ratified drain
192
+ sketch had, where re-applying stale bytes would overwrite the
193
+ winner's resolution.
194
+
195
+ Pending queue lives at `.cache/estate-sync/pending/`, gitignored per
196
+ the tooling standard §16. Content-safety invariant: no reset ever
197
+ runs when the queue write fails, and no workspace write ever happens
198
+ when upstream has diverged.
136
199
 
137
200
  ### Registry-scoped commit set
138
201
 
@@ -229,7 +292,13 @@ One JSON object per line, camelCase keys (§11 of the tooling standard):
229
292
  | `branch` | string | The current branch name. Present on `ok` and `noop-nothing-to-do`. |
230
293
  | `autoResolved` | number | Number of allowlisted regenerated-artifact paths auto-resolved during a rebase conflict. `ok` only, present iff > 0. |
231
294
  | `autoResolvedPaths` | string[] | The specific paths that got the `--ours` treatment. `ok` only, paired with `autoResolved`. |
232
- | `conflictedPaths` | string[] | The disallowed conflicted paths that forced the abort. `fail-rebase` only, when at least one conflict was outside the allowlist. |
295
+ | `conflictedPaths` | string[] | The disallowed conflicted paths. Present on `fail-rebase` (self-heal declined, wedged loud), and on any `fail-rebase-queued` / `fail-rebase-stuck` / `fail-rebase-needs-human` (self-heal ran, workspace healthy again). |
296
+ | `selfHealed` | bool | Set to `true` on any self-heal audit record (`fail-rebase-queued`, `-stuck`, or `-needs-human`). |
297
+ | `droppedCommit` | string | 40-hex sha of estate-sync's own commit that self-heal dropped. Present on all self-heal records. |
298
+ | `queued` | object[] | Summary of each queued pending entry (id, originalPath, attemptCount, alertLevel, upstreamBlobShaAtQueue, conflictingUpstreamCommitSha, pendingPath). Present on all self-heal records. |
299
+ | `drained` | object[] | Summary of drain results for this tick (id, originalPath, action, attemptCount, optional alertLevel + upstream blob shas). Present on any tick that saw pending entries at start-of-tick. |
300
+ | `drainFinalised` | string[] | Ids of pending entries removed after commit + push were confirmed on HEAD. Present on `ok` records that carried `drained` entries with action `staged-for-retry`. |
301
+ | `selfHealDeclined` | object | `{ stage, reason }` when self-heal was invoked but refused (queue-write failure, guard failure, reset failure). Paired with `status: fail-rebase` and `exitCode: 2`. |
233
302
 
234
303
  Status vocabulary (closed set):
235
304
 
@@ -238,13 +307,25 @@ Status vocabulary (closed set):
238
307
  | `ok` | 0 | Commit + push (or pull-only) succeeded. |
239
308
  | `noop-nothing-to-do` | 0 | Clean tree, no local commits ahead, up-to-date with origin. |
240
309
  | `noop-locked` | 0 | Another sync run holds the lock. |
241
- | `fail-rebase` | 2 | `git pull --rebase` conflicted; rebase aborted. Human reconciliation required. |
310
+ | `fail-rebase` | 2 | `git pull --rebase` conflicted; self-heal either not attempted (regenerated-artifacts branch already resolved) or declined (guard-failure / queue-write-failure / reset-failure). Workspace is wedged, human reconciliation required. |
311
+ | `fail-rebase-queued` | 0 | Self-heal ran on a non-allowlist conflict, dropped estate-sync's own commit under guard, queued the conflicting content for retry. Workspace is fast-forwardable again; librarian-alerts should surface the queued path (`alertLevel: warn`, `attemptCount < 5`). |
312
+ | `fail-rebase-stuck` | 0 | Same as `fail-rebase-queued` but the pending entry's `attemptCount >= 5` — escalated alert (`alertLevel: error`). |
313
+ | `fail-rebase-needs-human` | 0 | Self-heal ran, drain-time upstream divergence detected on a pending entry — someone else pushed a different resolution while ours was queued. Workspace NOT touched; pending held indefinitely (`alertLevel: needs-human`, sticky). |
242
314
  | `fail-push` | 2 | Push rejected even after one rebase-and-retry. Commit is local-only. |
243
315
  | `fail-git` | 3 | A `git status`/`add`/`commit` command itself errored (transport or IO). |
244
316
  | `fail-cli-bootstrap` | varies | parseArgs / import / startup failure before runSync was reached. |
245
317
 
246
- Consumer contract: monitors should treat `status == 'ok'` and any
247
- `noop-*` as healthy; any `fail-*` as failing.
318
+ Consumer contract:
319
+ - **Process health** — check `exitCode`. `0` = the sync process itself is
320
+ healthy (nothing to fix at cron / systemd level). This is the correct
321
+ signal for "is the cron process wedged?"
322
+ - **Alert routing** — check `status.startsWith('fail-')`. Every `fail-*`
323
+ status is a human-visible alert regardless of exitCode, because
324
+ self-heal statuses (`fail-rebase-queued`, `fail-rebase-stuck`,
325
+ `fail-rebase-needs-human`) intentionally exit 0 (workspace is healthy,
326
+ process is healthy) while surfacing that a pending entry needs
327
+ attention. Escalation ladder inside self-heal: `queued` → `stuck` at
328
+ attempt >= 5 → `needs-human` on drain-time divergence (sticky).
248
329
 
249
330
  ## Exit codes
250
331
 
@@ -1,12 +1,23 @@
1
1
  // work-cli.mjs — argv router for `pa work <verb>`.
2
2
  //
3
3
  // Verbs:
4
- // pa work create [--project <id>] --title "..." --category <c> [--priority N] [--summary "..."] [--owner ...] [--assignee ...]
4
+ // pa work create [--project <id>] --title "..." --category <c> [--priority N] [--summary "..."] [--owner ...] [--assignee ...] [--author <token>]
5
5
  // pa work status <id> <status>
6
6
  // pa work list [--project <id>] [--status ...] [--mine] [--theirs] [--all] [--json]
7
7
  //
8
8
  // PA name (for --mine resolution) is read from
9
9
  // operator/config/preferences.json. Falls back to 'pa' if missing.
10
+ //
11
+ // The `paName` field ALSO doubles as the fallback author-token source
12
+ // for `pa work create` (writer-scoped id allocation, R1 of the
13
+ // 2026-08-11 library remediation train). Precedence, highest first:
14
+ // 1. --author flag (or opts.author from a programmatic caller)
15
+ // 2. PA_AUTHOR_TOKEN env var
16
+ // 3. preferences.json `paName`
17
+ // If none resolve, `pa work create` hard-errors — the whole point of
18
+ // R1 is that a mint with no author identity is a defect, not a
19
+ // fall-back-to-default case. Full rationale + tests in
20
+ // `@stravica-ai/engineering-pa-core/src/workItemId/index.mjs`.
10
21
 
11
22
  import { readPreferences } from '../../../engineering-pa-core/src/preferences.mjs';
12
23
 
@@ -18,6 +29,7 @@ const USAGE = `pa work — manage operator-repo work items
18
29
  Usage:
19
30
  pa work create [--project <id>] --title "..." --category <feature|bug|chore|discussion|research>
20
31
  [--priority 0|1|2|3] [--summary "..."] [--owner ...] [--assignee ...]
32
+ [--author <token>]
21
33
  pa work status <id> <draft|planned|in-progress|blocked|done|archived>
22
34
  pa work list [--project <id>] [--status ...] [--category ...] [--mine] [--theirs] [--all] [--json]
23
35
  pa work -h, --help
@@ -26,6 +38,9 @@ Notes:
26
38
  - status=done stamps \`completedAt\` to today (only if not already set).
27
39
  - --mine resolves the PA name from operator/config/preferences.json
28
40
  (paName / agentName). Default: "pa".
41
+ - --author sets the writer-scoped id's author token. Precedence:
42
+ --author > PA_AUTHOR_TOKEN env > preferences.json paName. Must
43
+ match /^[a-z]{2,12}$/ and be present in operator/config/authors.json.
29
44
 
30
45
  Exit codes:
31
46
  0 ok | 1 usage/not-found | 2 operational failure
@@ -89,11 +104,29 @@ async function getPaName() {
89
104
  }
90
105
  }
91
106
 
107
+ /**
108
+ * Read the raw `paName` (device-local overlay merged over shared). Unlike
109
+ * `getPaName`, this returns null if the field is unset — createWork's
110
+ * author-resolver treats null as "skip this source", not "fall back to
111
+ * literal 'pa'" (which would silently mask a missing token).
112
+ */
113
+ async function readPreferencesPaName() {
114
+ try {
115
+ const repoRoot = await getRepoRoot();
116
+ const config = await readPreferences(repoRoot);
117
+ if (!config) return null;
118
+ return config.paName || config.agentName || null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
92
124
  async function doCreate(args) {
93
125
  const { flags } = parseFlags(args);
94
126
  const priority = flags.priority != null && flags.priority !== true
95
127
  ? Number(flags.priority)
96
128
  : null;
129
+ const preferencesPaName = await readPreferencesPaName();
97
130
  const res = await createWork({
98
131
  projectId: typeof flags.project === 'string' ? flags.project : null,
99
132
  title: typeof flags.title === 'string' ? flags.title : null,
@@ -102,6 +135,8 @@ async function doCreate(args) {
102
135
  summary: typeof flags.summary === 'string' ? flags.summary : null,
103
136
  owner: typeof flags.owner === 'string' ? flags.owner : null,
104
137
  assignee: typeof flags.assignee === 'string' ? flags.assignee : null,
138
+ author: typeof flags.author === 'string' ? flags.author : null,
139
+ preferencesPaName,
105
140
  });
106
141
  process.stdout.write(`created ${res.id}\n`);
107
142
  process.stdout.write(` ${res.path}\n`);
@@ -1,18 +1,30 @@
1
1
  // work.mjs — write-side commands for `pa work <verb>`.
2
2
  //
3
- // Source of truth is work/w-YYYY-MM-DD-NNN.json. We never cache.
3
+ // Source of truth is work/w-YYYY-MM-DD-*.json. We never cache.
4
4
  //
5
5
  // What lives here:
6
- // - create: scaffold a new work item with the next-free NNN for today
6
+ // - create: scaffold a new work item with the next-free NNN in the
7
+ // CURRENT WRITER'S per-day sequence
7
8
  // - status: change status (stamps `completedAt` on done)
8
9
  // - list: list work items, optionally filtered
9
10
  //
10
- // ID format and status/category enums match
11
- // packages/core/schema/work-item.json.
11
+ // Id format, parsing, and author resolution all live in
12
+ // `@stravica-ai/engineering-pa-core/src/workItemId/index.mjs` — the
13
+ // single-source primitive for the whole estate. See its header for the
14
+ // two legal forms (legacy + writer-scoped), the R1 remediation
15
+ // background, and the author-token precedence rules.
12
16
 
13
17
  import { existsSync } from 'node:fs';
14
18
  import { join } from 'node:path';
15
19
 
20
+ import {
21
+ parseWorkItemId,
22
+ isValidWorkItemId,
23
+ buildWorkItemId,
24
+ resolveAuthorToken,
25
+ maxNForAuthorOnDate,
26
+ } from '../../../engineering-pa-core/src/workItemId/index.mjs';
27
+
16
28
  import { getRepoRoot } from './repo-root.mjs';
17
29
  import { readJson, writeJson, ensureDir, listJsonFiles, today } from './io.mjs';
18
30
 
@@ -20,8 +32,6 @@ const STATUSES = new Set(['draft', 'planned', 'in-progress', 'blocked', 'done',
20
32
 
21
33
  const CATEGORIES = new Set(['feature', 'bug', 'chore', 'discussion', 'research']);
22
34
 
23
- const ID_RE = /^w-(\d{4}-\d{2}-\d{2})-(\d{3})$/;
24
-
25
35
  function validateStatus(status) {
26
36
  if (!STATUSES.has(status)) {
27
37
  const err = new Error(
@@ -53,38 +63,44 @@ function validatePriority(priority) {
53
63
  }
54
64
 
55
65
  /**
56
- * Scan work/*.json for the highest NNN with today's date. Returns the
57
- * next free NNN, zero-padded to 3 digits. Result is always "001"–"999".
66
+ * Highest NNN already allocated for `dateStr` in THIS author's per-day
67
+ * sequence. Legacy items (`w-YYYY-MM-DD-NNN` with no author segment)
68
+ * do not count toward any author's sequence — each writer starts from
69
+ * 001 on the day writer-scoping takes effect and existing legacy items
70
+ * simply occupy a separate, older space.
58
71
  *
59
72
  * If 999 is already taken, throws — callers should treat as an
60
- * operational failure and report. In practice this is unreachable.
73
+ * operational failure and report. In practice this is unreachable
74
+ * because NNN is per-author-per-day, not shared.
61
75
  */
62
- async function nextWorkNumber(repoRoot, dateStr) {
76
+ async function nextWorkNumber(repoRoot, dateStr, author) {
63
77
  const dir = join(repoRoot, 'work');
64
78
  const files = await listJsonFiles(dir);
65
- let max = 0;
66
- const prefix = `w-${dateStr}-`;
67
- for (const file of files) {
68
- if (!file.startsWith(prefix)) continue;
69
- const m = file.match(/^w-\d{4}-\d{2}-\d{2}-(\d{3})\.json$/);
70
- if (!m) continue;
71
- const n = parseInt(m[1], 10);
72
- if (Number.isFinite(n) && n > max) max = n;
73
- }
79
+ const max = maxNForAuthorOnDate(files, dateStr, author);
74
80
  const next = max + 1;
75
81
  if (next > 999) {
76
- const err = new Error(`no free work-item slot for ${dateStr} (NNN exhausted)`);
82
+ const err = new Error(
83
+ `no free work-item slot for ${dateStr} in author=${author} sequence (NNN exhausted)`,
84
+ );
77
85
  err.exitCode = 2;
78
86
  throw err;
79
87
  }
80
- return String(next).padStart(3, '0');
88
+ return next;
81
89
  }
82
90
 
83
91
  /**
84
92
  * Create a new work item. Returns { id, path }.
85
93
  *
86
94
  * Status defaults to "draft" per the brief. Date and NNN are
87
- * auto-derived from today; we do not accept overrides.
95
+ * auto-derived; we do not accept overrides.
96
+ *
97
+ * Author token comes from `opts.author` (explicit; wins over anything
98
+ * else), the `PA_AUTHOR_TOKEN` env var, or the `paName` field of
99
+ * `operator/config/preferences.json` (passed in as
100
+ * `opts.preferencesPaName` by the CLI entry). If none resolve, throws
101
+ * with an actionable message naming all three sources — a mint with no
102
+ * author identity is the exact defect R1 exists to close, so we refuse
103
+ * to fall back to any hard-coded default.
88
104
  */
89
105
  export async function createWork(opts) {
90
106
  if (!opts.title) {
@@ -95,13 +111,24 @@ export async function createWork(opts) {
95
111
  validateCategory(opts.category);
96
112
  validatePriority(opts.priority);
97
113
 
114
+ let author;
115
+ try {
116
+ author = resolveAuthorToken({
117
+ explicit: opts.author,
118
+ preferencesPaName: opts.preferencesPaName,
119
+ });
120
+ } catch (e) {
121
+ e.exitCode = 1;
122
+ throw e;
123
+ }
124
+
98
125
  const repoRoot = await getRepoRoot();
99
126
  const workDir = join(repoRoot, 'work');
100
127
  await ensureDir(workDir);
101
128
 
102
129
  const dateStr = today();
103
- const nnn = await nextWorkNumber(repoRoot, dateStr);
104
- const id = `w-${dateStr}-${nnn}`;
130
+ const nnn = await nextWorkNumber(repoRoot, dateStr, author);
131
+ const id = buildWorkItemId({ date: dateStr, author, nnn });
105
132
  const path = join(workDir, `${id}.json`);
106
133
 
107
134
  const item = {
@@ -127,10 +154,16 @@ export async function createWork(opts) {
127
154
  /**
128
155
  * Set work-item status. Stamps `completedAt` to today's date when
129
156
  * status=done (only if not already set). Always bumps `updated`.
157
+ *
158
+ * Accepts both legacy `w-YYYY-MM-DD-NNN` and writer-scoped
159
+ * `w-YYYY-MM-DD-<author>-NNN` ids — every existing item in the estate
160
+ * predates R1 and must remain mutable through this command.
130
161
  */
131
162
  export async function setWorkStatus(id, status) {
132
- if (!ID_RE.test(id)) {
133
- const err = new Error(`invalid work id: ${id} (expected w-YYYY-MM-DD-NNN)`);
163
+ if (!isValidWorkItemId(id)) {
164
+ const err = new Error(
165
+ `invalid work id: ${id} (expected w-YYYY-MM-DD-NNN or w-YYYY-MM-DD-<author>-NNN)`,
166
+ );
134
167
  err.exitCode = 1;
135
168
  throw err;
136
169
  }
@@ -157,6 +190,12 @@ export async function setWorkStatus(id, status) {
157
190
  * List work items. Filters: status, category, project, owner, assignee,
158
191
  * mine, theirs, all, paName (used for --mine resolution).
159
192
  * By default excludes done + archived (mirrors desk-list semantics).
193
+ *
194
+ * Reads any well-formed work item (both id forms). Skips files whose
195
+ * filename does not parse as a work-item id at all — the same
196
+ * skip-unparseable posture the original had, restated against the
197
+ * shared parser so an in-directory README or a stray temp file cannot
198
+ * poison the listing.
160
199
  */
161
200
  export async function listWork(filters = {}) {
162
201
  const repoRoot = await getRepoRoot();
@@ -164,10 +203,11 @@ export async function listWork(filters = {}) {
164
203
  const files = await listJsonFiles(dir);
165
204
  const items = [];
166
205
  for (const f of files) {
206
+ if (!parseWorkItemId(f)) continue;
167
207
  try {
168
208
  items.push(await readJson(join(dir, f)));
169
209
  } catch {
170
- // Skip unparseable.
210
+ // Skip unparseable JSON contents.
171
211
  }
172
212
  }
173
213
  let filtered = items;
@@ -198,3 +198,91 @@ export async function rebaseContinue(runner, { cwd }) {
198
198
  { cwd, env: { ...process.env, GIT_EDITOR: 'true' } },
199
199
  );
200
200
  }
201
+
202
+ // Resolve a ref (default HEAD) to its full sha. Returns { code, sha, stderr }.
203
+ // The self-heal path uses this both to record the sha of estate-sync's own
204
+ // commit at commit time AND to verify HEAD is still that sha immediately
205
+ // before dropping it — the guard that stops the reset ever touching a
206
+ // commit we did not just make.
207
+ export async function revParseSha(runner, { cwd, ref = 'HEAD' } = {}) {
208
+ const r = await runner.spawn('git', ['rev-parse', '--verify', `${ref}^{commit}`], { cwd });
209
+ return { code: r.code, sha: r.stdout.trim(), stderr: r.stderr };
210
+ }
211
+
212
+ // Count commits in a range (e.g. HEAD^..HEAD). Returns { code, count, stderr }.
213
+ // count === 1 is the drop-guard invariant: exactly one commit between HEAD's
214
+ // parent and HEAD (never a merge-commit stack, never zero, never more).
215
+ export async function revListCount(runner, { cwd, range }) {
216
+ const r = await runner.spawn('git', ['rev-list', '--count', range], { cwd });
217
+ const n = Number.parseInt(r.stdout.trim(), 10);
218
+ return { code: r.code, count: Number.isFinite(n) ? n : null, stderr: r.stderr };
219
+ }
220
+
221
+ // git reset --soft <ref>. Moves HEAD to <ref>, leaves BOTH the index and
222
+ // the working tree untouched. This is the load-bearing primitive for the
223
+ // R2 self-heal path: after dropping our commit, the non-conflicting
224
+ // portion of that commit's diff MUST remain staged in the index so the
225
+ // next tick recommits it. --mixed would unstage (still fine for our
226
+ // purposes but noisier), --hard would obliterate everything, and --keep
227
+ // (which we originally used, corrected 2026-08-12 per the reviewer's
228
+ // blocker on PR #120) actively UPDATES the working tree to match <ref>
229
+ // on every file that differs between HEAD and <ref> — meaning every
230
+ // non-conflicting path in the dropped commit gets silently destroyed.
231
+ // --soft leaves the surgical work to the caller: keep what we want,
232
+ // remove or restore only the conflicting paths we've already queued.
233
+ export async function resetSoft(runner, { cwd, ref }) {
234
+ return runner.spawn('git', ['reset', '--soft', ref], { cwd });
235
+ }
236
+
237
+ // git rm --cached -- <path>. Unstages a single path — removes it from the
238
+ // index without touching the working tree. Paired with `fs.unlink` in
239
+ // self-heal to surgically un-stage a newly-added conflicting path after
240
+ // the --soft reset (the addition is queued to pending, so we want the
241
+ // path GONE from the index AND from disk without touching any other
242
+ // staged content).
243
+ export async function rmCached(runner, { cwd, paths }) {
244
+ if (paths.length === 0) return { code: 0, stdout: '', stderr: '' };
245
+ return runner.spawn('git', ['rm', '--cached', '--', ...paths], { cwd });
246
+ }
247
+
248
+ // git hash-object <path>. Computes git's own blob sha for the byte
249
+ // contents of a file, WITHOUT staging or writing anything to the object
250
+ // store. Used by the drain to compare a pending file's bytes against
251
+ // `origin/<branch>:<path>` via matching sha values — binary-safe, no
252
+ // utf8 round-trip, and apples-to-apples (git blob sha both sides).
253
+ export async function hashObject(runner, { cwd, path }) {
254
+ const r = await runner.spawn('git', ['hash-object', '--', path], { cwd });
255
+ return { code: r.code, sha: r.stdout.trim(), stderr: r.stderr };
256
+ }
257
+
258
+ // Does <ref>:<path> exist? `git cat-file -e` exits 0 if yes, non-zero if no.
259
+ // Used by self-heal to decide the working-tree cleanup shape:
260
+ // - path existed at HEAD^ → checkout HEAD^ to restore parent's version
261
+ // - path did NOT exist there → the file was newly added by our commit;
262
+ // remove it from the working tree entirely
263
+ // Getting this branch right is what stops the wedge-loop where our own
264
+ // content, still in the tree after reset, re-conflicts on every tick.
265
+ export async function refPathExists(runner, { cwd, ref, path }) {
266
+ const r = await runner.spawn('git', ['cat-file', '-e', `${ref}:${path}`], { cwd });
267
+ return r.code === 0;
268
+ }
269
+
270
+ // git checkout <ref> -- <paths>. Copies the ref's version of each path into
271
+ // BOTH the index and the working tree. Used by self-heal after the
272
+ // reset --keep HEAD^ to overwrite the (now unstaged) conflicting paths with
273
+ // their pre-commit versions, so the next tick's step 6 doesn't re-stage the
274
+ // content we've deliberately queued out of the way.
275
+ export async function checkoutFromRef(runner, { cwd, ref, paths }) {
276
+ if (paths.length === 0) return { code: 0, stdout: '', stderr: '' };
277
+ return runner.spawn('git', ['checkout', ref, '--', ...paths], { cwd });
278
+ }
279
+
280
+ // Does the ref exist locally? Used to check origin/<branch> before we try
281
+ // to inspect it (a fresh clone with no fetches wouldn't have it). We use
282
+ // `git show-ref --verify --quiet` because it's the ref-existence idiom that
283
+ // doesn't fall back to name-rev heuristics and produces no output on either
284
+ // branch.
285
+ export async function refExists(runner, { cwd, ref }) {
286
+ const r = await runner.spawn('git', ['show-ref', '--verify', '--quiet', ref], { cwd });
287
+ return r.code === 0;
288
+ }