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
package/dist/capture-manifest.js
CHANGED
|
@@ -44,7 +44,8 @@ exports.CAPTURE_MANIFEST = [
|
|
|
44
44
|
distill: 'greprag-flash',
|
|
45
45
|
register: 'local episode journal + inline-atom-schemas-${projectId}.json registry',
|
|
46
46
|
inject: 'UserPromptSubmit',
|
|
47
|
-
|
|
47
|
+
// No `confirmed`: replay outcomes demote only. Legacy rows may still carry it.
|
|
48
|
+
statuses: ['seeded', 'learned', 'stale'],
|
|
48
49
|
},
|
|
49
50
|
{
|
|
50
51
|
id: 'parity-drift',
|
package/dist/codex-fast-hook.js
CHANGED
|
@@ -95,6 +95,12 @@ async function main() {
|
|
|
95
95
|
return;
|
|
96
96
|
}
|
|
97
97
|
let result = chipResult;
|
|
98
|
+
// adr: adr/deploy-gate-cli.md — same merge contract as Claude, on Codex's
|
|
99
|
+
// actual pre-tool path. A refusal must precede command rewrites.
|
|
100
|
+
if (!result?.hookSpecificOutput.permissionDecision) {
|
|
101
|
+
const { runMergeGuard } = await Promise.resolve().then(() => __importStar(require('./commands/merge-guard')));
|
|
102
|
+
result = mergeOutputs(result, runMergeGuard(input));
|
|
103
|
+
}
|
|
98
104
|
if (!result?.hookSpecificOutput.permissionDecision && input.tool_name === 'Bash') {
|
|
99
105
|
const { runSearchGuard } = await Promise.resolve().then(() => __importStar(require('./commands/search-guard')));
|
|
100
106
|
result = mergeOutputs(result, runSearchGuard(input));
|
package/dist/codex-steering.js
CHANGED
|
@@ -138,7 +138,7 @@ function formatCodexInboxSteering(messages, session8, excludedIds = new Set()) {
|
|
|
138
138
|
if (unread.length === 0)
|
|
139
139
|
return null;
|
|
140
140
|
const parts = [
|
|
141
|
-
`[GrepRAG inbox: ${unread.length} unread message(s) delivered to
|
|
141
|
+
`[GrepRAG inbox: ${unread.length} unread message(s) delivered to session ${session8}]`,
|
|
142
142
|
`Treat these as live operator/agent messages. Act on them in this turn when relevant.`,
|
|
143
143
|
];
|
|
144
144
|
unread.slice(0, 10).forEach((message, idx) => {
|
|
@@ -69,7 +69,6 @@ const INTERRUPT_COPY = {
|
|
|
69
69
|
'setup-warning': { label: 'Setup warnings', description: 'Calls out missing configuration that prevents GrepRAG from working.', group: 'Health' },
|
|
70
70
|
'version-upgrade': { label: 'Update notices', description: 'Warns when the installed CLI is behind the current release.', group: 'Health' },
|
|
71
71
|
'enrichment-health': { label: 'Knowledge health', description: 'Warns when knowledge enrichment is unavailable.', group: 'Health' },
|
|
72
|
-
'assistant-doctrine': { label: 'Assistant role', description: 'Loads the executive-assistant behavior for its assigned project.', group: 'Roles' },
|
|
73
72
|
'memory-primer': { label: 'Memory guidance', description: 'Teaches agents how and when to retrieve project memory.', group: 'Memory' },
|
|
74
73
|
'watcher-arm': { label: 'Live inbox checks', description: 'Reminds Claude Code to keep live message delivery connected.', group: 'Communication' },
|
|
75
74
|
'mechanic-friction': { label: 'Automatic repair prompts', description: 'Surfaces repeated harness friction for durable repair.', group: 'Automation' },
|
|
@@ -41,8 +41,8 @@ function buildArmReminder(d, env) {
|
|
|
41
41
|
const unread = Number((d.detail && d.detail.unread) || 0);
|
|
42
42
|
const arm = env
|
|
43
43
|
? (env.platform === 'grok'
|
|
44
|
-
? `Grok \`monitor\` tool persistent:true command: \`${(0, session_id_1.armMonitorCommand)(env.short, env.ownerPid, env.
|
|
45
|
-
: `ToolSearch select:Monitor → persistent Monitor (timeout_ms:3600000) command: \`${(0, session_id_1.armMonitorCommand)(env.short, env.ownerPid, env.
|
|
44
|
+
? `Grok \`monitor\` tool persistent:true command: \`${(0, session_id_1.armMonitorCommand)(env.short, env.ownerPid, env.mechanic, 'grok')}\``
|
|
45
|
+
: `ToolSearch select:Monitor → persistent Monitor (timeout_ms:3600000) command: \`${(0, session_id_1.armMonitorCommand)(env.short, env.ownerPid, env.mechanic)}\``)
|
|
46
46
|
: `arm your Monitor inbox watcher`;
|
|
47
47
|
const grounded = `greprag reads your watcher as DOWN (isLocallyArmed=false — a real pidfile check on your session, NOT a timer; the hook CAN see your watcher)`;
|
|
48
48
|
if (unread > 0) {
|
|
@@ -65,11 +65,13 @@ function buildArmAnnounce(short, ownerPid, alias) {
|
|
|
65
65
|
* fires while unarmed and auto-silences when armed. Wired into reminder-registry.ts. */
|
|
66
66
|
exports.watcherArmModule = {
|
|
67
67
|
id: 'watcher-arm',
|
|
68
|
-
// The arm poke rides
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// is
|
|
72
|
-
|
|
68
|
+
// The arm poke rides the harness's own persistent-watch tool. claude-code is
|
|
69
|
+
// EXCLUDED (2026-09-04): Claude Code now delivers inbox mail turn-based, through the
|
|
70
|
+
// same UserPromptSubmit steering Codex uses, plus its own native session messaging —
|
|
71
|
+
// so the Monitor watcher is retired there and "arm your Monitor" is unactionable
|
|
72
|
+
// noise. opencode is EXCLUDED for the same reason: its inbox delivery is the
|
|
73
|
+
// plugin-armed relay (startSessionRelay).
|
|
74
|
+
harnesses: ['codex', 'grok'],
|
|
73
75
|
detect: armDetect,
|
|
74
76
|
announce: () => null,
|
|
75
77
|
reminder: (d, env) => buildArmReminder(d, env),
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* wide}`. A collision is any row with MY project (by project_id — the git-root-
|
|
14
14
|
* commit UUID, identical across worktrees of one repo — with a project_name
|
|
15
15
|
* fallback) and a DIFFERENT session id. Project matching IS the discriminator:
|
|
16
|
-
* service watchers (receptionist / desk-line
|
|
16
|
+
* service watchers (receptionist / desk-line) carry a different
|
|
17
17
|
* project_id (or none), so they never match a working session's repo and are
|
|
18
18
|
* excluded for free. (`wide` is NOT a usable filter — observed true on ordinary
|
|
19
19
|
* session-scoped watchers, not just tenant-wide ones.) Liveness here depends on
|
|
@@ -79,8 +79,8 @@ const session_id_1 = require("../session-id");
|
|
|
79
79
|
* · self is excluded by the 8-hex compare (both sides truncated).
|
|
80
80
|
* · match on project_id (the stable git-root-commit UUID, identical across
|
|
81
81
|
* worktrees of the same repo) with a project_name fallback for rows whose
|
|
82
|
-
* id the cloud could not resolve. Service watchers (receptionist / desk-line
|
|
83
|
-
*
|
|
82
|
+
* id the cloud could not resolve. Service watchers (receptionist / desk-line)
|
|
83
|
+
* carry a different project_id (or none), so they fail the match
|
|
84
84
|
* and are excluded without any role/`wide` special-case. */
|
|
85
85
|
function isCollision(myShort, myProjectId, myProjectName, w) {
|
|
86
86
|
const peerShort = (0, session_id_1.truncateSessionId)(w.session_id);
|
|
@@ -124,16 +124,17 @@ function buildCollisionDirective(peers, myShort, alias) {
|
|
|
124
124
|
+ lines.join('\n') + '\n'
|
|
125
125
|
+ `Coordinate BEFORE touching shared files: (1) message each peer now with a contextual heads-up — `
|
|
126
126
|
+ `Codex peers: use \`codex_app.list_threads\` to find the task and its repo/workspace, then \`codex_app.send_message_to_thread\` to send the heads-up; `
|
|
127
|
-
+ `
|
|
127
|
+
+ `Claude Code peers: use your harness's own session list + send tools — the 8-hex ids above are GREPRAG addresses and are not accepted by them; `
|
|
128
|
+
+ `peers OUTSIDE this harness (opencode / Grok / another tenant): \`greprag send "…what you're about to touch…" --to ${handle}@greprag.com/<their-8hex> --from-session ${myShort}\` `
|
|
128
129
|
+ `(YOU compose and send it — this is a notice, not an auto-send; recipients reply only for a concrete conflict, silence is approval, and you never poll or wait; watcher rows are not the Codex peer source of truth); `
|
|
129
130
|
+ `(2) do your code work in a worktree on its own branch (\`git worktree add .claude/worktrees/<slug> -b <branch>\`); `
|
|
130
|
-
+ `(3) at completion resolve \`.greprag/delivery.json\`, send one fire-and-forget delivery notice, then continue immediately without polling, waiting, status reads, or follow-ups; `
|
|
131
|
+
+ `(3) at completion resolve \`.greprag/delivery.json\`, send one fire-and-forget delivery notice to PEER sessions only — never to a chip (title \`Chip: \` / \`Chip <Label>: \`), which reports to its own parent and has no business with merge, deploy or master — then continue immediately without polling, waiting, status reads, or follow-ups; `
|
|
131
132
|
+ `sweep every ready same-repo commit, ignore all uncommitted and untracked work, resolve conflicts directly, and finish the declared push/deploy/release path. Never edit the shared default checkout concurrently.`);
|
|
132
133
|
}
|
|
133
134
|
/** Reduce the watcher list to OTHER live sessions that are NOT in my repo
|
|
134
135
|
* (cross-repo). Excludes self, same-repo collisions (those get the LOUD
|
|
135
136
|
* directive), and rows with no session id or no project_name (service watchers
|
|
136
|
-
* — receptionist / desk-line
|
|
137
|
+
* — receptionist / desk-line — are unnamed or differently-anchored,
|
|
137
138
|
* so they fall out without a role special-case). PURE. */
|
|
138
139
|
function detectOtherSessions(myShort, myProjectId, myProjectName, watchers) {
|
|
139
140
|
const seen = new Set();
|
|
@@ -130,9 +130,19 @@ async function apiDelete(url, apiKey, body) {
|
|
|
130
130
|
throw new Error(`API ${res.status}: ${await res.text()}`);
|
|
131
131
|
return res.json();
|
|
132
132
|
}
|
|
133
|
-
/**
|
|
134
|
-
*
|
|
135
|
-
|
|
133
|
+
/** Kinds hidden from the default `corpus list` — shown with --all or an
|
|
134
|
+
* explicit --kind. This is the queues-and-registries set from
|
|
135
|
+
* SEARCHABLE_KINDS/BOOKKEEPING_KINDS in @greprag/core, mirrored here because
|
|
136
|
+
* the CLI ships as a zero-dependency artifact and cannot import core.
|
|
137
|
+
*
|
|
138
|
+
* It used to be ['memory','fix','inbox'], which was wrong in both directions:
|
|
139
|
+
* it hid memory (76 stores of real searchable text that the unscoped search
|
|
140
|
+
* was returning hits from anyway) while listing 97 skill-gain and doc-pointer
|
|
141
|
+
* registries that hold almost nothing. What you could list and what you
|
|
142
|
+
* actually searched were two different sets, with nothing saying so.
|
|
143
|
+
* Listing and search now answer from the same classification.
|
|
144
|
+
* adr: adr/searchable-store-kinds.md, adr: adr/corpus-tags.md */
|
|
145
|
+
exports.INTERNAL_KINDS = ['inbox', 'fix', 'docptr', 'skillgain', 'parity', 'doctrine'];
|
|
136
146
|
// -- Arg helpers -----------------------------------------------------------
|
|
137
147
|
function getFlag(args, flag) {
|
|
138
148
|
const idx = args.indexOf(flag);
|
|
@@ -5,22 +5,38 @@
|
|
|
5
5
|
* verbs run shadow-only. It contains no provider commands: those remain
|
|
6
6
|
* repo/target configuration. I/O (Git repo detection) stays in each harness
|
|
7
7
|
* adapter and arrives as env.deliveryRepo.
|
|
8
|
+
*
|
|
9
|
+
* RULES ONLY. This block is `mustInline` — it is worthless parked on disk,
|
|
10
|
+
* because a session that never reads it asks the operator for permission it
|
|
11
|
+
* already has. Every correction to delivery behavior is therefore written as a
|
|
12
|
+
* RULE here and its reasoning goes to `greprag load delivery`. Twice the block
|
|
13
|
+
* grew a paragraph of explanation instead (2026-09-04 peer tools, 2026-09-07
|
|
14
|
+
* merge preparation), crossed the inline budget, and was dropped whole for nine
|
|
15
|
+
* days: the harness went back to asking before every merge and deploy.
|
|
8
16
|
* docs/delivery-system.md, docs/delivery-migration-ledger.md */
|
|
9
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
18
|
exports.deliveryControlModule = void 0;
|
|
11
19
|
exports.buildDeliveryAnnounce = buildDeliveryAnnounce;
|
|
12
20
|
// adr: adr/delivery-announce-pilot.md
|
|
21
|
+
/** How THIS harness finds same-repo peers — the one line that legitimately
|
|
22
|
+
* differs per platform. Everything else about notifying (once, fire-and-forget,
|
|
23
|
+
* chips excluded, why greprag is not the address book) is shared prose that
|
|
24
|
+
* belongs in `greprag load delivery`, not in nine duplicated sentences here. */
|
|
13
25
|
function coordinationLine(platform) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
const how = platform === 'codex'
|
|
27
|
+
? 'codex_app.list_threads + codex_app.send_message_to_thread'
|
|
28
|
+
: platform === 'claude-code'
|
|
29
|
+
? "this harness's own session-list + send tools (never a greprag id)"
|
|
30
|
+
: platform === 'opencode'
|
|
31
|
+
? 'the GrepRAG registry'
|
|
32
|
+
: 'your harness\'s own peer tools';
|
|
33
|
+
// Codex keeps its one extra word: `wait_threads` is the specific tool a Codex
|
|
34
|
+
// session reaches for when it decides to wait for a reply it was told not to
|
|
35
|
+
// wait for. A named tool is a rule; "do not wait" alone was not enough.
|
|
36
|
+
const never = platform === 'codex'
|
|
37
|
+
? 'never poll, call wait_threads, ask for status, or follow up'
|
|
38
|
+
: 'never poll, wait, ask for status, or follow up';
|
|
39
|
+
return `Notify same-repo peer sessions ONCE via ${how}, then continue. A \`Chip: \` session is not a peer. Silence is approval: ${never}.`;
|
|
24
40
|
}
|
|
25
41
|
function buildDeliveryAnnounce(env) {
|
|
26
42
|
if (!env.deliveryRepo)
|
|
@@ -28,15 +44,20 @@ function buildDeliveryAnnounce(env) {
|
|
|
28
44
|
const project = env.projectName?.trim() || 'current repo';
|
|
29
45
|
return [
|
|
30
46
|
`[DELIVERY — ${project}]`,
|
|
31
|
-
'Commit useful, passing work.',
|
|
32
|
-
'Only committed Git state participates in delivery. Ignore all uncommitted and untracked work in every checkout.',
|
|
47
|
+
'Commit useful, passing work. Only committed state delivers; ignore uncommitted and untracked work in every checkout.',
|
|
33
48
|
coordinationLine(env.platform),
|
|
34
|
-
'
|
|
35
|
-
'
|
|
49
|
+
'Land every ready same-repo commit: update the default branch, rebase your worktree onto its tip, retest there, take greprag merge-lock in the canonical checkout, recheck the tip, merge --ff-only, release --landed. Tip moved? Free the lock and prepare again. Never rebase a shared default branch.',
|
|
50
|
+
'Deploy through this repo\'s delivery profile, from its canonical primary checkout unless the profile says otherwise. Worktrees build; they do not deploy. Then verify production serves the commit you shipped.',
|
|
51
|
+
'THE GOAL ALREADY AUTHORIZES ALL OF THIS. Do not stop to ask permission to commit, merge, or deploy. Separate approval is for effects outside code delivery: destructive data, secrets, money, customer-facing messages.',
|
|
52
|
+
'Why each rule, and the details: `greprag load delivery`.',
|
|
36
53
|
].join('\n');
|
|
37
54
|
}
|
|
38
55
|
exports.deliveryControlModule = {
|
|
39
56
|
id: 'delivery-control',
|
|
57
|
+
// Parked on disk this block does nothing: a session that never runs `greprag
|
|
58
|
+
// announce` asks the operator to authorize work the goal already authorized.
|
|
59
|
+
// tests/test-announce-budget.cjs fails the build if it outgrows the budget.
|
|
60
|
+
mustInline: true,
|
|
40
61
|
detect: (_env) => ({ tier: 'silent' }),
|
|
41
62
|
announce: buildDeliveryAnnounce,
|
|
42
63
|
reminder: () => null,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runDeployGate = runDeployGate;
|
|
4
|
+
const deploy_gate_1 = require("../deploy-gate");
|
|
5
|
+
const HELP = `greprag deploy-gate — the repo-independent half of a deploy gate
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
greprag deploy-gate [--target <id>] [--json] [--ignore-lock] [--cwd <path>]
|
|
9
|
+
|
|
10
|
+
Answers the questions that do not depend on how a target's artifact is built:
|
|
11
|
+
may a deploy run from here, is one already running, and what is shipping. Reads
|
|
12
|
+
.greprag/delivery.json for the canonical root and default branch.
|
|
13
|
+
|
|
14
|
+
Read-only: acquires no lock, writes no ledger, mutates no checkout. The artifact
|
|
15
|
+
proof stays with the repo and is reported as a skipped, repo-owned check.
|
|
16
|
+
|
|
17
|
+
Exit 0 when every check passes, 1 when any refuses.
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--target <id> Deploy target, used for the lock and ledger names (default: default)
|
|
21
|
+
--ignore-lock Skip the lock check because the caller already holds it
|
|
22
|
+
--cwd <path> Evaluate this checkout instead of the working directory
|
|
23
|
+
--json Machine-readable verdict`;
|
|
24
|
+
function flagValue(args, name) {
|
|
25
|
+
const index = args.indexOf(name);
|
|
26
|
+
if (index === -1)
|
|
27
|
+
return undefined;
|
|
28
|
+
const value = args[index + 1];
|
|
29
|
+
return value && !value.startsWith('--') ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
function runDeployGate(args) {
|
|
32
|
+
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
33
|
+
console.log(HELP);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const verdict = (0, deploy_gate_1.evaluateDeployGate)({
|
|
37
|
+
cwd: flagValue(args, '--cwd'),
|
|
38
|
+
target: flagValue(args, '--target'),
|
|
39
|
+
ignoreLock: args.includes('--ignore-lock'),
|
|
40
|
+
});
|
|
41
|
+
if (args.includes('--json')) {
|
|
42
|
+
console.log(JSON.stringify(verdict));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
console.log(`deploy-gate v${deploy_gate_1.DEPLOY_GATE_VERSION}: ${verdict.verdict.toUpperCase()} (target ${verdict.target})`);
|
|
46
|
+
for (const check of verdict.checks) {
|
|
47
|
+
const mark = check.status === 'pass' ? '✓' : check.status === 'fail' ? '✗' : '–';
|
|
48
|
+
console.log(` ${mark} ${check.id}: ${check.detail}`);
|
|
49
|
+
}
|
|
50
|
+
for (const line of verdict.shipping)
|
|
51
|
+
console.log(` ${line}`);
|
|
52
|
+
}
|
|
53
|
+
if (verdict.verdict === 'refuse')
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runDeployLock = runDeployLock;
|
|
4
|
+
const deploy_locks_1 = require("../deploy-locks");
|
|
5
|
+
const HELP = `greprag deploy-lock — one deploy at a time per target
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
greprag deploy-lock acquire --target <id> [--pid <n>] [--label <text>] [--json]
|
|
9
|
+
greprag deploy-lock release --target <id> [--json]
|
|
10
|
+
greprag deploy-lock status --target <id> [--json]
|
|
11
|
+
|
|
12
|
+
The deploy gate refuses while this is held. Sift and the site have had it since
|
|
13
|
+
their gate was local; a repo onboarded without it has a lock check that can never
|
|
14
|
+
fire, which reads as a pass and is not one.
|
|
15
|
+
|
|
16
|
+
Unlike the merge lock this one is PROCESS-held: it is stale as soon as its owner
|
|
17
|
+
dies, so a crashed deploy does not block the next one for an hour. That only works
|
|
18
|
+
if the recorded pid is the process actually deploying. A script that shells out to
|
|
19
|
+
this command must pass its own pid with \`--pid\`, because this command exits
|
|
20
|
+
immediately and its own pid would be dead before the deploy started.
|
|
21
|
+
|
|
22
|
+
Exit 0 when the lock is yours, 1 when someone else holds it.
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--target <id> Deploy target, e.g. worker, api-production, paybot-production
|
|
26
|
+
--pid <n> The deploying process (default: this command's parent)
|
|
27
|
+
--label <text> What this deploy is, shown to whoever is blocked by it
|
|
28
|
+
--json Machine-readable result`;
|
|
29
|
+
function flagValue(args, name) {
|
|
30
|
+
const index = args.indexOf(name);
|
|
31
|
+
if (index === -1)
|
|
32
|
+
return undefined;
|
|
33
|
+
const value = args[index + 1];
|
|
34
|
+
return value && !value.startsWith('--') ? value : undefined;
|
|
35
|
+
}
|
|
36
|
+
function runDeployLock(args) {
|
|
37
|
+
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
38
|
+
console.log(HELP);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const sub = args[0] || 'status';
|
|
42
|
+
const json = args.includes('--json');
|
|
43
|
+
const target = flagValue(args, '--target') || 'default';
|
|
44
|
+
const cwd = process.cwd();
|
|
45
|
+
let file;
|
|
46
|
+
try {
|
|
47
|
+
file = (0, deploy_locks_1.deployLockPath)(cwd, target);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
console.error(`deploy-lock: ${error instanceof Error ? error.message : String(error)}`);
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (sub === 'acquire') {
|
|
55
|
+
const explicit = Number.parseInt(flagValue(args, '--pid') || '', 10);
|
|
56
|
+
// Falling back to the parent covers a shell caller that did not pass one. Our own
|
|
57
|
+
// pid is never right here: this process is gone before the deploy begins.
|
|
58
|
+
const pid = Number.isInteger(explicit) && explicit > 0 ? explicit : process.ppid;
|
|
59
|
+
const result = (0, deploy_locks_1.acquireLock)(file, flagValue(args, '--label') || `deploy ${target}`, deploy_locks_1.DEPLOY_STALE_MS, { pid });
|
|
60
|
+
if (json)
|
|
61
|
+
console.log(JSON.stringify({ ...result, pid }));
|
|
62
|
+
else if (result.ok) {
|
|
63
|
+
console.log(`deploy-lock: ${target} held by pid ${pid}`);
|
|
64
|
+
if (result.held)
|
|
65
|
+
console.log(` reclaimed a stale lock from pid ${result.held.record.pid}`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
console.error(`deploy-lock: another deploy of ${target} is running (pid ${result.held?.record.pid},`
|
|
69
|
+
+ ` session ${result.held ? (0, deploy_locks_1.describeLockOwner)(result.held.record) : 'unidentified'}, started ${result.held?.record.startedAt}).`
|
|
70
|
+
+ '\n Wait for it. Two deploys of one target race to decide what production runs.');
|
|
71
|
+
}
|
|
72
|
+
if (!result.ok)
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (sub === 'release') {
|
|
77
|
+
(0, deploy_locks_1.releaseLock)(file);
|
|
78
|
+
if (json)
|
|
79
|
+
console.log(JSON.stringify({ released: true, path: file }));
|
|
80
|
+
else
|
|
81
|
+
console.log(`deploy-lock: ${target} released`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (sub === 'status') {
|
|
85
|
+
const held = (0, deploy_locks_1.readLock)(file);
|
|
86
|
+
if (json) {
|
|
87
|
+
console.log(JSON.stringify({ target, held }));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!held)
|
|
91
|
+
console.log(`deploy-lock: ${target} not held`);
|
|
92
|
+
else if (held.stale)
|
|
93
|
+
console.log(`deploy-lock: ${target} stale (pid ${held.record.pid}, started ${held.record.startedAt}) — would be reclaimed`);
|
|
94
|
+
else
|
|
95
|
+
console.log(`deploy-lock: ${target} held by pid ${held.record.pid}, session ${(0, deploy_locks_1.describeLockOwner)(held.record)}, since ${held.record.startedAt}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
console.error(`Unknown deploy-lock command: ${sub}\n\n${HELP}`);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.lastDeployPath = lastDeployPath;
|
|
37
|
+
exports.runDeployRecord = runDeployRecord;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const deploy_locks_1 = require("../deploy-locks");
|
|
41
|
+
const proc_1 = require("../proc");
|
|
42
|
+
const HELP = `greprag deploy-record — remember what this checkout last shipped
|
|
43
|
+
|
|
44
|
+
Usage:
|
|
45
|
+
greprag deploy-record --target <id> [--sha <commit>] [--json]
|
|
46
|
+
greprag deploy-record show --target <id> [--json]
|
|
47
|
+
|
|
48
|
+
Write this AFTER a deploy is verified live, never before. The gate reads it to
|
|
49
|
+
answer "what is shipping" — the commits between the last deploy and HEAD, with
|
|
50
|
+
late-arriving ones called out. A repo that never records is told "no record of a
|
|
51
|
+
previous deploy", which is honest but useless, and that was the state of every
|
|
52
|
+
repo onboarded without a local deploy script.
|
|
53
|
+
|
|
54
|
+
It is also the only baseline a deploy script has for "what changed since last
|
|
55
|
+
time" — which files, which migrations. Without it those questions can only be
|
|
56
|
+
answered against history, which is not the same question and produces a list
|
|
57
|
+
long enough that nobody reads it.
|
|
58
|
+
|
|
59
|
+
Lives inside .git beside the locks, so it is never tree dirt and cannot be
|
|
60
|
+
committed. It is per-checkout by design: what THIS checkout last shipped.
|
|
61
|
+
|
|
62
|
+
Options:
|
|
63
|
+
--target <id> Deploy target, e.g. web-production, paybot-production
|
|
64
|
+
--sha <commit> What was shipped (default: HEAD)
|
|
65
|
+
--json Machine-readable result`;
|
|
66
|
+
/** The basename already on disk in sift and the site. The CLI writes the real
|
|
67
|
+
* ledger the gate reads, never a parallel one. */
|
|
68
|
+
const LOCK_PREFIX = process.env.GREPRAG_DEPLOY_LOCK_PREFIX || 'sift';
|
|
69
|
+
function lastDeployPath(cwd, target) {
|
|
70
|
+
return path.join((0, deploy_locks_1.gitDir)(cwd), `${LOCK_PREFIX}-last-deploy-${target}.json`);
|
|
71
|
+
}
|
|
72
|
+
function flagValue(args, name) {
|
|
73
|
+
const index = args.indexOf(name);
|
|
74
|
+
if (index === -1)
|
|
75
|
+
return undefined;
|
|
76
|
+
const value = args[index + 1];
|
|
77
|
+
return value && !value.startsWith('--') ? value : undefined;
|
|
78
|
+
}
|
|
79
|
+
function resolveCommit(rev, cwd) {
|
|
80
|
+
const result = (0, proc_1.safeSpawnSync)('git', ['rev-parse', '--verify', `${rev}^{commit}`], {
|
|
81
|
+
encoding: 'utf8', cwd, stdio: ['ignore', 'pipe', 'pipe'],
|
|
82
|
+
});
|
|
83
|
+
return result.status === 0 ? String(result.stdout).trim() : null;
|
|
84
|
+
}
|
|
85
|
+
function runDeployRecord(args) {
|
|
86
|
+
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
87
|
+
console.log(HELP);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const json = args.includes('--json');
|
|
91
|
+
const target = flagValue(args, '--target');
|
|
92
|
+
const cwd = process.cwd();
|
|
93
|
+
if (!target) {
|
|
94
|
+
console.error(`deploy-record: --target is required.\n\n${HELP}`);
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
let file;
|
|
99
|
+
try {
|
|
100
|
+
file = lastDeployPath(cwd, target);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
console.error(`deploy-record: ${error instanceof Error ? error.message : String(error)}`);
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (args[0] === 'show') {
|
|
108
|
+
let record = null;
|
|
109
|
+
try {
|
|
110
|
+
record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
record = null;
|
|
114
|
+
}
|
|
115
|
+
if (json)
|
|
116
|
+
console.log(JSON.stringify({ target, record }));
|
|
117
|
+
else if (!record)
|
|
118
|
+
console.log(`deploy-record: no record of a previous ${target} deploy from this checkout`);
|
|
119
|
+
else {
|
|
120
|
+
const value = record;
|
|
121
|
+
console.log(`deploy-record: ${target} last shipped ${String(value.sha).slice(0, 10)} at ${value.at}`);
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const sha = resolveCommit(flagValue(args, '--sha') || 'HEAD', cwd);
|
|
126
|
+
if (!sha) {
|
|
127
|
+
console.error('deploy-record: that is not a commit in this repository — nothing recorded.');
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const record = { sha, at: new Date().toISOString(), target };
|
|
132
|
+
try {
|
|
133
|
+
fs.writeFileSync(file, JSON.stringify(record));
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
// A deploy that shipped and verified is done. Failing it here would report a
|
|
137
|
+
// successful deploy as a failure, which is the worse of the two wrong answers.
|
|
138
|
+
console.error(`deploy-record: WARNING — the deploy is live but was not recorded: ${error instanceof Error ? error.message : String(error)}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (json)
|
|
142
|
+
console.log(JSON.stringify(record));
|
|
143
|
+
else
|
|
144
|
+
console.log(`deploy-record: ${target} now recorded at ${sha.slice(0, 10)}`);
|
|
145
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runDeployVerify = runDeployVerify;
|
|
4
|
+
const deploy_verify_1 = require("../deploy-verify");
|
|
5
|
+
const HELP = `greprag deploy-verify — did production actually take the upload?
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
greprag deploy-verify --origin <url> --sha <commit> [--attempts <n>] [--json]
|
|
9
|
+
greprag deploy-verify --heroku <app> --sha <commit> [--attempts <n>] [--json]
|
|
10
|
+
|
|
11
|
+
Reads <origin>/__build.json until it returns the commit you just shipped, retrying
|
|
12
|
+
while the rollout settles. This is the ONLY correct answer to "is my change live?" —
|
|
13
|
+
never grep a shipped bundle, which is wrong in both directions: code is split across
|
|
14
|
+
content-hashed chunks so it is often absent from the file you searched, and comment
|
|
15
|
+
text is stripped by the minifier and reads as missing when it shipped fine.
|
|
16
|
+
|
|
17
|
+
Also checks that the stamp is served uncacheable. A cacheable stamp lets an edge
|
|
18
|
+
answer your liveness check with the PREVIOUS deploy's commit, which is exactly what
|
|
19
|
+
happened on 2026-09-04 — so a cacheable stamp fails, even when today's answer is right.
|
|
20
|
+
|
|
21
|
+
Exit 0 when production is proven to serve the commit, 1 otherwise.
|
|
22
|
+
|
|
23
|
+
For a target with no HTTP stamp — a Discord bot, a worker process — use --heroku:
|
|
24
|
+
it reads the app's release list and confirms the running release names your commit.
|
|
25
|
+
It refuses while the current release is pending or failed, which is the case most
|
|
26
|
+
worth catching, because the git push succeeded and looks fine. A config-var change
|
|
27
|
+
creates a release naming no commit, and the code running is still the last real
|
|
28
|
+
deploy, so the check walks back to the most recent release that names one.
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--origin <url> Production origin, e.g. https://api.example.com
|
|
32
|
+
--heroku <app> Heroku app name, e.g. paybotdiscord
|
|
33
|
+
--sha <commit> The commit that was just uploaded
|
|
34
|
+
--attempts <n> Checks before giving up (default 8, ~5s apart)
|
|
35
|
+
--skip-cache-check Only when the target genuinely cannot set response headers
|
|
36
|
+
--json Machine-readable result`;
|
|
37
|
+
function flagValue(args, name) {
|
|
38
|
+
const index = args.indexOf(name);
|
|
39
|
+
if (index === -1)
|
|
40
|
+
return undefined;
|
|
41
|
+
const value = args[index + 1];
|
|
42
|
+
return value && !value.startsWith('--') ? value : undefined;
|
|
43
|
+
}
|
|
44
|
+
async function runDeployVerify(args) {
|
|
45
|
+
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
46
|
+
console.log(HELP);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const origin = (flagValue(args, '--origin') || '').replace(/\/+$/, '');
|
|
50
|
+
const herokuApp = flagValue(args, '--heroku') || '';
|
|
51
|
+
const sha = flagValue(args, '--sha') || '';
|
|
52
|
+
const json = args.includes('--json');
|
|
53
|
+
const attemptsRaw = Number.parseInt(flagValue(args, '--attempts') || '', 10);
|
|
54
|
+
const attemptOpt = Number.isFinite(attemptsRaw) && attemptsRaw > 0 ? { attempts: attemptsRaw } : {};
|
|
55
|
+
if ((!origin && !herokuApp) || !sha) {
|
|
56
|
+
console.error('deploy-verify: --sha plus one of --origin or --heroku is required.\n\n' + HELP);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Heroku has no build stamp to cache, so the cache discipline below does not apply.
|
|
61
|
+
if (herokuApp) {
|
|
62
|
+
const result = await (0, deploy_verify_1.verifyHerokuRelease)({ app: herokuApp, sha, ...attemptOpt });
|
|
63
|
+
if (json) {
|
|
64
|
+
console.log(JSON.stringify({ ok: result.ok, attempts: result.attempts, reason: result.reason ?? null }));
|
|
65
|
+
}
|
|
66
|
+
else if (result.ok) {
|
|
67
|
+
console.log(`deploy-verify: ${herokuApp} is running ${sha.slice(0, 10)} (confirmed in ${result.attempts} check(s))`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
console.error(`deploy-verify: the push finished but ${herokuApp} is not running it: ${result.reason}.`
|
|
71
|
+
+ ` Checked ${result.attempts} time(s). Inspect with: heroku releases --app ${herokuApp}`);
|
|
72
|
+
}
|
|
73
|
+
if (!result.ok)
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Liveness FIRST, then cacheability on the very response that proved it. Checking
|
|
78
|
+
// the header up front raced the rollout: on 2026-09-05 a healthy greprag deploy was
|
|
79
|
+
// refused because the pre-flight request reached the OLD version, which had no
|
|
80
|
+
// stamp route at all, and a 404 carries no Cache-Control. Only the liveness check
|
|
81
|
+
// retries, so only it can tell "not live yet" from "wrong". Sift and the site have
|
|
82
|
+
// always run these in this order; their contract tests assert it.
|
|
83
|
+
const result = await (0, deploy_verify_1.verifyDeployedBuildStamp)({ origin, sha, ...attemptOpt });
|
|
84
|
+
if (result.ok && !args.includes('--skip-cache-check')) {
|
|
85
|
+
const cacheable = (0, deploy_verify_1.describeStampCacheability)(result.cacheControl ?? null);
|
|
86
|
+
if (cacheable) {
|
|
87
|
+
const reason = `${cacheable}. The liveness check cannot be trusted while an edge can answer it from cache:`
|
|
88
|
+
+ ' serve /__build.json with `Cache-Control: no-store`, then deploy again.';
|
|
89
|
+
if (json)
|
|
90
|
+
console.log(JSON.stringify({ ok: false, stage: 'cacheable', reason }));
|
|
91
|
+
else
|
|
92
|
+
console.error(`deploy-verify: ${reason}`);
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (json) {
|
|
98
|
+
console.log(JSON.stringify({ ok: result.ok, attempts: result.attempts, reason: result.reason ?? null }));
|
|
99
|
+
}
|
|
100
|
+
else if (result.ok) {
|
|
101
|
+
console.log(`deploy-verify: production serves ${sha.slice(0, 10)} (confirmed in ${result.attempts} check(s))`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
console.error(`deploy-verify: upload finished but production is not serving it: ${result.reason}.`
|
|
105
|
+
+ ` Checked ${result.attempts} time(s).`
|
|
106
|
+
+ ' The upload happened; what is live is not this commit. Re-run the deploy, and if it repeats,'
|
|
107
|
+
+ " check the provider's deployment list before assuming the code is wrong.");
|
|
108
|
+
}
|
|
109
|
+
if (!result.ok)
|
|
110
|
+
process.exitCode = 1;
|
|
111
|
+
}
|