@jakobm/peacekeeper 0.12.2 → 0.13.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/README.md CHANGED
@@ -44,27 +44,55 @@ Run `npm install` from the project root and commit the resulting `package-lock.j
44
44
 
45
45
  ### 2. `peacekeeper.config.json`
46
46
 
47
- Place this required file at the caller's Git root. All fields shown here are required and unknown fields are rejected. `git.checkpointPushRemotes`, `git.releaseMergeMethod`, `git.releaseRequiredChecks`, and `git.releaseTestWorkflow` are optional fields described below; release commands require `git.releaseRequiredChecks`. Optional `projectChecks` settings are described under local project checks. There are no built-in test-command defaults: each caller must define `tests.commands` explicitly for its own toolchain.
47
+ Place this file at the caller's Git root. Every field, including nested fields,
48
+ is required. Unknown fields and invalid values are rejected. If no file exists,
49
+ Peacekeeper uses the complete bundled [defaults](scripts/project-config-defaults.json).
50
+ The defaults contain no test commands; configure those before running tests or
51
+ recording or reusing a test receipt.
52
+
53
+ Run `peacekeeper update-config` to create the file or recursively fill missing
54
+ fields from the defaults. Existing values, including empty arrays, are preserved.
55
+ Review newly inserted values. Invalid JSON, unknown fields, or invalid existing
56
+ values cause failure without changing the file. Repeating the command on a
57
+ complete valid file leaves it unchanged.
58
+
59
+ All CLI commands validate configuration before executing, except help,
60
+ `check-project --explain`, and `update-config`. An incomplete existing file fails
61
+ with missing field paths and a repair hint. Updates introducing new fields
62
+ therefore require `peacekeeper update-config` before ordinary commands can run.
63
+
64
+ Complete example:
48
65
 
49
66
  ```json
50
67
  {
51
68
  "git": {
52
69
  "remote": "origin",
53
70
  "defaultBranch": "main",
54
- "branchPrefix": "codex/"
71
+ "branchPrefix": "codex/",
72
+ "checkpointPushRemotes": ["origin"],
73
+ "releaseMergeMethod": "merge",
74
+ "releaseRequiredChecks": ["test"],
75
+ "releaseTestWorkflow": "test.yml"
55
76
  },
56
77
  "tests": {
57
78
  "commands": ["eslint .", "node --test"],
58
79
  "receiptMaxAgeMinutes": 60,
59
80
  "npmTestUsesRunner": true
60
81
  },
61
- "loggingDirectory": "tmp/logs"
82
+ "loggingDirectory": "tmp/logs",
83
+ "loggingEnabled": true,
84
+ "projectChecks": {
85
+ "testWorkflow": "test.yml",
86
+ "requiredJobs": ["test"]
87
+ }
62
88
  }
63
89
  ```
64
90
 
65
91
  `git.remote` and `git.defaultBranch` identify the branch used for checkpointing and cleanup. `git.branchPrefix` names new task branches. `tests.commands` are complete shell commands, run in order from the caller's Git root; install any executables they use. The test runner stops on the first failure and records a receipt only after every command succeeds. `receiptMaxAgeMinutes` controls how long a receipt can be reused. Set `npmTestUsesRunner` to `true` only when the caller's `npm test` script invokes `peacekeeper run-tests`; this makes the rerun hint `npm test -- rerun`. Otherwise the hint uses the explicit package path. `loggingDirectory` stores hook invocation logs relative to the primary Git worktree's root.
66
92
 
67
- To publish checkpoint branches automatically, add `checkpointPushRemotes` to `git`:
93
+ `loggingEnabled` has the bundled default `true`; set it to `false` to disable invocation logs and the background cleanup worker's log file. Existing logs are kept. The setting is read from the primary checkout's configuration, including for linked worktrees. SessionStart and Stop logs include the process and event working directories, event type, primary-checkout classification, and hook response or error. Only selected event fields are logged, never the full incoming payload. These logs require the Peacekeeper script to start; a missing Node.js executable or package must be diagnosed in Codex's own logs.
94
+
95
+ To publish checkpoint branches automatically, set `git.checkpointPushRemotes` (fragment):
68
96
 
69
97
  ```json
