greprag 5.80.0 → 5.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/capture-manifest.js +2 -1
  2. package/dist/codex-fast-hook.js +6 -0
  3. package/dist/codex-steering.js +1 -1
  4. package/dist/commands/app-model.js +0 -1
  5. package/dist/commands/arm-reminder.js +9 -7
  6. package/dist/commands/collision-check.js +7 -6
  7. package/dist/commands/corpus/client.js +13 -3
  8. package/dist/commands/delivery-reminder.js +35 -14
  9. package/dist/commands/deploy-gate.js +55 -0
  10. package/dist/commands/deploy-lock.js +100 -0
  11. package/dist/commands/deploy-record.js +145 -0
  12. package/dist/commands/deploy-verify.js +111 -0
  13. package/dist/commands/inbox-primer-reminder.js +5 -5
  14. package/dist/commands/inbox-watch.js +2 -4
  15. package/dist/commands/init.js +82 -0
  16. package/dist/commands/load.js +40 -0
  17. package/dist/commands/loadout-reminder.js +1 -1
  18. package/dist/commands/merge-guard.js +419 -0
  19. package/dist/commands/merge-lock.js +176 -0
  20. package/dist/commands/parity-reminder.js +53 -0
  21. package/dist/commands/persona-reminder.js +11 -0
  22. package/dist/commands/persona.js +50 -0
  23. package/dist/commands/procedure.js +77 -6
  24. package/dist/commands/reminder-registry.js +21 -5
  25. package/dist/commands/repodoc.js +433 -0
  26. package/dist/commands/search.js +149 -0
  27. package/dist/commands/skillgain.js +33 -25
  28. package/dist/delivery-lifecycle.js +16 -1
  29. package/dist/deploy-gate.js +355 -0
  30. package/dist/deploy-locks.js +339 -0
  31. package/dist/deploy-verify.js +209 -0
  32. package/dist/env-redaction.js +157 -0
  33. package/dist/harness-limits.js +17 -0
  34. package/dist/hook-runtime.js +11 -1
  35. package/dist/hook.js +170 -88
  36. package/dist/index.js +593 -567
  37. package/dist/inline-atom-episode.js +15 -7
  38. package/dist/inline-atom.js +8 -2
  39. package/dist/native-skill-adoption.js +11 -0
  40. package/dist/native-skill-mirror.js +8 -1
  41. package/dist/node-identity.bundle.js +1166 -0
  42. package/dist/opencode-plugin.bundle.js +307 -119
  43. package/dist/procedure-enabled.js +55 -0
  44. package/dist/procedure-runtime.js +6 -0
  45. package/dist/procedure-scope.js +190 -0
  46. package/dist/procedure-watch.js +29 -16
  47. package/dist/procedure.js +111 -5
  48. package/dist/project-anchor.js +1 -14
  49. package/dist/reminder-injector.js +11 -10
  50. package/dist/repodoc-client.js +296 -0
  51. package/dist/session-id.js +7 -8
  52. package/dist/skill-landing.js +57 -2
  53. package/dist/skill-mirror-client.js +14 -0
  54. package/dist/skill-mirror-files.js +18 -0
  55. package/package.json +2 -2
  56. package/scripts/bundle-node-identity.mjs +47 -0
  57. package/skill/templates/chip-spawn.md +7 -1
  58. package/skill/templates/delivery.md +105 -0
  59. package/skill/templates/prompt-audit.md +196 -0
  60. package/skill/templates/skill-change.md +25 -2
  61. package/dist/assistant-doctrine.js +0 -85
  62. package/dist/commands/assistant-reminder.js +0 -19
  63. package/dist/commands/assistant.js +0 -95
