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.
- package/dist/capture-manifest.js +2 -1
- package/dist/codex-fast-hook.js +6 -0
- package/dist/codex-steering.js +1 -1
- package/dist/commands/app-model.js +0 -1
- package/dist/commands/arm-reminder.js +9 -7
- package/dist/commands/collision-check.js +7 -6
- package/dist/commands/corpus/client.js +13 -3
- package/dist/commands/delivery-reminder.js +35 -14
- package/dist/commands/deploy-gate.js +55 -0
- package/dist/commands/deploy-lock.js +100 -0
- package/dist/commands/deploy-record.js +145 -0
- package/dist/commands/deploy-verify.js +111 -0
- package/dist/commands/inbox-primer-reminder.js +5 -5
- package/dist/commands/inbox-watch.js +2 -4
- package/dist/commands/init.js +82 -0
- package/dist/commands/load.js +40 -0
- package/dist/commands/loadout-reminder.js +1 -1
- package/dist/commands/merge-guard.js +419 -0
- package/dist/commands/merge-lock.js +176 -0
- package/dist/commands/parity-reminder.js +53 -0
- package/dist/commands/persona-reminder.js +11 -0
- package/dist/commands/persona.js +50 -0
- package/dist/commands/procedure.js +77 -6
- package/dist/commands/reminder-registry.js +21 -5
- package/dist/commands/repodoc.js +433 -0
- package/dist/commands/search.js +149 -0
- package/dist/commands/skillgain.js +33 -25
- package/dist/delivery-lifecycle.js +16 -1
- package/dist/deploy-gate.js +355 -0
- package/dist/deploy-locks.js +339 -0
- package/dist/deploy-verify.js +209 -0
- package/dist/env-redaction.js +157 -0
- package/dist/harness-limits.js +17 -0
- package/dist/hook-runtime.js +11 -1
- package/dist/hook.js +170 -88
- package/dist/index.js +593 -567
- package/dist/inline-atom-episode.js +15 -7
- package/dist/inline-atom.js +8 -2
- package/dist/native-skill-adoption.js +11 -0
- package/dist/native-skill-mirror.js +8 -1
- package/dist/node-identity.bundle.js +1166 -0
- package/dist/opencode-plugin.bundle.js +307 -119
- package/dist/procedure-enabled.js +55 -0
- package/dist/procedure-runtime.js +6 -0
- package/dist/procedure-scope.js +190 -0
- package/dist/procedure-watch.js +29 -16
- package/dist/procedure.js +111 -5
- package/dist/project-anchor.js +1 -14
- package/dist/reminder-injector.js +11 -10
- package/dist/repodoc-client.js +296 -0
- package/dist/session-id.js +7 -8
- package/dist/skill-landing.js +57 -2
- package/dist/skill-mirror-client.js +14 -0
- package/dist/skill-mirror-files.js +18 -0
- package/package.json +2 -2
- package/scripts/bundle-node-identity.mjs +47 -0
- package/skill/templates/chip-spawn.md +7 -1
- package/skill/templates/delivery.md +105 -0
- package/skill/templates/prompt-audit.md +196 -0
- package/skill/templates/skill-change.md +25 -2
- package/dist/assistant-doctrine.js +0 -85
- package/dist/commands/assistant-reminder.js +0 -19
- 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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}]
|
|
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
|
-
|
|
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).
|
|
90
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|