create-agent-rig 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +140 -34
  2. package/README.md +12 -6
  3. package/package.json +1 -1
  4. package/packages/cli/dist/commands/init.js +6 -3
  5. package/packages/cli/dist/commands/upgrade.js +2 -2
  6. package/packages/cli/dist/index.js +46 -8
  7. package/packages/cli/dist/lib/manifest.js +10 -0
  8. package/scripts/prepare.mjs +1 -1
  9. package/templates/agent-os/init/AGENTS.md +11 -3
  10. package/templates/agent-os/init/CLAUDE.md +11 -3
  11. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
  12. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
  13. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
  14. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
  15. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  16. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
  17. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +298 -16
  18. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
  19. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
  20. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -1
  21. package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
  22. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +28 -3
  23. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  24. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +23 -0
  25. package/templates/agent-os/universal/.claude/rules/autonomy.md +8 -0
  26. package/templates/agent-os/universal/.claude/rules/invariants.md +33 -3
  27. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +19 -1
  28. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  29. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  30. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  31. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
  32. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
  33. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  34. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
  35. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
  36. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
  37. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +138 -15
  38. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +394 -46
  39. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
  40. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  41. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  42. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
  43. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  44. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  45. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
  46. package/templates/agent-os/universal/.claude/settings.json +5 -1
  47. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
  48. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +298 -16
  49. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
  50. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
  51. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  52. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  53. package/templates/agent-os/universal/.codex/hooks.json +6 -1
  54. package/templates/agent-os/universal/AGENTS.md +3 -1
  55. package/templates/agent-os/universal/CLAUDE.md +3 -1
  56. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  57. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
  58. package/templates/agent-os/universal/layers.json +9 -0
  59. package/templates/hash-history.json +309 -49
  60. package/templates/release-ledger.json +9 -0
@@ -70,7 +70,9 @@
70
70
  * one.** A caller that read absence as "the head I am holding" would take a
71
71
  * verdict about a diff nobody named as a verdict about the diff it is about
72
72
  * to merge, so a caller that needs the answer keyed to a commit handles
73
- * absence itself.
73
+ * absence itself — `lib/gate-coverage.mjs` is the one that does, and it puts
74
+ * such a verdict in its own list rather than counting it either way. When
75
+ * present the value is a commit SHAPE, not free text: see `isCommitId`.
74
76
  */
75
77
 
76
78
  /** Every word any gate in this rulebook may return. */
@@ -166,11 +168,14 @@ const DIAGNOSIS_LIMIT = 120;
166
168
  * worst they buy is a misread within those backticks. Escape sequences are the ones
167
169
  * that rewrite the operator's screen, and those are what this removes.
168
170
  *
169
- * ⚠ **The CLI's own argv is not passed through it.** The report's path and the
170
- * subcommand are printed exactly as the caller wrote them, because their whole job
171
- * is to be pasted back into a command; the expected-gate argument IS sanitised,
172
- * because it is printed beside a reviewer-written gate as one of two names the
173
- * operator compares, and a value that can repaint that comparison defeats it.
171
+ * ⚠ **Two of the CLI's own arguments are passed through it, and two are not.**
172
+ * The report's path and the subcommand are printed exactly as the caller wrote
173
+ * them, because their whole job is to be pasted back into a command. The
174
+ * expected-gate argument IS sanitised, because it is printed beside a
175
+ * reviewer-written gate as one of two names the operator compares, and a value
176
+ * that can repaint that comparison defeats it. So is `coverage`'s commit
177
+ * argument, which is printed beside journal-written reviewer names for the same
178
+ * reason.
174
179
  *
175
180
  * It never throws — it is called only where something has already gone wrong, and a
176
181
  * sanitiser that throws there turns a diagnosis into a crash the caller reads as
@@ -210,6 +215,29 @@ const isPlainObject = (value) =>
210
215
 
211
216
  const isText = (value) => typeof value === 'string' && value.trim() !== '';
212
217
 