70
98
  {
@@ -75,11 +103,9 @@ To publish checkpoint branches automatically, add `checkpointPushRemotes` to `gi
75
103
  }
76
104
  ```
77
105
 
78
- Each name must identify a Git remote configured in the calling repository. With this
79
- setting, checkpoints push to exactly those remotes, creating the task branch if
80
- needed. An empty list disables checkpoint pushes. When the field is omitted,
81
- checkpoints keep the previous behavior: push only when the branch already exists
82
- on `git.remote`. Pushes never use force. Only a push to `git.remote` sets the
106
+ Each name must identify a Git remote configured in the calling repository. Checkpoints push to exactly those remotes, creating the task branch if
107
+ needed. An empty list disables checkpoint pushes and is the bundled default.
108
+ The field must be present; there is no implicit push fallback. Pushes never use force. Only a push to `git.remote` sets the
83
109
  branch's upstream. If a push fails, earlier pushes are kept and the checkpoint
84
110
  records a failure; fix the cause and rerun the checkpoint command to retry without
85
111
  creating another commit when there are no new changes.
@@ -167,7 +193,7 @@ The installed file contains these definitions (other existing hooks are retained
167
193
  }
168
194
  ```
169
195
 
170
- The commands resolve the package from the current Git worktree. Install dependencies in each worktree where the hooks will run; the commands need that worktree's `node_modules/@jakobm/peacekeeper`. The checkpoint hooks apply only to linked worktrees. `SessionStart` supplies the checkpoint instructions before work begins, including on resume, fork, and after compaction; no matcher is needed. `Stop` uses the same package-owned instructions as a fallback. You do not need to copy checkpoint rules into each project's `AGENTS.md`. Explicit user instructions not to commit or push take precedence: the agent must skip the checkpoint command when they conflict, even if `Stop` requests it. The hook checks Git state and cannot determine user intent itself. When uncommitted changes remain, the `Stop` hook asks the agent to review the diff and run `checkpoint-codex-changes` with a descriptive English commit message. That command stages all changes and commits them on a task branch; it refuses the configured default branch. With `git.checkpointPushRemotes`, it pushes without force to each listed remote, including new branches. Without that setting, it pushes only if the branch already exists on `git.remote`, otherwise the branch stays local.
196
+ The commands resolve the package from the current Git worktree. Install dependencies in each worktree where the hooks will run; the commands need that worktree's `node_modules/@jakobm/peacekeeper`. The checkpoint hooks apply only to linked worktrees. `SessionStart` supplies the checkpoint instructions before work begins, including on resume, fork, and after compaction; no matcher is needed. `Stop` uses the same package-owned instructions as a fallback. You do not need to copy checkpoint rules into each project's `AGENTS.md`. Explicit user instructions not to commit or push take precedence: the agent must skip the checkpoint command when they conflict, even if `Stop` requests it. The hook checks Git state and cannot determine user intent itself. When uncommitted changes remain, the `Stop` hook asks the agent to review the diff and run `checkpoint-codex-changes` with a descriptive English commit message. That command stages all changes and commits them on a task branch; it refuses the configured default branch. With `git.checkpointPushRemotes`, it pushes without force to each listed remote, including new branches. An empty array keeps the branch local.
171
197
 
172
198
  `SessionEnd` starts a detached worker to clean up the session worktree and branch only if the captured branch tip is still unchanged and fully merged into the remote default branch. Dirty or locked worktrees are kept. Worker output is appended to `cleanup-ended-session.log` in the shared Git directory. Cleanup does not require the primary worktree to have the default branch checked out. A local default branch that can fast-forward is updated before cleanup: a branch not checked out in any worktree is moved atomically, while a checked-out branch is merged in its clean worktree so its files and index stay synchronized. A dirty or diverged default branch is left unchanged. Codex loads project hooks only after the project hook source is trusted; review them with `/hooks`. The three-second `SessionEnd` timeout is the maximum documented for that event. See the [Codex hooks reference](https://learn.chatgpt.com/docs/hooks) for hook loading, trust, and timeout behavior.
173
199
 
@@ -319,13 +345,13 @@ Release merge selection reads the calling repository's root
319
345
  `peacekeeper.config.json`, not the installed package's configuration:
320
346
 
321
347
  - If GitHub allows only one merge method, that method is used regardless of the preference.
322
- - If multiple methods are allowed, optional `git.releaseMergeMethod` selects
348
+ - If multiple methods are allowed, `git.releaseMergeMethod` selects
323
349
  `"merge"` (merge commit), `"rebase"`, or `"squash"`. A configured method that
324
350
  GitHub disallows causes the release to stop before creating a branch or PR.
325
- - Without a preference, merge commit is chosen first, then rebase.
351
+ - The bundled preference is `"merge"`.
326
352
 
327
- For example, add `"releaseMergeMethod": "rebase"` inside the existing `git` object.
328
- A caller without a merge preference uses the same defaults.
353
+ For example, set `"releaseMergeMethod": "rebase"` inside the existing `git` object.
354
+ A caller without a configuration file uses the bundled defaults.
329
355
  The chosen method is saved with release state and reused on retries; unfinished
330
356
  releases created by older versions retain their original squash behavior.
331
357
 
@@ -371,9 +397,8 @@ publish job. Add `peacekeeper verify-release-protection` to a required PR
371
397
  test job and `peacekeeper verify-release` before publishing, with `GH_TOKEN`
372
398
  and `RELEASE_TAG` set for release verification. Both commands are shipped with
373
399
  the package; no verifier script or artifact upload needs to be copied.
374
- Set optional `git.releaseTestWorkflow` to the PR workflow filename (for this
375
- repository: `"test.yml"`). If omitted, verification uses the publish workflow
376
- filename for compatibility with combined workflows. All configured check names
400
+ Set `git.releaseTestWorkflow` to the PR workflow filename (for this
401
+ repository: `"test.yml"`). For combined workflows, explicitly set the publish workflow filename. All configured check names
377
402
  must be jobs in the selected PR workflow's run.
378
403
  The `publish` job must contain a step whose command is exactly `npm publish`.
379
404
  Its `if` can be omitted when the workflow triggers only on pushes of
@@ -381,3 +406,11 @@ Its `if` can be omitted when the workflow triggers only on pushes of
381
406
  For combined workflows or other triggers, the job must use the exact condition
382
407
  `${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}`.
383
408
  Explicit alternative job conditions are not supported. Workspaces are not supported.
409
+
410
+ Configuration migration: previously optional fields must now be declared. Run
411
+ `peacekeeper update-config`, review the inserted defaults, and commit the result.
412
+ In particular, set `git.checkpointPushRemotes` explicitly to retain automatic
413
+ pushes. Empty `git.releaseRequiredChecks` is valid configuration but blocks release
414
+ policy verification; empty `tests.commands` blocks test execution and receipts.
415
+ `projectChecks` always explicitly selects the workflow and jobs. This change does
416
+ not add online GitHub verification to `check-project`.
@@ -4,6 +4,7 @@ const { spawnSync } = require('node:child_process');
4
4
  const path = require('node:path');
5
5
 
6
6
  const COMMANDS = new Map([
7
+ ['update-config', 'Create or complete project configuration using bundled defaults.'],
7
8
  ['check-project', 'Validate local toolchain, hooks, CI and publication conventions.'],
8
9
  ['install-hooks', 'Install or update project Codex hooks while preserving other hooks.'],
9
10
  ['verify-release', 'Verify release versions, required CI and strict branch protection.'],
@@ -42,6 +43,9 @@ function main() {
42
43
  process.exitCode = 2;
43
44
  return;
44
45
  }
46
+ const informational = (args.length === 1 && ['--help', '-h'].includes(args[0]))
47
+ || (command === 'check-project' && args.length === 1 && args[0] === '--explain');
48
+ if (command !== 'update-config' && !informational) require('../scripts/project-config').projectConfig();
45
49
  const script = path.resolve(__dirname, '..', 'scripts', `${command}.js`);
46
50
  const result = spawnSync(process.execPath, [script, ...args], {
47
51
  cwd: process.cwd(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jakobm/peacekeeper",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
4
4
  "description": "Shared repository test, Git worktree, and Codex hook commands",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,13 +13,16 @@ Purpose and scope
13
13
  --explain prints this entire document without loading or checking a project.
14
14
 
15
15
  Project configuration
16
- A valid peacekeeper.config.json is required at the Git root, along with
17
- package.json. The normal configuration schema still applies.
18
-
19
- Select the PR workflow by filename, in this order:
20
- projectChecks.testWorkflow -> git.releaseTestWorkflow -> test.yml
21
- Select required job names in this order:
22
- projectChecks.requiredJobs -> git.releaseRequiredChecks -> ["test"]
16
+ package.json is required at the Git root. Without peacekeeper.config.json,
17
+ bundled defaults apply. An existing configuration must declare every schema
18
+ field, including nested fields; unknown fields and invalid values fail.
19
+ Run peacekeeper update-config to create or complete it without overwriting
20
+ existing values. Review inserted defaults. Empty tests.commands blocks tests
21
+ and receipts. Help, --explain and update-config bypass completeness checking.
22
+
23
+ projectChecks.testWorkflow selects the PR workflow filename.
24
+ projectChecks.requiredJobs selects required job names.
25
+ Defaults are test.yml and ["test"], respectively.
23
26
  For published packages, git.releaseRequiredChecks is mandatory and its names
24
27
  are always added to the required jobs. Set git.releaseTestWorkflow to the
25
28
  selected PR workflow for release verification. Published projects cannot
@@ -34,8 +37,7 @@ Project configuration
34
37
  "testWorkflow": "package.yml",
35
38
  "requiredJobs": ["test"]
36
39
  }
37
- projectChecks is optional; when present it requires both testWorkflow and
38
- requiredJobs. Omit it when release settings already select the desired jobs.
40
+ projectChecks and both its fields are mandatory in an existing configuration.
39
41
 
40
42
  Toolchain: package.json and all Node-based CI jobs
41
43
  Pin exact versions in package.json with:
@@ -140,11 +140,11 @@ function checkProject(cwd = process.cwd()) {
140
140
  });
141
141
 
142
142
  const published = manifest.private !== true;
143
- const filename = config.projectChecks?.testWorkflow || config.git.releaseTestWorkflow || 'test.yml';
144
- const required = [...new Set([...(config.projectChecks?.requiredJobs || config.git.releaseRequiredChecks || ['test']), ...(published ? config.git.releaseRequiredChecks || [] : [])])];
143
+ const filename = config.projectChecks.testWorkflow;
144
+ const required = [...new Set([...(config.projectChecks.requiredJobs), ...(published ? config.git.releaseRequiredChecks : [])])];
145
145
  check('CI', () => {
146
146
  const workflow = workflows.get(filename);
147
- if (!requireThat(workflow, `${filename}: configured PR workflow is missing (selected by ${config.projectChecks?.testWorkflow ? 'projectChecks.testWorkflow' : config.git.releaseTestWorkflow ? 'git.releaseTestWorkflow' : 'default test.yml'}). Set projectChecks.testWorkflow${published ? ' and git.releaseTestWorkflow to the same existing file' : ' to an existing file'} in peacekeeper.config.json. PR checks are blocked until it can be read.`)) return;
147
+ if (!requireThat(workflow, `${filename}: configured PR workflow is missing (selected by projectChecks.testWorkflow). Set projectChecks.testWorkflow${published ? ' and git.releaseTestWorkflow to the same existing file' : ' to an existing file'} in peacekeeper.config.json. PR checks are blocked until it can be read.`)) return;
148
148
  const on = workflow.on;
149
149
  const simpleTrigger = on === 'pull_request' || (Array.isArray(on) && on.includes('pull_request'));
150
150
  requireThat(simpleTrigger || (on && typeof on === 'object' && Object.hasOwn(on, 'pull_request')), `${filename}: declare an on.pull_request trigger.`);
@@ -179,7 +179,7 @@ function checkProject(cwd = process.cwd()) {
179
179
 
180
180
  if (published) check('publication', () => {
181
181
  requireThat(config.git.releaseRequiredChecks?.length, 'Configure git.releaseRequiredChecks for a published package.');
182
- requireThat(!config.git.releaseTestWorkflow || config.git.releaseTestWorkflow === filename,
182
+ requireThat(config.git.releaseTestWorkflow === filename,
183
183
  'projectChecks.testWorkflow must match git.releaseTestWorkflow.');
184
184
  requireThat(semver.valid(manifest.version) === manifest.version && !semver.prerelease(manifest.version), 'package.json: published packages need an exact stable version.');
185
185
  requireThat(['package-lock.json', 'npm-shrinkwrap.json'].some(file => fs.existsSync(path.join(root, file))), 'Commit a package lockfile before publishing.');
@@ -205,7 +205,7 @@ function checkProject(cwd = process.cwd()) {
205
205
  const permissions = job.permissions || workflow.permissions;
206
206
  requireThat(permissions && permissions['id-token'] === 'write' && ['read', 'write'].includes(permissions.contents),
207
207
  `${publishing.path}: declare contents: read and id-token: write for publishing.`);
208
- const selected = config.git.releaseTestWorkflow || path.basename(publishing.path);
208
+ const selected = config.git.releaseTestWorkflow;
209
209
  requireThat(selected === filename, 'Configure git.releaseTestWorkflow to select the checked PR workflow.');
210
210
  });
211
211
  return { errors, published };
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const { ROOT, environmentFingerprint, receiptPath, snapshot } = require('./receipt-state');
5
- const { projectConfig } = require('./project-config');
5
+ const { projectConfig, requireTestCommands } = require('./project-config');
6
6
  function successMessage(root = ROOT()) {
7
7
  const command = projectConfig(root).tests.npmTestUsesRunner
8
8
  ? 'npm test -- rerun'
@@ -20,6 +20,7 @@ function matchesReceipt(receipt, root = ROOT()) {
20
20
  }
21
21
 
22
22
  function checkReceipt(root = ROOT()) {
23
+ requireTestCommands(root);
23
24
  let receipt;
24
25
  try {
25
26
  receipt = JSON.parse(fs.readFileSync(receiptPath(root), 'utf8'));
@@ -176,14 +176,10 @@ function checkpoint(repositoryRoot, message, log = console.log) {
176
176
  return { committed: false, pushed: false };
177
177
  }
178
178
 
179
- const pushRemotes = checkpointPushRemotes ?? (
180
- remoteBranchExists(repositoryRoot, branch) ? [remote] : []
181
- );
179
+ const pushRemotes = checkpointPushRemotes;
182
180
  if (pushRemotes.length === 0) {
183
181
  clearFailureMarker(repositoryRoot);
184
- log(checkpointPushRemotes === undefined
185
- ? `Kept ${branch} local because ${remote}/${branch} does not exist.`
186
- : `Kept ${branch} local because checkpoint push is disabled.`);
182
+ log(`Kept ${branch} local because checkpoint push is disabled.`);
187
183
  return { committed: status !== '', pushed: false };
188
184
  }
189
185
 
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const fs = require('node:fs');
4
3
  const { checkpointInstructions } = require('./checkpoint-instructions');
5
4
  const { projectConfig } = require('./project-config');
6
- const { isPrimaryWorktree, logScriptInvocation } = require('./log-script-invocation');
5
+ const { isPrimaryWorktree, runLoggedCheckpointHook } = require('./log-script-invocation');
7
6
 
8
7
  function buildResponse(event) {
9
8
  if (event?.hook_event_name !== 'SessionStart' || typeof event.cwd !== 'string') {
@@ -18,20 +17,8 @@ function buildResponse(event) {
18
17
  };
19
18
  }
20
19
 
21
- function main() {
22
- logScriptInvocation();
23
- const input = fs.readFileSync(0, 'utf8').trim();
24
- process.stdout.write(`${JSON.stringify(buildResponse(input === '' ? null : JSON.parse(input)))}\n`);
25
- }
26
-
27
20
  if (require.main === module) {
28
- try {
29
- main();
30
- } catch (error) {
31
- process.stdout.write(`${JSON.stringify({
32
- systemMessage: `Codex checkpoint instructions failed: ${error.message}`,
33
- })}\n`);
34
- }
21
+ runLoggedCheckpointHook(buildResponse, 'Codex checkpoint instructions failed');
35
22
  }
36
23
 
37
24
  module.exports = { buildResponse };
@@ -14,7 +14,7 @@ const { projectConfig } = require('./project-config');
14
14
  const { checkpointInstructions } = require('./checkpoint-instructions');
15
15
  const {
16
16
  isPrimaryWorktree,
17
- logScriptInvocation,
17
+ runLoggedCheckpointHook,
18
18
  } = require('./log-script-invocation.js');
19
19
 
20
20
  function runGit(args, cwd) {
@@ -79,21 +79,8 @@ function buildResponse(event) {
79
79
  };
80
80
  }
81
81
 
82
- function main() {
83
- logScriptInvocation();
84
- const input = fs.readFileSync(0, 'utf8').trim();
85
- const event = input === '' ? null : JSON.parse(input);
86
- process.stdout.write(`${JSON.stringify(buildResponse(event))}\n`);
87
- }
88
-
89
82
  if (require.main === module) {
90
- try {
91
- main();
92
- } catch (error) {
93
- process.stdout.write(`${JSON.stringify({
94
- systemMessage: `Codex checkpoint check failed: ${error.message}`,
95
- })}\n`);
96
- }
83
+ runLoggedCheckpointHook(buildResponse, 'Codex checkpoint check failed');
97
84
  }
98
85
 
99
86
  module.exports = { buildResponse };
@@ -267,8 +267,9 @@ function runWorker(target) {
267
267
  }
268
268
 
269
269
  function launchWorker(target) {
270
- const logPath = path.join(target.commonGitDirectory, 'cleanup-ended-session.log');
271
- const logFile = fs.openSync(logPath, 'a');
270
+ const loggingEnabled = projectConfig(target.repositoryRoot).loggingEnabled !== false;
271
+ const logPath = loggingEnabled ? path.join(target.commonGitDirectory, 'cleanup-ended-session.log') : null;
272
+ const logFile = loggingEnabled ? fs.openSync(logPath, 'a') : 'ignore';
272
273
  const payload = Buffer.from(JSON.stringify(target)).toString('base64url');
273
274
  const child = spawn(process.execPath, [__filename, '--worker', payload], {
274
275
  cwd: target.repositoryRoot,
@@ -277,7 +278,7 @@ function launchWorker(target) {
277
278
  });
278
279
 
279
280
  child.unref();
280
- fs.closeSync(logFile);
281
+ if (loggingEnabled) fs.closeSync(logFile);
281
282
  return { logPath, pid: child.pid };
282
283
  }
283
284
 
@@ -60,18 +60,49 @@ function logScriptInvocation(options = {}) {
60
60
  const date = options.date || new Date();
61
61
  const processId = options.processId || process.pid;
62
62
  const repositoryRoot = sourceRepositoryRoot(cwd);
63
- const logDirectory = path.join(repositoryRoot, projectConfig(repositoryRoot).loggingDirectory);
63
+ const config = projectConfig(repositoryRoot);
64
+ if (config.loggingEnabled === false) return null;
65
+ const logDirectory = path.join(repositoryRoot, config.loggingDirectory);
64
66
  const scriptName = path.basename(argv[1] || 'unknown-script');
65
67
  const fileName = `${filesystemTimestamp(date)}-${scriptName}-${processId}.log`;
66
68
  const commandLine = argv.map(shellQuote).join(' ');
67
69
 
68
70
  fs.mkdirSync(logDirectory, { recursive: true });
69
71
  const logPath = path.join(logDirectory, fileName);
70
- fs.writeFileSync(logPath, `${commandLine}\n`, { flag: 'wx' });
72
+ fs.writeFileSync(logPath, `${commandLine}\n${JSON.stringify({ cwd })}\n`, { flag: 'wx' });
71
73
  return logPath;
72
74
  }
73
75
 
76
+ // Only allowlisted event fields are recorded; hook payloads can contain user content.
77
+ function runLoggedCheckpointHook(buildResponse, errorPrefix) {
78
+ let logPath;
79
+ let event;
80
+ let response;
81
+ try {
82
+ logPath = logScriptInvocation();
83
+ const input = fs.readFileSync(0, 'utf8').trim();
84
+ event = input === '' ? null : JSON.parse(input);
85
+ const details = {
86
+ hookEventName: event?.hook_event_name,
87
+ eventCwd: event?.cwd,
88
+ source: event?.source,
89
+ stopHookActive: event?.stop_hook_active,
90
+ };
91
+ if (logPath) fs.appendFileSync(logPath, `${JSON.stringify({ event: details })}\n`);
92
+ if (typeof event?.cwd === 'string') {
93
+ const primaryWorktree = isPrimaryWorktree(event.cwd);
94
+ if (logPath) fs.appendFileSync(logPath, `${JSON.stringify({ primaryWorktree })}\n`);
95
+ }
96
+ response = buildResponse(event);
97
+ } catch (error) {
98
+ response = { systemMessage: `${errorPrefix}: ${error.message}` };
99
+ }
100
+ if (logPath) fs.appendFileSync(logPath, `${JSON.stringify({ response })}\n`);
101
+ process.stdout.write(`${JSON.stringify(response)}\n`);
102
+ }
103
+
74
104
  module.exports = {
105
+ runLoggedCheckpointHook,
75
106
  filesystemTimestamp,
76
107
  isPrimaryWorktree,
77
108
  logScriptInvocation,
@@ -0,0 +1,26 @@
1
+ {
2
+ "git": {
3
+ "remote": "origin",
4
+ "defaultBranch": "main",
5
+ "branchPrefix": "codex/",
6
+ "checkpointPushRemotes": [],
7
+ "releaseMergeMethod": "merge",
8
+ "releaseRequiredChecks": [
9
+ "test"
10
+ ],
11
+ "releaseTestWorkflow": "test.yml"
12
+ },
13
+ "tests": {
14
+ "commands": [],
15
+ "receiptMaxAgeMinutes": 60,
16
+ "npmTestUsesRunner": false
17
+ },
18
+ "loggingEnabled": true,
19
+ "loggingDirectory": "tmp/logs",
20
+ "projectChecks": {
21
+ "testWorkflow": "test.yml",
22
+ "requiredJobs": [
23
+ "test"
24
+ ]
25
+ }
26
+ }
@@ -11,15 +11,29 @@ function repositoryRoot(cwd = process.cwd()) {
11
11
  }).trim();
12
12
  }
13
13
 
14
- function requireExactKeys(value, keys, location, file, optionalKeys = []) {
14
+ // Kept independent of the defaults so additions require an explicit default.
15
+ const CONFIG_SCHEMA = {
16
+ git: { remote: true, defaultBranch: true, branchPrefix: true,
17
+ checkpointPushRemotes: true, releaseMergeMethod: true,
18
+ releaseRequiredChecks: true, releaseTestWorkflow: true },
19
+ tests: { commands: true, receiptMaxAgeMinutes: true, npmTestUsesRunner: true },
20
+ loggingDirectory: true,
21
+ loggingEnabled: true,
22
+ projectChecks: { testWorkflow: true, requiredJobs: true },
23
+ };
24
+
25
+ function validateShape(value, schema, location, errors) {
15
26
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
16
- throw new Error(`${file}: ${location} must be an object`);
27
+ errors.push(`${location} must be an object`);
28
+ return;
17
29
  }
18
30
  for (const key of Object.keys(value)) {
19
- if (!keys.includes(key) && !optionalKeys.includes(key)) throw new Error(`${file}: unknown field ${location}.${key}`);
31
+ if (!Object.hasOwn(schema, key)) errors.push(`unknown field ${location}.${key}`);
20
32
  }
21
- for (const key of keys) {
22
- if (!Object.hasOwn(value, key)) throw new Error(`${file}: missing field ${location}.${key}`);
33
+ for (const [key, child] of Object.entries(schema)) {
34
+ const name = location === 'config' ? key : `${location}.${key}`;
35
+ if (!Object.hasOwn(value, key)) errors.push(`missing field ${name}`);
36
+ else if (child !== true) validateShape(value[key], child, name, errors);
23
37
  }
24
38
  }
25
39
 
@@ -30,51 +44,44 @@ function requireNonemptyString(value, location, file) {
30
44
  }
31
45
 
32
46
  function validateConfig(config, file) {
33
- requireExactKeys(config, ['git', 'tests', 'loggingDirectory'], 'config', file, ['projectChecks']);
34
- if (Object.hasOwn(config, 'projectChecks')) {
35
- requireExactKeys(config.projectChecks, ['testWorkflow', 'requiredJobs'], 'projectChecks', file);
36
- if (typeof config.projectChecks.testWorkflow !== 'string' || !/^[A-Za-z0-9_-]+\.ya?ml$/u.test(config.projectChecks.testWorkflow)) {
37
- throw new Error(`${file}: projectChecks.testWorkflow must be a workflow filename`);
38
- }
39
- const jobs = config.projectChecks.requiredJobs;
40
- if (!Array.isArray(jobs) || !jobs.length || jobs.some(job => typeof job !== 'string' || !job.trim() || job !== job.trim()) || new Set(jobs).size !== jobs.length) {
41
- throw new Error(`${file}: projectChecks.requiredJobs must contain unique nonempty job names`);
42
- }
43
- }
44
- requireExactKeys(config.git, ['remote', 'defaultBranch', 'branchPrefix'], 'git', file, ['checkpointPushRemotes', 'releaseMergeMethod', 'releaseRequiredChecks', 'releaseTestWorkflow']);
45
- requireExactKeys(config.tests, ['commands', 'receiptMaxAgeMinutes', 'npmTestUsesRunner'], 'tests', file);
47
+ const errors = [];
48
+ validateShape(config, CONFIG_SCHEMA, 'config', errors);
49
+ if (errors.length) throw new Error(`${file}: ${errors.join('; ')}. Run peacekeeper update-config to add missing fields.`);
50
+ if (typeof config.projectChecks.testWorkflow !== 'string' || !/^[A-Za-z0-9_-]+\.ya?ml$/u.test(config.projectChecks.testWorkflow)) {
51
+ throw new Error(`${file}: projectChecks.testWorkflow must be a workflow filename`);
52
+ }
53
+ const jobs = config.projectChecks.requiredJobs;
54
+ if (!Array.isArray(jobs) || jobs.some(job => typeof job !== 'string' || !job.trim() || job !== job.trim()) || new Set(jobs).size !== jobs.length) {
55
+ throw new Error(`${file}: projectChecks.requiredJobs must contain unique nonempty job names`);
56
+ }
46
57
 
47
58
  for (const field of ['remote', 'defaultBranch', 'branchPrefix']) {
48
59
  requireNonemptyString(config.git[field], `git.${field}`, file);
49
60
  }
50
- if (Object.hasOwn(config.git, 'checkpointPushRemotes')) {
51
- const remotes = config.git.checkpointPushRemotes;
52
- if (!Array.isArray(remotes)
53
- || remotes.some((remote) => typeof remote !== 'string' || remote.trim() === ''
54
- || remote !== remote.trim() || remote.startsWith('-'))
55
- || new Set(remotes).size !== remotes.length) {
56
- throw new Error(`${file}: git.checkpointPushRemotes must be an array of unique remote names`);
57
- }
58
- }
59
- if (Object.hasOwn(config.git, 'releaseTestWorkflow')) {
60
- if (typeof config.git.releaseTestWorkflow !== 'string' || !/^[A-Za-z0-9_-]+\.ya?ml$/u.test(config.git.releaseTestWorkflow)) {
61
- throw new Error(`${file}: git.releaseTestWorkflow must be a workflow filename ending in .yml or .yaml`);
62
- }
63
- }
64
- if (Object.hasOwn(config.git, 'releaseRequiredChecks')) {
65
- const checks = config.git.releaseRequiredChecks;
66
- if (!Array.isArray(checks) || !checks.length || checks.some(check => typeof check !== 'string' || !check.trim() || check !== check.trim()) || new Set(checks).size !== checks.length) {
67
- throw new Error(`${file}: git.releaseRequiredChecks must be a nonempty array of unique check names`);
68
- }
69
- }
70
- if (Object.hasOwn(config.git, 'releaseMergeMethod')
71
- && !['merge', 'squash', 'rebase'].includes(config.git.releaseMergeMethod)) {
61
+ const remotes = config.git.checkpointPushRemotes;
62
+ if (!Array.isArray(remotes)
63
+ || remotes.some((remote) => typeof remote !== 'string' || remote.trim() === ''
64
+ || remote !== remote.trim() || remote.startsWith('-'))
65
+ || new Set(remotes).size !== remotes.length) {
66
+ throw new Error(`${file}: git.checkpointPushRemotes must be an array of unique remote names`);
67
+ }
68
+ if (typeof config.git.releaseTestWorkflow !== 'string' || !/^[A-Za-z0-9_-]+\.ya?ml$/u.test(config.git.releaseTestWorkflow)) {
69
+ throw new Error(`${file}: git.releaseTestWorkflow must be a workflow filename ending in .yml or .yaml`);
70
+ }
71
+ const checks = config.git.releaseRequiredChecks;
72
+ if (!Array.isArray(checks) || checks.some(check => typeof check !== 'string' || !check.trim() || check !== check.trim()) || new Set(checks).size !== checks.length) {
73
+ throw new Error(`${file}: git.releaseRequiredChecks must be an array of unique check names`);
74
+ }
75
+ if (!['merge', 'squash', 'rebase'].includes(config.git.releaseMergeMethod)) {
72
76
  throw new Error(`${file}: git.releaseMergeMethod must be merge, squash, or rebase`);
73
77
  }
78
+ if (typeof config.loggingEnabled !== 'boolean') {
79
+ throw new Error(`${file}: loggingEnabled must be a boolean`);
80
+ }
74
81
  requireNonemptyString(config.loggingDirectory, 'loggingDirectory', file);
75
- if (!Array.isArray(config.tests.commands) || config.tests.commands.length === 0
82
+ if (!Array.isArray(config.tests.commands)
76
83
  || config.tests.commands.some((command) => typeof command !== 'string' || command.trim() === '')) {
77
- throw new Error(`${file}: tests.commands must be a nonempty array of command strings`);
84
+ throw new Error(`${file}: tests.commands must be an array of command strings`);
78
85
  }
79
86
  if (!Number.isSafeInteger(config.tests.receiptMaxAgeMinutes)
80
87
  || config.tests.receiptMaxAgeMinutes < 1) {
@@ -88,12 +95,17 @@ function validateConfig(config, file) {
88
95
  function projectConfig(cwd = process.cwd()) {
89
96
  const root = repositoryRoot(cwd);
90
97
  const file = path.join(root, CONFIG_FILE);
91
- if (!fs.existsSync(file)) {
92
- throw new Error(`${file}: configuration file is required; define tests.commands explicitly`);
93
- }
94
- const config = JSON.parse(fs.readFileSync(file, 'utf8'));
98
+ const config = fs.existsSync(file)
99
+ ? JSON.parse(fs.readFileSync(file, 'utf8'))
100
+ : structuredClone(require('./project-config-defaults.json'));
95
101
  validateConfig(config, file);
96
102
  return { root, ...config };
97
103
  }
98
104
 
99
- module.exports = { CONFIG_FILE, projectConfig, repositoryRoot };
105
+ function requireTestCommands(root) {
106
+ if (!projectConfig(root).tests.commands.length) {
107
+ throw new Error('Configure tests.commands before running tests or using a test receipt.');
108
+ }
109
+ }
110
+
111
+ module.exports = { CONFIG_FILE, CONFIG_SCHEMA, validateConfig, projectConfig, repositoryRoot, requireTestCommands };
@@ -43,7 +43,7 @@ function fileFingerprint(root, relative) {
43
43
 
44
44
  function sharedScriptsFingerprint() {
45
45
  const hash = crypto.createHash('sha256');
46
- for (const name of fs.readdirSync(__dirname).filter((file) => file.endsWith('.js')).sort()) {
46
+ for (const name of fs.readdirSync(__dirname).filter((file) => file.endsWith('.js') || file === 'project-config-defaults.json').sort()) {
47
47
  hash.update(name);
48
48
  hash.update('\0');
49
49
  hash.update(fs.readFileSync(path.join(__dirname, name)));
@@ -2,9 +2,11 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const { requireTestCommands } = require('./project-config');
5
6
  const { ROOT, receiptPath, snapshot } = require('./receipt-state');
6
7
 
7
8
  function recordReceipt(root = ROOT(), testedSnapshot) {
9
+ requireTestCommands(root);
8
10
  const current = snapshot(root, testedSnapshot?.baseCommit);
9
11
  if (testedSnapshot && JSON.stringify(current) !== JSON.stringify(testedSnapshot)) {
10
12
  throw new Error('Files or environment changed while tests ran; rerun tests before recording a receipt.');
@@ -4,7 +4,7 @@ const os = require('node:os');
4
4
  const path = require('node:path');
5
5
  const yaml = require('yaml');
6
6
  const { githubApi, requiredChecks, verifyProtection } = require('./release-protection');
7
- const { CONFIG_FILE, projectConfig } = require('./project-config');
7
+ const { projectConfig } = require('./project-config');
8
8
  const requiredCondition = "${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}";
9
9
  const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
10
10
  const pollIntervalMs = 5_000;
@@ -223,13 +223,12 @@ function prepare(exec, cwd, bump, log) {
223
223
  const remote = checked(exec, 'git', ['remote', 'get-url', 'origin'], cwd);
224
224
  const { host, slug } = originRepository(remote);
225
225
  const repository = github(exec, cwd, host, `repos/${slug}`, {});
226
- const configured = fs.existsSync(path.join(cwd, CONFIG_FILE))
227
- ? projectConfig(cwd).git.releaseMergeMethod : undefined;
226
+ const configured = projectConfig(cwd).git.releaseMergeMethod;
228
227
  const allowed = ['merge', 'rebase', 'squash'].filter((method) =>
229
228
  repository[{ merge: 'allow_merge_commit', rebase: 'allow_rebase_merge', squash: 'allow_squash_merge' }[method]] === true);
230
229
  if (!allowed.length)
231
230
  throw new ReleaseError('No supported PR merge method is enabled.');
232
- const mergeMethod = allowed.length === 1 ? allowed[0] : configured || allowed[0];
231
+ const mergeMethod = allowed.length === 1 ? allowed[0] : configured;
233
232
  if (!allowed.includes(mergeMethod))
234
233
  throw new ReleaseError(`Configured release merge method ${mergeMethod} is not enabled on GitHub.`);
235
234
  const defaultBranch = repository.default_branch;
@@ -4,7 +4,7 @@ const { spawnSync } = require('node:child_process');
4
4
  const fs = require('node:fs');
5
5
  const { checkReceipt, successMessage } = require('./check-test-receipt');
6
6
  const { recordReceipt } = require('./record-test-receipt');
7
- const { projectConfig } = require('./project-config');
7
+ const { projectConfig, requireTestCommands } = require('./project-config');
8
8
  const { receiptPath, snapshot } = require('./receipt-state');
9
9
 
10
10
  function run(command, root) {
@@ -23,6 +23,7 @@ function hasReusableReceipt() {
23
23
  }
24
24
 
25
25
  function main() {
26
+ requireTestCommands();
26
27
  const config = projectConfig();
27
28
  const force = process.argv.length === 3 && ['rerun', '--force'].includes(process.argv[2]);
28
29
  if (process.argv.length !== 2 && !force) {
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { isDeepStrictEqual } = require('node:util');
6
+ const { CONFIG_FILE, repositoryRoot, validateConfig } = require('./project-config');
7
+
8
+ function complete(value, defaults) {
9
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
10
+ const result = { ...value };
11
+ for (const [key, fallback] of Object.entries(defaults)) {
12
+ if (!Object.hasOwn(value, key)) result[key] = structuredClone(fallback);
13
+ else if (fallback !== null && typeof fallback === 'object' && !Array.isArray(fallback)) {
14
+ result[key] = complete(value[key], fallback);
15
+ }
16
+ }
17
+ return result;
18
+ }
19
+
20
+ function updateConfig(cwd = process.cwd()) {
21
+ const file = path.join(repositoryRoot(cwd), CONFIG_FILE);
22
+ const defaults = require('./project-config-defaults.json');
23
+ validateConfig(defaults, 'bundled defaults');
24
+ const exists = fs.existsSync(file);
25
+ const original = exists ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
26
+ const updated = complete(original, defaults);
27
+ validateConfig(updated, file);
28
+ if (!exists || !isDeepStrictEqual(original, updated)) {
29
+ fs.writeFileSync(file, `${JSON.stringify(updated, null, 2)}\n`);
30
+ console.log(`Updated ${file}. Review defaults before using project commands.`);
31
+ } else console.log(`Configuration already complete: ${file}`);
32
+ }
33
+ if (require.main === module) {
34
+ try {
35
+ const args = process.argv.slice(2);
36
+ if (args.length === 1 && ['--help', '-h'].includes(args[0])) console.log('Usage: peacekeeper update-config');
37
+ else if (args.length) throw new Error('Usage: peacekeeper update-config');
38
+ else updateConfig();
39
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
40
+ }
41
+ module.exports = { updateConfig };
@@ -17,9 +17,9 @@ try {
17
17
  const api = githubApi((command, args, options) => spawnSync(command, args, { ...options, encoding: 'utf8' }), cwd, host);
18
18
  const tag = process.env.RELEASE_TAG;
19
19
  const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
20
- const publishWorkflow = inspectWorkflow(cwd, tag);
20
+ inspectWorkflow(cwd, tag);
21
21
  verifyRelease({ api, slug, sha, tag, branch: config.git.defaultBranch,
22
- checks: requiredChecks(cwd), workflow: config.git.releaseTestWorkflow || path.basename(publishWorkflow.path) });
22
+ checks: requiredChecks(cwd), workflow: config.git.releaseTestWorkflow });
23
23
  } catch (error) {
24
24
  console.error(error.message);
25
25
  process.exitCode = 1;