@@ -24,6 +24,8 @@ const HELP = `greprag skillgain — Skill Learning Loop queue (docs/skill-learni
24
24
 
25
25
  USAGE
26
26
  greprag skillgain list Pending gains for this project (not yet landed).
27
+ greprag skillgain list --all Pending gains across EVERY project, labelled
28
+ by the project each came from.
27
29
  greprag skillgain reject <8hex> The UNDO: mark a gain bogus AND remove its
28
30
  landed artifacts (the fuse line in SKILL.md
29
31
  + the docs/learned/<slug>.md doc).
@@ -31,6 +33,9 @@ USAGE
31
33
  (rarely needed — landing is automatic and a
32
34
  digested fuse needs no marking).
33
35
 
36
+ reject/done address a gain by its id, which is unique across every project —
37
+ they work from any directory, no need to stand in the gain's own repo.
38
+
34
39
  Gains land automatically at session start as a briefcase (fuse line + focused
35
40
  doc) and SELF-APPLY on the skill's next invocation.`;
36
41
  async function runSkillGain(args) {
@@ -40,11 +45,14 @@ async function runSkillGain(args) {
40
45
  console.error('GREPRAG_API_KEY not set — run `greprag init` first.');
41
46
  process.exit(1);
42
47
  }
43
- const anchor = (0, project_anchor_1.readAnchor)(process.cwd());
44
48
  if (sub === 'list') {
45
- const res = await fetch(`${cfg.apiUrl}/v1/skillgain/${anchor.projectId}/pending`, {
46
- headers: { 'Authorization': `Bearer ${cfg.apiKey}` },
47
- });
49
+ // --all is tenant-wide and needs no anchor; the default view is this
50
+ // project's queue, so only that branch resolves one.
51
+ const all = args.includes('--all');
52
+ const url = all
53
+ ? `${cfg.apiUrl}/v1/skillgain/pending`
54
+ : `${cfg.apiUrl}/v1/skillgain/${(0, project_anchor_1.readAnchor)(process.cwd()).projectId}/pending`;
55
+ const res = await fetch(url, { headers: { 'Authorization': `Bearer ${cfg.apiKey}` } });
48
56
  if (!res.ok) {
49
57
  console.error(`API ${res.status}`);
50
58
  process.exit(1);
@@ -52,11 +60,14 @@ async function runSkillGain(args) {
52
60
  const data = await res.json();
53
61
  const gains = data.gains || [];
54
62
  if (gains.length === 0) {
55
- console.log('No pending skill gains.');
63
+ console.log(all ? 'No pending skill gains in any project.' : 'No pending skill gains.');
64
+ if (!all)
65
+ console.log('(this project only — `greprag skillgain list --all` covers every project)');
56
66
  return;
57
67
  }
58
68
  for (const g of gains) {
59
- console.log(`[${g.nodeId}] ${g.skill} · ${g.gainType}\n ${g.text}`);
69
+ const where = all && g.projectName ? ` · @${g.projectName}` : '';
70
+ console.log(`[${g.nodeId}] ${g.skill} · ${g.gainType}${where}\n ${g.text}`);
60
71
  }
61
72
  return;
62
73
  }
@@ -67,13 +78,13 @@ async function runSkillGain(args) {
67
78
  process.exit(1);
68
79
  }
69
80
  const status = sub === 'done' ? 'landed' : 'rejected';
70
- const res = await fetch(`${cfg.apiUrl}/v1/skillgain/${anchor.projectId}/status`, {
81
+ // Id-addressed and tenant-wide: no projectId in the path, no anchor read,
82
+ // so this resolves the gain wherever it was captured rather than only in
83
+ // the repo the operator happens to be standing in.
84
+ const res = await fetch(`${cfg.apiUrl}/v1/skillgain/status`, {
71
85
  method: 'POST',
72
86
  headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' },
73
- body: JSON.stringify({
74
- projectName: anchor.projectName,
75
- updates: [{ nodeId, status }],
76
- }),
87
+ body: JSON.stringify({ updates: [{ nodeId, status }] }),
77
88
  });
78
89
  if (!res.ok) {
79
90
  console.error(`API ${res.status}`);
@@ -81,24 +92,21 @@ async function runSkillGain(args) {
81
92
  }
82
93
  const data = await res.json();
83
94
  if ((data.updated || 0) === 0) {
84
- console.error(`[${nodeId}] not found in ${anchor.projectName}'s skillgain queue.`);
95
+ console.error(`[${nodeId}] no such skill gain in this tenant.`);
96
+ console.error('List what is pending with `greprag skillgain list --all`.');
85
97
  process.exit(1);
86
98
  }
87
- console.log(`[${nodeId}] → ${status}`);
99
+ const hit = (data.resolved || [])[0];
100
+ console.log(`[${nodeId}] → ${status}${hit?.projectName ? ` (from @${hit.projectName})` : ''}`);
88
101
  // Reject is the UNDO: also remove any landed artifacts (the fuse line in
89
- // SKILL.md + the docs/learned/<slug>.md doc). Skill name via the record.
90
- if (sub === 'reject') {
102
+ // SKILL.md + the docs/learned/<slug>.md doc). The status call already told
103
+ // us the skill, so this needs no second lookup.
104
+ if (sub === 'reject' && hit?.skill) {
91
105
  try {
92
- const gr = await fetch(`${cfg.apiUrl}/v1/skillgain/${anchor.projectId}/gain/${nodeId}`, { headers: { 'Authorization': `Bearer ${cfg.apiKey}` } });
93
- if (gr.ok) {
94
- const gd = await gr.json();
95
- if (gd.gain?.skill) {
96
- const homeDir = process.env.HOME || process.env.USERPROFILE || '';
97
- const removed = (0, skill_landing_1.removeLandedGain)(gd.gain.skill, nodeId, process.cwd(), homeDir);
98
- if (removed)
99
- console.log(` removed landed artifacts from ${gd.gain.skill}`);
100
- }
101
- }
106
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
107
+ const removed = (0, skill_landing_1.removeLandedGain)(hit.skill, nodeId, process.cwd(), homeDir);
108
+ if (removed)
109
+ console.log(` removed landed artifacts from ${hit.skill}`);
102
110
  }
103
111
  catch { /* best-effort — the stamp makes manual cleanup findable */ }
104
112
  }
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  /** Lifecycle verbs governed by the announce-first Delivery System pilot. */
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.DELIVERY_LIFECYCLE_VERBS = void 0;
4
+ exports.DELIVERY_OWNED_VERBS = exports.DELIVERY_LIFECYCLE_VERBS = void 0;
5
5
  exports.isDeliveryLifecycleVerb = isDeliveryLifecycleVerb;
6
+ exports.isDeliveryOwnedVerb = isDeliveryOwnedVerb;
6
7
  exports.DELIVERY_LIFECYCLE_VERBS = [
7
8
  'commit', 'merge', 'push', 'deploy', 'release',
8
9
  ];
@@ -10,3 +11,17 @@ const DELIVERY_LIFECYCLE_SET = new Set(exports.DELIVERY_LIFECYCLE_VERBS);
10
11
  function isDeliveryLifecycleVerb(verb) {
11
12
  return DELIVERY_LIFECYCLE_SET.has((verb || '').trim().toLowerCase());
12
13
  }
14
+ /** Verbs whose recipe the repo's **Delivery Profile document** owns. A procedure
15
+ * row for one of these may POINT at a profile section; it may never hold a copy
16
+ * of that section's text, be hand-registered with steps, or be written by the
17
+ * LEARN leg. The five lifecycle verbs qualify by the pilot. `verify` qualifies
18
+ * because "is my change live" is answered by the deploy document's own liveness
19
+ * section — it is the second half of deploy, not a separate recipe.
20
+ * adr: adr/delivery-owned-procedures.md */
21
+ exports.DELIVERY_OWNED_VERBS = [
22
+ ...exports.DELIVERY_LIFECYCLE_VERBS, 'verify',
23
+ ];
24
+ const DELIVERY_OWNED_SET = new Set(exports.DELIVERY_OWNED_VERBS);
25
+ function isDeliveryOwnedVerb(verb) {
26
+ return DELIVERY_OWNED_SET.has((verb || '').trim().toLowerCase());
27
+ }
@@ -0,0 +1,355 @@
1
+ "use strict";
2
+ /** deploy-gate.ts — the harness-independent half of a repo's deploy gate.
3
+ *
4
+ * WHY THIS EXISTS. `scripts/lib/canonical-deploy.mjs` carries a delimited
5
+ * "shared block" that is byte-identical in C:/sift and C:/suppliersift-site,
6
+ * held in sync by a sha256 literal in each repo's contract test. That detects
7
+ * drift; it does not prevent it, and it makes onboarding a third repo cost a
8
+ * ~380-line paste plus a hash. Everything in that block answers questions that
9
+ * do not depend on how a target's artifact is produced — WHERE a deploy may run
10
+ * from, WHETHER one is already running, WHAT is shipping — so it belongs in one
11
+ * place that every repo already has: this CLI.
12
+ *
13
+ * WHAT STAYS IN THE REPO. The artifact proof. The worker guards its working
14
+ * tree because wrangler bundles it from disk; the site guards `dist/` because
15
+ * it uploads a directory built earlier. Those are different objects and the
16
+ * gate never pretends to know them — it reports `artifact` as repo-owned and
17
+ * the caller supplies the proof.
18
+ *
19
+ * READ-ONLY BY CONSTRUCTION. Nothing here acquires a lock, writes a ledger, or
20
+ * mutates a checkout. That is what makes it safe to run in shadow beside a
21
+ * live gate: a shadow run must never be able to affect the deploy it observes.
22
+ * Acquiring the lock is the caller's job and stays in the repo until the flip.
23
+ *
24
+ * # adr: adr/deploy-gate-cli.md
25
+ */
26
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ var desc = Object.getOwnPropertyDescriptor(m, k);
29
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
30
+ desc = { enumerable: true, get: function() { return m[k]; } };
31
+ }
32
+ Object.defineProperty(o, k2, desc);
33
+ }) : (function(o, m, k, k2) {
34
+ if (k2 === undefined) k2 = k;
35
+ o[k2] = m[k];
36
+ }));
37
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
38
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
39
+ }) : function(o, v) {
40
+ o["default"] = v;
41
+ });
42
+ var __importStar = (this && this.__importStar) || (function () {
43
+ var ownKeys = function(o) {
44
+ ownKeys = Object.getOwnPropertyNames || function (o) {
45
+ var ar = [];
46
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
47
+ return ar;
48
+ };
49
+ return ownKeys(o);
50
+ };
51
+ return function (mod) {
52
+ if (mod && mod.__esModule) return mod;
53
+ var result = {};
54
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
55
+ __setModuleDefault(result, mod);
56
+ return result;
57
+ };
58
+ })();
59
+ Object.defineProperty(exports, "__esModule", { value: true });
60
+ exports.LOCATION_CHECKS = exports.DEPLOY_GATE_VERSION = void 0;
61
+ exports.normalizePath = normalizePath;
62
+ exports.shippingSummaryLines = shippingSummaryLines;
63
+ exports.evaluateDeployGate = evaluateDeployGate;
64
+ const fs = __importStar(require("fs"));
65
+ const path = __importStar(require("path"));
66
+ const proc_1 = require("./proc");
67
+ const delivery_config_1 = require("./delivery-config");
68
+ const deploy_locks_1 = require("./deploy-locks");
69
+ /** Bump when a check is added, removed, or changes verdict for the same repo state.
70
+ * The shadow log records it, so a disagreement can be attributed to a version
71
+ * rather than to the repo, and the flip can assert a floor.
72
+ *
73
+ * v2 — added `merge-state` and `merge-lock`.
74
+ * v3 — added `provenance`.
75
+ * v4 — canonicalRoot is optional; absent means a ref-shipping repo, where the branch
76
+ * check also accepts a tip that is a fast-forward of the remote default branch. */
77
+ exports.DEPLOY_GATE_VERSION = 4;
78
+ /** The checks the repo gates already perform for themselves. The shadow harness
79
+ * compares against THIS subset, so a refusal on a check no repo gate has yet is
80
+ * recorded as the CLI being stricter rather than as the two gates disagreeing. */
81
+ exports.LOCATION_CHECKS = ['profile', 'canonical-root', 'primary-checkout', 'branch'];
82
+ function normalizePath(value) {
83
+ return path.resolve(value).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
84
+ }
85
+ function git(args, cwd) {
86
+ const result = (0, proc_1.safeSpawnSync)('git', args, {
87
+ encoding: 'utf8', cwd, stdio: ['ignore', 'pipe', 'pipe'],
88
+ });
89
+ if (result.status !== 0)
90
+ throw new Error(`git ${args.join(' ')} failed`);
91
+ return String(result.stdout || '').trim();
92
+ }
93
+ /** The ledger lives inside `.git` beside the locks, so it is never tree dirt and
94
+ * can never be committed. The `sift-` basename is the name already on disk in
95
+ * both repos — the gate reads the real file rather than a parallel one. */
96
+ const LOCK_PREFIX = process.env.GREPRAG_DEPLOY_LOCK_PREFIX || 'sift';
97
+ function lastDeployPath(cwd, target) {
98
+ return path.join(git(['rev-parse', '--absolute-git-dir'], cwd), `${LOCK_PREFIX}-last-deploy-${target}.json`);
99
+ }
100
+ function minutes(ms) {
101
+ return Number.isFinite(ms) ? `${Math.round(ms / 60000)}m` : 'unknown age';
102
+ }
103
+ /** Pure: turn `git log` porcelain into the lines a deployer should read before
104
+ * uploading. Each line is `<sha>\t<author date ISO>\t<author>: <subject>`.
105
+ *
106
+ * A commit authored BEFORE the last deploy but only shipping now is
107
+ * late-arriving code — the signature of a stale branch merged long after it was
108
+ * cut. Author date is the right clock: rebase and cherry-pick rewrite the
109
+ * committer date, so only the author date remembers when the code was written.
110
+ * A notice, never a gate. */
111
+ function shippingSummaryLines(logOutput, lastSha, sinceIso) {
112
+ const commits = String(logOutput || '')
113
+ .split('\n')
114
+ .map(line => line.trim())
115
+ .filter(line => line.length > 0)
116
+ .map(line => {
117
+ const [sha, authoredIso, rest] = line.split('\t');
118
+ return rest === undefined
119
+ ? { text: line, authoredIso: null }
120
+ : { text: `${sha} ${rest}`, authoredIso };
121
+ });
122
+ if (!lastSha)
123
+ return ['no record of a previous deploy from this checkout; shipping HEAD as-is'];
124
+ if (commits.length === 0)
125
+ return [`nothing new since the last deploy from here (${lastSha.slice(0, 10)})`];
126
+ const since = sinceIso ? Date.parse(sinceIso) : NaN;
127
+ const stale = Number.isNaN(since)
128
+ ? []
129
+ : commits.filter(c => c.authoredIso && Date.parse(c.authoredIso) < since);
130
+ return [
131
+ `shipping ${commits.length} commit(s) since the last deploy from here (${lastSha.slice(0, 10)}):`,
132
+ ...commits.map(c => ` ${c.text}`),
133
+ ...(stale.length
134
+ ? [
135
+ `note: ${stale.length} of these were written before that deploy — old code arriving late.`,
136
+ ' Expected when sweeping an older branch. Unexpected here means a stale merge.',
137
+ ]
138
+ : []),
139
+ ];
140
+ }
141
+ function shippingSummary(cwd, target) {
142
+ let lastSha = null;
143
+ try {
144
+ const record = JSON.parse(fs.readFileSync(lastDeployPath(cwd, target), 'utf8'));
145
+ lastSha = typeof record?.sha === 'string' && record.sha ? record.sha : null;
146
+ }
147
+ catch {
148
+ lastSha = null;
149
+ }
150
+ if (!lastSha)
151
+ return shippingSummaryLines('', null);
152
+ try {
153
+ const log = git(['log', '--no-merges', '--format=%h%x09%aI%x09%an: %s', `${lastSha}..HEAD`], cwd);
154
+ const sinceIso = git(['log', '-1', '--format=%aI', lastSha], cwd);
155
+ return shippingSummaryLines(log, lastSha, sinceIso);
156
+ }
157
+ catch {
158
+ // The recorded sha can vanish from history after a reset or a force-push.
159
+ return [`previous deploy ${lastSha.slice(0, 10)} is no longer in history; shipping HEAD as-is`];
160
+ }
161
+ }
162
+ /**
163
+ * Run every repo-independent deploy check and report, without deciding anything.
164
+ *
165
+ * Never throws: an internal failure becomes a failing check, because a gate that
166
+ * crashes instead of refusing is indistinguishable from a gate that passed.
167
+ */
168
+ function evaluateDeployGate(options = {}) {
169
+ const cwd = path.resolve(options.cwd || process.cwd());
170
+ const target = options.target || 'default';
171
+ const checks = [];
172
+ const add = (id, status, detail) => { checks.push({ id, status, detail }); };
173
+ let canonicalRoot = null;
174
+ let defaultBranch = null;
175
+ let branch = null;
176
+ let head = null;
177
+ let shipping = [];
178
+ const resolved = (0, delivery_config_1.resolveDeliveryProfile)(cwd);
179
+ if (resolved.mode !== 'profile' || !resolved.profile) {
180
+ add('profile', 'fail', `no valid delivery profile (${resolved.mode}): ${resolved.errors.join('; ') || 'none'}`);
181
+ return finish();
182
+ }
183
+ defaultBranch = resolved.profile.git.defaultBranch;
184
+ // The env override exists so a rescue can deploy from a relocated checkout.
185
+ // It is deliberately loud in the verdict rather than silent.
186
+ const override = process.env.GREPRAG_DEPLOY_CANONICAL_ROOT || process.env.SIFT_CANONICAL_ROOT || '';
187
+ canonicalRoot = override || resolved.profile.deploy?.canonicalRoot || null;
188
+ add('profile', 'pass', `${resolved.configPath}${override ? ' (canonicalRoot overridden by env)' : ''}`);
189
+ if (!canonicalRoot) {
190
+ // A profile with no canonicalRoot is a DECLARATION, not an omission: this repo
191
+ // ships committed refs, not a working tree. The canonical-checkout rule exists
192
+ // because a bundler reads files off disk, so a worktree could ship unreleased
193
+ // edits — `git push` cannot do that, it moves a ref. Paybot's own deploy doc
194
+ // explicitly allows pushing from a delivery worktree when it is a fast-forward,
195
+ // and refusing that would impose a disk-artifact rule on a repo with no disk
196
+ // artifact. Everything else — the locks, merge state, provenance — still applies.
197
+ add('canonical-root', 'skip', 'no canonicalRoot declared: this repo ships committed refs, not a working tree');
198
+ add('primary-checkout', 'skip', 'not applicable without a canonicalRoot');
199
+ }
200
+ else if (normalizePath(cwd) !== normalizePath(canonicalRoot)) {
201
+ add('canonical-root', 'fail', `refusing to deploy from ${cwd}. Worktrees are for building. Merge to ${defaultBranch}, then deploy from ${canonicalRoot}.`);
202
+ }
203
+ else {
204
+ add('canonical-root', 'pass', canonicalRoot);
205
+ }
206
+ // Only meaningful for a repo that ships a working tree; skipped above otherwise.
207
+ if (canonicalRoot)
208
+ try {
209
+ // Branch name alone is not enough: a clone has its own default branch, and a
210
+ // linked worktree can sit on one too. The primary checkout is the one whose
211
+ // git dir IS the common dir.
212
+ const gitDir = git(['rev-parse', '--absolute-git-dir'], cwd);
213
+ const common = path.resolve(cwd, git(['rev-parse', '--git-common-dir'], cwd));
214
+ if (normalizePath(gitDir) !== normalizePath(common)) {
215
+ add('primary-checkout', 'fail', `refusing to deploy from a git worktree at ${cwd}. Merge to ${defaultBranch}, then deploy from ${canonicalRoot || 'the canonical checkout'}.`);
216
+ }
217
+ else {
218
+ add('primary-checkout', 'pass', gitDir);
219
+ }
220
+ }
221
+ catch (error) {
222
+ add('primary-checkout', 'fail', message(error));
223
+ }
224
+ try {
225
+ head = git(['rev-parse', 'HEAD'], cwd);
226
+ branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
227
+ if (process.env.GREPRAG_DEPLOY_ALLOW_BRANCH === '1' || process.env.SIFT_DEPLOY_ALLOW_BRANCH === '1') {
228
+ add('branch', 'skip', `branch gate disabled by env (on ${branch})`);
229
+ }
230
+ else if (branch === defaultBranch) {
231
+ add('branch', 'pass', branch);
232
+ }
233
+ else if (!canonicalRoot && defaultBranch
234
+ && (0, deploy_locks_1.isAncestorOfHead)(cwd, `${resolved.profile.git.remote || 'origin'}/${defaultBranch}`)) {
235
+ // Same reasoning as canonical-root: this repo ships a ref, and the guarantee that
236
+ // matters is that the ref only moves forward. A tip that already contains the
237
+ // remote's is exactly the fast-forward paybot's deploy doc allows from a delivery
238
+ // worktree. The remote-tracking ref can be stale, which is why this is the softer
239
+ // half — git itself refuses a non-fast-forward push, and that is the hard backstop.
240
+ add('branch', 'pass', `${branch} — not ${defaultBranch}, but a fast-forward of it, so the push only moves the ref forward`);
241
+ }
242
+ else {
243
+ add('branch', 'fail', `refusing to deploy branch ${branch}. Merge to ${defaultBranch}, then deploy from ${canonicalRoot || 'the canonical checkout'}.`);
244
+ }
245
+ }
246
+ catch (error) {
247
+ add('branch', 'fail', message(error));
248
+ }
249
+ if (options.ignoreLock) {
250
+ add('lock', 'skip', 'caller holds the lock for this target');
251
+ }
252
+ else {
253
+ try {
254
+ const held = (0, deploy_locks_1.readLock)((0, deploy_locks_1.deployLockPath)(cwd, target));
255
+ if (!held)
256
+ add('lock', 'pass', 'no deploy lock held');
257
+ else if (held.stale)
258
+ add('lock', 'pass', 'a stale lock is present and would be reclaimed');
259
+ else {
260
+ add('lock', 'fail', `another build or deploy is running in this checkout (pid ${held.record.pid}, session ${(0, deploy_locks_1.describeLockOwner)(held.record)}, started ${held.record.startedAt}).`);
261
+ }
262
+ }
263
+ catch (error) {
264
+ add('lock', 'pass', `no readable deploy lock (${message(error)})`);
265
+ }
266
+ }
267
+ // A merge in progress means the working tree is mid-resolution: a deploy from
268
+ // here ships neither side cleanly. Git knows this exactly, and on 2026-09-04
269
+ // nothing asked it — a dead chip left the canonical checkout conflicted and
270
+ // three sessions rediscovered it one at a time.
271
+ try {
272
+ const merge = (0, deploy_locks_1.readMergeState)(cwd);
273
+ const mergeHeld = (0, deploy_locks_1.readMergeLock)(cwd);
274
+ if (!merge.inProgress) {
275
+ add('merge-state', 'pass', 'no merge, rebase, cherry-pick or revert in progress');
276
+ }
277
+ else {
278
+ const unresolved = merge.unresolved.length
279
+ ? `${merge.unresolved.length} unresolved path(s): ${merge.unresolved.slice(0, 5).join(', ')}`
280
+ : 'resolved but not committed';
281
+ // No lock behind an open merge is the abandoned signature: either its owner
282
+ // died, or it was started by hand and nobody can be asked about it.
283
+ const owner = mergeHeld && !mergeHeld.stale
284
+ ? `held by pid ${mergeHeld.record.pid}, session ${(0, deploy_locks_1.describeLockOwner)(mergeHeld.record)}`
285
+ : 'NOBODY HOLDS THE MERGE LOCK — the owner died or never took one';
286
+ add('merge-state', 'fail', `a ${merge.kind} has been open in this checkout for ${minutes(merge.ageMs)} (${unresolved}). ${owner}. Finish or abort it before deploying.`);
287
+ }
288
+ if (mergeHeld && !mergeHeld.stale) {
289
+ // Says how to clear it: this check BLOCKS deploys as of the flip, and the
290
+ // lock is session-held, so a session that dies without releasing would
291
+ // otherwise stop every deploy in this checkout until the hour ceiling.
292
+ add('merge-lock', 'fail', `a merge is running in this checkout (pid ${mergeHeld.record.pid}, session ${(0, deploy_locks_1.describeLockOwner)(mergeHeld.record)}, started ${mergeHeld.record.startedAt}, ${minutes(mergeHeld.ageMs)}). Deploying now would ship a half-integrated tree. Wait for it, or if you know that session is gone: greprag merge-lock release`);
293
+ }
294
+ else {
295
+ add('merge-lock', 'pass', mergeHeld ? 'a stale merge lock is present and would be reclaimed' : 'no merge lock held');
296
+ }
297
+ }
298
+ catch (error) {
299
+ add('merge-state', 'fail', `could not read merge state: ${message(error)}`);
300
+ }
301
+ // PROVENANCE. Every other check asks whether this deploy may run. This one asks a
302
+ // different question: is the work somebody merged actually IN it?
303
+ //
304
+ // On 2026-09-04 a session merged, a peer resolved the same conflicted checkout and
305
+ // completed the merge itself under a new sha, and the first session's merge commit
306
+ // stopped existing on any branch. Its deploy exited 0 honestly — production really
307
+ // was serving HEAD — and it reported shipping work that was not there. Nothing in
308
+ // the deploy path asked. A deploy proving production serves HEAD says nothing about
309
+ // whether HEAD contains what you meant to ship.
310
+ try {
311
+ const unshipped = (0, deploy_locks_1.openClaims)(cwd, (0, deploy_locks_1.readLandedClaims)(cwd));
312
+ if (unshipped.length === 0) {
313
+ add('provenance', 'pass', 'no unshipped landed claims');
314
+ }
315
+ else {
316
+ for (const claim of unshipped) {
317
+ // The two states read identically to a session and mean different things: a
318
+ // commit still in the object store was rewritten or orphaned and its CONTENT
319
+ // may well have landed under another sha, while one that is gone never landed
320
+ // at all. Say which, because the next move differs.
321
+ const fate = (0, deploy_locks_1.commitExists)(cwd, claim.sha)
322
+ ? 'it still exists but is on no branch — it was rewritten or its merge was redone, so the CONTENT may have landed under a different sha. Confirm, then: greprag merge-lock claim --drop ' + claim.sha.slice(0, 10)
323
+ : 'it is not in this repository at all — that work never landed.';
324
+ add('provenance', 'fail', `${claim.label} (${claim.sha.slice(0, 10)}, claimed ${claim.at}) is NOT in what is about to ship: ${fate}`);
325
+ }
326
+ }
327
+ }
328
+ catch (error) {
329
+ add('provenance', 'fail', `could not check landed claims: ${message(error)}`);
330
+ }
331
+ // Named so its absence is visible. The gate does not know how this repo's
332
+ // artifact is produced and must never guess.
333
+ add('artifact', 'skip', 'artifact proof is repo-owned; the caller supplies it');
334
+ try {
335
+ shipping = shippingSummary(cwd, target);
336
+ }
337
+ catch (error) {
338
+ shipping = [`could not summarise what is shipping: ${message(error)}`];
339
+ }
340
+ return finish();
341
+ function finish() {
342
+ const failed = checks.filter(c => c.status === 'fail');
343
+ const locationFailed = failed.filter(c => exports.LOCATION_CHECKS.includes(c.id));
344
+ return {
345
+ gateVersion: exports.DEPLOY_GATE_VERSION,
346
+ verdict: failed.length === 0 ? 'pass' : 'refuse',
347
+ locationVerdict: locationFailed.length === 0 ? 'pass' : 'refuse',
348
+ target, cwd, canonicalRoot, defaultBranch, branch, head,
349
+ checks, reasons: failed.map(c => `${c.id}: ${c.detail}`), shipping,
350
+ };
351
+ }
352
+ }
353
+ function message(error) {
354
+ return error instanceof Error ? error.message : String(error);
355
+ }