218
+ /**
219
+ * A commit id, checked as a SHAPE rather than as text (AR-79).
220
+ *
221
+ * `headSha` arrived as "any non-blank text". **No consumer interpolates it
222
+ * today** — the only reader compares it as a string (`lib/gate-coverage.mjs`),
223
+ * and the merge criterion takes its SHA from `gh pr view --json headRefOid`, not
224
+ * from a verdict. The shape is fixed here so that the first consumer that does
225
+ * put it in an argument position inherits the check instead of having to
226
+ * remember it; `--upload-pack=…`, a leading `-` and `../…` are not commits, and
227
+ * a field that only ever holds commits is the cheap way to keep them out.
228
+ *
229
+ * ⚠ **This covers the value inside a verdict block, and nothing else.**
230
+ * `run-journal.mjs` takes `headSha` on a decision record as any non-blank
231
+ * string, and `pr-ship`'s fan-out record reaches it without passing through
232
+ * `parseVerdict` at all — so a commit id that never came from a reviewer's block
233
+ * is not shaped by this.
234
+ *
235
+ * **Checked on the raw value — never trimmed, never lowercased.** A value that
236
+ * needs rewriting to pass is a value the reviewer did not write, and rewriting it
237
+ * silently is how a near-miss becomes a match.
238
+ */
239
+ export const isCommitId = (value) => typeof value === 'string' && /^[0-9a-f]{7,64}$/i.test(value);
240
+
213
241
  /**
214
242
  * The last fenced ```json block, as one of three answers: `{ raw }` for a block
215
243
  * whose extent is unambiguous, `{ ambiguous: true }` for one carrying a fence of
@@ -398,10 +426,11 @@ export function parseVerdict(text) {
398
426
  }
399
427
 
400
428
  const headSha = parsed.headSha;
401
- if (headSha !== undefined && !isText(headSha)) {
429
+ if (headSha !== undefined && !isCommitId(headSha)) {
402
430
  problems.push(
403
431
  `\`headSha\` is \`${safeForDiagnosis(headSha)}\`, which is not a commit this verdict ` +
404
- 'could have answered for. It is optional, and a line of text when present.',
432
+ 'could have answered for. It is optional, and 7 to 64 hex characters (0-9a-f) when ' +
433
+ 'present — nothing else, and with no surrounding space.',
405
434
  );
406
435
  }
407
436
 
@@ -11,7 +11,7 @@
11
11
  // reason it is safe to script half a checklist. The honest objection to a partial
12
12
  // script — "a script that half-checks is worse than a list the run actually
13
13
  // reads" — is true exactly while the boundary is invisible. A silent script would
14
- // let a GO on three items read as a pass on six.
14
+ // let a GO on four items read as a pass on seven.
15
15
  //
16
16
  // 🔴 **`unknown` never becomes `pass`.** A probe that could not run tells you
17
17
  // nothing, and "I could not look" recorded as "it is fine" is the failure this
@@ -95,6 +95,31 @@ export const checkDefaultBranchFresh = () => {
95
95
  }
96
96
  };
97
97
 
98
+ /**
99
+ * `RIG_RUN_DIR` must not already be exported when preflight runs (AR-139).
100
+ *
101
+ * Preflight walks BEFORE this run declares its directory (`loop` §1), so a
102
+ * value already in the environment is a leak — an `export` that outlived an
103
+ * earlier run, or a shell that inherited one. Everything the run then spawns
104
+ * inherits it too: the queue CLI under test, the gate scripts, and the real
105
+ * run's append-only trace receives their fixture records. Measured at 38
106
+ * fixture selections and 22 fixture revalidation events, plus two tests
107
+ * exiting 1 and blamed on load. A hard failure, because starting on it puts
108
+ * this run's stops in somebody else's file.
109
+ */
110
+ export const checkRunDirNotExported = (env = process.env) => {
111
+ const value = env.RIG_RUN_DIR;
112
+ return value
113
+ ? {
114
+ ok: false,
115
+ detail:
116
+ `RIG_RUN_DIR is already exported (${value}) — a leak from an earlier run. ` +
117
+ '`unset RIG_RUN_DIR`, then declare this run\'s own directory; an inherited ' +
118
+ "one lands this run's trace and stop conditions in somebody else's file",
119
+ }
120
+ : { ok: true, detail: 'not exported' };
121
+ };
122
+
98
123
  /** The last deploy must have concluded successfully — never start on a broken runtime. */
99
124
  export const checkLastDeploy = ({ workflow = 'deploy' } = {}) => {
100
125
  try {
@@ -172,6 +197,7 @@ const invokedDirectly = () => {
172
197
  if (invokedDirectly()) {
173
198
  const checks = {
174
199
  killSwitch: checkKillSwitch(),
200
+ runDirNotExported: checkRunDirNotExported(),
175
201
  defaultBranchFresh: checkDefaultBranchFresh(),
176
202
  lastDeploy: checkLastDeploy(),
177
203
  };
@@ -0,0 +1,51 @@
1
+ // The two git questions a proposal's `asOf` needs answered (AR-116):
2
+ // which commit is HEAD, and what changed between a commit and HEAD.
3
+ //
4
+ // Kept out of `core.mjs`, which is pure, and out of the adapters, which would
5
+ // otherwise each spawn git their own way. Both answers are `null` when git
6
+ // cannot answer — no checkout, an unknown commit, a shallow clone — and
7
+ // `overtakenOf` in core.mjs turns that `null` into an "unanswerable" finding
8
+ // rather than a clean one.
9
+
10
+ import { execFileSync } from 'node:child_process';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { withoutGitLocation } from '../git-env.mjs';
14
+
15
+ const git = (args, cwd) =>
16
+ execFileSync('git', args, {
17
+ cwd,
18
+ encoding: 'utf8',
19
+ stdio: ['ignore', 'pipe', 'ignore'],
20
+ env: withoutGitLocation(),
21
+ }).trim();
22
+
23
+ /** HEAD of the checkout at `cwd`, or null where there is none. */
24
+ export const headShaOf = ({ cwd = process.cwd() } = {}) => {
25
+ try {
26
+ const sha = git(['rev-parse', 'HEAD'], cwd);
27
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ };
32
+
33
+ /** Paths changed between `asOf` and `head`, or null when git cannot say. */
34
+ export const changedSinceOf = ({ cwd = process.cwd(), asOf, head = 'HEAD' } = {}) => {
35
+ if (typeof asOf !== 'string' || !/^[0-9a-f]{7,40}$/.test(asOf)) return null;
36
+ try {
37
+ return git(['diff', '--name-only', '-z', asOf, head], cwd).split('\0').filter(Boolean);
38
+ } catch {
39
+ return null;
40
+ }
41
+ };
42
+
43
+ /**
44
+ * The commit a proposal is measured against: what the caller says, or HEAD of
45
+ * the project this script belongs to. `null` files without one — and hygiene
46
+ * then reports the proposal as unanswerable rather than current. One
47
+ * implementation for all three adapters, so they cannot answer differently.
48
+ */
49
+ const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
50
+ export const withAsOf = (proposal) =>
51
+ proposal.asOf === undefined ? { ...proposal, asOf: headShaOf({ cwd: PROJECT_ROOT }) } : proposal;
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { execFileSync } from 'node:child_process';
17
- import { dirname } from 'node:path';
17
+ import { dirname, resolve } from 'node:path';
18
18
 
19
19
  // The sanitiser is IMPORTED, never re-derived. This repo had already ruled on
20
20
  // which git variables to strip, exported the answer with its stated limit, and
@@ -76,7 +76,9 @@ export const mainCheckoutRoot = (startDir) => {
76
76
  env: { ...withoutGitLocation(), LC_ALL: 'C', LANGUAGE: '' },
77
77
  },
78
78
  ).trim();
79
- return gitDir ? dirname(gitDir) : startDir;
79
+ // `resolve`, because git answers with forward slashes on Windows
80
+ // (`C:/Users/...`) and every caller compares against a native path.
81
+ return gitDir ? resolve(dirname(gitDir)) : startDir;
80
82
  } catch (error) {
81
83
  if (error?.code === 'ENOENT') return startDir; // git is not installed
82
84
  const stderr = String(error?.stderr ?? '');
@@ -87,3 +89,61 @@ export const mainCheckoutRoot = (startDir) => {
87
89
  );
88
90
  }
89
91
  };
92
+
93
+ /**
94
+ * Is this checkout in a state a gate round may be counted against (AR-141)?
95
+ *
96
+ * A round is counted per branch and the fan-out's verdicts name a head. On one
97
+ * branch two rounds were counted before a commit that pre-commit then refused,
98
+ * so the counter and the records named a head that never shipped. The three
99
+ * states that make a head unshippable are decidable from git alone: a dirty
100
+ * working tree (tracked or untracked), a branch with no upstream, and commits
101
+ * the upstream has not seen. `{ ok: true }` otherwise; a git that cannot answer
102
+ * is reported as such, never as clean.
103
+ *
104
+ * Bounded: two git calls, each with a timeout, and the porcelain output is
105
+ * read only for emptiness. The upstream question has one catch for three
106
+ * states — a detached HEAD, a branch with no upstream, an upstream that is
107
+ * gone — and names none of them apart; the refusal is right for all three.
108
+ */
109
+ export const checkoutIsShippable = (root) => {
110
+ const git = (args) =>
111
+ execFileSync('git', args, {
112
+ cwd: root,
113
+ encoding: 'utf8',
114
+ stdio: ['ignore', 'pipe', 'ignore'],
115
+ env: withoutGitLocation(),
116
+ timeout: 30_000,
117
+ }).trim();
118
+ let status;
119
+ try {
120
+ status = git(['status', '--porcelain']);
121
+ } catch (error) {
122
+ return { ok: false, why: `git could not read the working tree at ${root}: ${error.message}` };
123
+ }
124
+ if (status !== '') {
125
+ return {
126
+ ok: false,
127
+ why: 'the working tree is dirty — a round counted now would name a head that has not ' +
128
+ 'been committed; commit (and push) first',
129
+ };
130
+ }
131
+ let ahead;
132
+ try {
133
+ ahead = git(['rev-list', '--count', '@{upstream}..HEAD']);
134
+ } catch {
135
+ return {
136
+ ok: false,
137
+ why: 'HEAD has no upstream — push the branch first, so the round names a head the ' +
138
+ 'reviewers and CI can see',
139
+ };
140
+ }
141
+ if (ahead !== '0') {
142
+ return {
143
+ ok: false,
144
+ why: `HEAD is ${ahead} commit(s) ahead of its upstream — push first, so the round names ` +
145
+ 'the head that ships',
146
+ };
147
+ }
148
+ return { ok: true };
149
+ };