create-agent-rig 0.6.2 → 0.7.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/CHANGELOG.md +95 -0
- package/package.json +1 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +2 -3
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +117 -80
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +19 -12
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +23 -3
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +65 -7
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +2 -2
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +2 -2
- package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +109 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +19 -0
- package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +800 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-evidence.mjs +56 -0
- package/templates/agent-os/universal/.claude/scripts/lib/shell-tools.mjs +81 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +19 -1
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +17 -66
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +29 -7
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +159 -23
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +41 -19
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +4 -2
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +268 -48
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +32 -15
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +180 -37
- package/templates/agent-os/universal/.claude/settings.json +1 -1
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +117 -80
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +19 -12
- package/templates/agent-os/universal/.codex/hooks.json +1 -1
- package/templates/agent-os/universal/.rig/revalidation.json +10 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +3 -2
- package/templates/agent-os/universal/docs/decisions/content-blind-revalidation.md +144 -0
- package/templates/agent-os/universal/layers.json +5 -0
- package/templates/hash-history.json +36 -15
- package/templates/release-ledger.json +2 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Resolution indexes shared by selection and the aggregate report. */
|
|
2
|
+
export const typedResolutionsOf = (events = []) => {
|
|
3
|
+
const resolutions = new Map();
|
|
4
|
+
for (const event of events) {
|
|
5
|
+
const actionRequired = event.data?.actionRequired ?? event.data?.actionChanged;
|
|
6
|
+
if (
|
|
7
|
+
event.kind !== 'revalidation-outcome' ||
|
|
8
|
+
typeof event.data?.detectionId !== 'string' ||
|
|
9
|
+
typeof actionRequired !== 'boolean'
|
|
10
|
+
) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const resolvedAt = Date.parse(event.data?.resolvedAt ?? event.at);
|
|
14
|
+
if (!Number.isFinite(resolvedAt)) continue;
|
|
15
|
+
const matching = resolutions.get(event.data.detectionId) ?? [];
|
|
16
|
+
matching.push({ resolvedAt, data: event.data });
|
|
17
|
+
resolutions.set(event.data.detectionId, matching);
|
|
18
|
+
}
|
|
19
|
+
return resolutions;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const typedResolutionOf = (resolutions, event) => {
|
|
23
|
+
const detectionAt = Date.parse(event.at);
|
|
24
|
+
if (typeof event.data?.id !== 'string' || !Number.isFinite(detectionAt)) return null;
|
|
25
|
+
return (
|
|
26
|
+
(resolutions.get(event.data.id) ?? [])
|
|
27
|
+
.filter((resolution) => resolution.resolvedAt >= detectionAt)
|
|
28
|
+
.sort((left, right) => left.resolvedAt - right.resolvedAt)[0]?.data ?? null
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The newest blocking detection in one run that no typed outcome resolves. */
|
|
33
|
+
export const unresolvedBlockingDetectionOf = (events = []) => {
|
|
34
|
+
const resolutions = typedResolutionsOf(events);
|
|
35
|
+
for (const event of [...events].reverse()) {
|
|
36
|
+
if (event.kind !== 'revalidation') continue;
|
|
37
|
+
const result = event.data?.result;
|
|
38
|
+
if (!['CHANGED', 'CONFLICT', 'UNVERIFIABLE'].includes(result)) continue;
|
|
39
|
+
if (typedResolutionOf(resolutions, event)) continue;
|
|
40
|
+
if (
|
|
41
|
+
typeof event.data?.id !== 'string' ||
|
|
42
|
+
typeof event.data?.ticket !== 'string' ||
|
|
43
|
+
typeof (event.data?.checkpoint ?? event.data?.point) !== 'string'
|
|
44
|
+
) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
kind: 'revalidation-hold',
|
|
49
|
+
ticket: event.data.ticket,
|
|
50
|
+
checkpoint: event.data.checkpoint ?? event.data.point,
|
|
51
|
+
result,
|
|
52
|
+
detectionId: event.data.id,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell-executing tools this rulebook's Never-tier guards must see.
|
|
3
|
+
*
|
|
4
|
+
* ONE list, because two spellings of the same fact disagree eventually and the
|
|
5
|
+
* copy nobody is looking at is the one that is wrong (`.claude/rules/invariants.md`,
|
|
6
|
+
* "One mechanism, one implementation"). `.claude/settings.json` cannot import
|
|
7
|
+
* this — it is data, not code — so the correspondence is held by a test rather
|
|
8
|
+
* than by generation: `test/template/shell-tools.test.ts` reads both and refuses
|
|
9
|
+
* a tool named here that some shell guard's matcher omits, or a tool in that
|
|
10
|
+
* matcher this file does not name. That test lives in the generator and is
|
|
11
|
+
* absent in a generated rig.
|
|
12
|
+
*
|
|
13
|
+
* 🔴 **Wiring is not behaviour, and certifying only the wiring is how the gap
|
|
14
|
+
* this file was written to close survived its own PR.** The matcher was widened
|
|
15
|
+
* while both guards still opened with a literal `tool_name !== 'Bash'` and
|
|
16
|
+
* returned allow, so every Never-tier rule and the kill switch stayed
|
|
17
|
+
* bypassable on the other surface — with the configuration tests green.
|
|
18
|
+
* The guards therefore IMPORT this list rather than restating it, and the same
|
|
19
|
+
* test file now spawns them. It is absent in a generated rig, like the one
|
|
20
|
+
* above: `test/template/shell-tools.test.ts`
|
|
21
|
+
* › "guard-bash reaches its verdict on every shell tool, not just the one it
|
|
22
|
+
* was written for" and
|
|
23
|
+
* › "block-no-verify reaches its verdict on every shell tool"
|
|
24
|
+
* run one refusal and one positive control per entry below, so a surface added
|
|
25
|
+
* here without being honoured goes red.
|
|
26
|
+
*
|
|
27
|
+
* ── Why this exists (RP-65) ─────────────────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* `settings.json` wired `block-no-verify` and `guard-bash` under the matcher
|
|
30
|
+
* `Bash` alone. Measured on the live harness: `git commit --no-verify --dry-run`
|
|
31
|
+
* was BLOCKED through the `Bash` tool and RAN through the `PowerShell` tool, in
|
|
32
|
+
* the same session and the same checkout. On that surface the Never tier and the
|
|
33
|
+
* kill switch were prose.
|
|
34
|
+
*
|
|
35
|
+
* ── Why an enumerated list and not a wildcard ───────────────────────────────
|
|
36
|
+
*
|
|
37
|
+
* A matcher that admitted anything would hand these guards tools that execute
|
|
38
|
+
* nothing — and a guard that fires on a non-shell tool is one somebody turns
|
|
39
|
+
* off. Adding a surface is a deliberate edit here, which is also where the
|
|
40
|
+
* reason for each entry is written down.
|
|
41
|
+
*
|
|
42
|
+
* ── What widening the matcher does NOT buy ──────────────────────────────────
|
|
43
|
+
*
|
|
44
|
+
* 🔴 The same RULES now run on both surfaces; the PARSING is not thereby
|
|
45
|
+
* identical. `guard-bash` tokenises POSIX shell — quoting, separators, wrappers
|
|
46
|
+
* — and PowerShell's syntax is its own. A command whose danger is visible only
|
|
47
|
+
* after PowerShell-specific parsing can read differently there.
|
|
48
|
+
*
|
|
49
|
+
* 🔴 **And the kill switch is narrower than it sounds.** It matches a command
|
|
50
|
+
* NAME, so it refuses the operation only when the operation is spelled that
|
|
51
|
+
* way. Measured with the brake armed: `gh pr merge …` is refused on both
|
|
52
|
+
* surfaces; `gh.exe pr merge …`, `Start-Process gh -ArgumentList …` and
|
|
53
|
+
* `Remove-Item -Recurse -Force C:\` are allowed. The `.exe` spelling is
|
|
54
|
+
* allowed under `Bash` as well, so the bound belongs to the rule set and not
|
|
55
|
+
* to the widened matcher.
|
|
56
|
+
*
|
|
57
|
+
* That gap is also why `guard-bash.mjs` keeps its name. A rename would promise
|
|
58
|
+
* a parity the parser does not have, and it would break every installed rig's
|
|
59
|
+
* manifest for a word.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The tool names KNOWN to run a shell command — a hand-maintained list.
|
|
64
|
+
*
|
|
65
|
+
* - `Bash` — the POSIX surface these guards were written for.
|
|
66
|
+
* - `PowerShell` — the Windows-native surface, measured to bypass them before
|
|
67
|
+
* RP-65. A harness that does not expose it simply never matches the name.
|
|
68
|
+
*
|
|
69
|
+
* 🔴 **Nothing checks this list against the harness, and that is the direction
|
|
70
|
+
* the defect came from.** The tests derive their expectations FROM this
|
|
71
|
+
* constant, so what they guard is list → matcher → verdict. The opposite
|
|
72
|
+
* direction — harness → list — is guarded by nobody. A harness that gains a
|
|
73
|
+
* third shell tool leaves every test green and the Never tier inert on it,
|
|
74
|
+
* which is exactly what `PowerShell` did until somebody measured it by hand.
|
|
75
|
+
* Over-listing is safe (an unexposed name never matches); under-listing is
|
|
76
|
+
* the whole bug, and it is invisible until a person edits this file.
|
|
77
|
+
*/
|
|
78
|
+
export const SHELL_TOOLS = Object.freeze(['Bash', 'PowerShell']);
|
|
79
|
+
|
|
80
|
+
/** The `matcher` value `settings.json` must carry for the shell guards. */
|
|
81
|
+
export const SHELL_TOOL_MATCHER = SHELL_TOOLS.join('|');
|
|
@@ -11,19 +11,21 @@
|
|
|
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
|
|
14
|
+
// let a GO on the scripted subset read as a pass on the whole checklist.
|
|
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
|
|
18
18
|
// checklist exists to prevent.
|
|
19
19
|
import { execFileSync } from 'node:child_process';
|
|
20
20
|
import { realpathSync } from 'node:fs';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
21
22
|
import { fileURLToPath } from 'node:url';
|
|
22
23
|
// One implementation of the brake, shared with the hook that enforces it. This
|
|
23
24
|
// file used to carry its own `process.env.AGENT_LOOP_STOP || <default>`, which is
|
|
24
25
|
// the replace-not-add bug — fixed in the hook and left open here for a full review
|
|
25
26
|
// cycle, because preflight is the only scripted brake check and had no test.
|
|
26
27
|
import { brakeIsOn } from './stop-flag.mjs';
|
|
28
|
+
import { readRevalidationContract } from './lib/claim-records.mjs';
|
|
27
29
|
|
|
28
30
|
/** The items this script cannot check: judgement, or a call worth more than it saves. */
|
|
29
31
|
export const UNCHECKED = [
|
|
@@ -120,6 +122,20 @@ export const checkRunDirNotExported = (env = process.env) => {
|
|
|
120
122
|
: { ok: true, detail: 'not exported' };
|
|
121
123
|
};
|
|
122
124
|
|
|
125
|
+
export const checkDetectionContract = (projectRoot) => {
|
|
126
|
+
try {
|
|
127
|
+
const contract = readRevalidationContract(projectRoot);
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
detail:
|
|
131
|
+
`${contract.detection.mode} via ${contract.detection.sources.join(' + ')}, ` +
|
|
132
|
+
`${contract.detection.acceptedLatency} accepted latency, no push`,
|
|
133
|
+
};
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return { ok: false, detail: error.message };
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
123
139
|
/** The last deploy must have concluded successfully — never start on a broken runtime. */
|
|
124
140
|
export const checkLastDeploy = ({ workflow = 'deploy' } = {}) => {
|
|
125
141
|
try {
|
|
@@ -195,9 +211,11 @@ const invokedDirectly = () => {
|
|
|
195
211
|
};
|
|
196
212
|
|
|
197
213
|
if (invokedDirectly()) {
|
|
214
|
+
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
198
215
|
const checks = {
|
|
199
216
|
killSwitch: checkKillSwitch(),
|
|
200
217
|
runDirNotExported: checkRunDirNotExported(),
|
|
218
|
+
detectionContract: checkDetectionContract(projectRoot),
|
|
201
219
|
defaultBranchFresh: checkDefaultBranchFresh(),
|
|
202
220
|
lastDeploy: checkLastDeploy(),
|
|
203
221
|
};
|
|
@@ -677,7 +677,8 @@ export const selectNext = (
|
|
|
677
677
|
|
|
678
678
|
/**
|
|
679
679
|
* Revalidation at SELECT — is the item the run is about to take the item the
|
|
680
|
-
* last take-up saw?
|
|
680
|
+
* last take-up saw? This is compatibility EVIDENCE only: claim fingerprints
|
|
681
|
+
* are the sole authority for scope and commentary drift.
|
|
681
682
|
*
|
|
682
683
|
* The snapshot is the ticket's `updatedAt` marker as recorded at the previous
|
|
683
684
|
* take-up in THIS run (`run-state.mjs` › recordTakeUp). One string compare on
|
|
@@ -692,74 +693,13 @@ export const selectNext = (
|
|
|
692
693
|
* marker that moved: a first sight (no snapshot yet) is `false` with the baseline
|
|
693
694
|
* recorded, not a change.
|
|
694
695
|
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
* ⚠ Limit: the marker moves on the run's OWN claim and comments too. The
|
|
699
|
-
* tracker adapters re-record the take-up after each write they make (AR-140),
|
|
700
|
-
* so a move made THROUGH the adapter is not a hold — one made by any other
|
|
701
|
-
* route (a hand-posted comment, a connector) still is. This function cannot
|
|
702
|
-
* tell who moved it; the re-read can, and the `loop` skill records that
|
|
703
|
-
* conclusion as a separate `revalidation-outcome` event.
|
|
696
|
+
* It deliberately returns no `source` and no `action`. Adding either would
|
|
697
|
+
* restore the marker as a second decision engine beside `.rig/claims/`.
|
|
704
698
|
*/
|
|
705
|
-
export const
|
|
699
|
+
export const takeUpEvidenceOf = ({ ticket, snapshot = null }) => {
|
|
706
700
|
const to = typeof ticket?.updatedAt === 'string' ? ticket.updatedAt : null;
|
|
707
701
|
const from = typeof snapshot === 'string' ? snapshot : null;
|
|
708
|
-
|
|
709
|
-
// `action` the same three words BEFORE_PR and BEFORE_CLOSE use, and the two
|
|
710
|
-
// markers sit under `task` — so a reader of the evidence log needs one parser.
|
|
711
|
-
const base = { ticket: ticket?.id ?? null, point: 'SELECT', task: { from, to } };
|
|
712
|
-
if (to === null) return { ...base, changed: null, source: [], action: 'unverifiable' };
|
|
713
|
-
const changed = from !== null && from !== to;
|
|
714
|
-
return {
|
|
715
|
-
...base,
|
|
716
|
-
changed,
|
|
717
|
-
source: changed ? ['task:updatedAt'] : [],
|
|
718
|
-
action: changed ? 'hold' : 'continue',
|
|
719
|
-
};
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
/**
|
|
723
|
-
* Revalidation at BEFORE_PR — the aggregate over two sources, pure.
|
|
724
|
-
*
|
|
725
|
-
* `task` is what {@link revalidationOf} returned for the ticket against the
|
|
726
|
-
* take-up snapshot; `mainChanged` is the list of cited paths the default branch
|
|
727
|
-
* changed since the branch forked (`revalidate.mjs` computes it from git). One
|
|
728
|
-
* source name per finding — `task:updatedAt`, `main:<path>` — so a hold names
|
|
729
|
-
* exactly what moved, never "something changed".
|
|
730
|
-
*
|
|
731
|
-
* `changed` keeps the three values of the SELECT point: `true` when any source
|
|
732
|
-
* moved; `null` when nothing moved but the task could not be checked (no
|
|
733
|
-
* snapshot, no marker, no run) — a blind spot on one side is not a clean pass
|
|
734
|
-
* on both; `false` only when both sides were compared and neither moved.
|
|
735
|
-
*/
|
|
736
|
-
export const beforePrRevalidationOf = ({ ticket, task = { changed: null }, mainChanged = [] }) => {
|
|
737
|
-
const source = [
|
|
738
|
-
...(task?.changed === true ? ['task:updatedAt'] : []),
|
|
739
|
-
...mainChanged.map((path) => `main:${path}`),
|
|
740
|
-
];
|
|
741
|
-
const changed = source.length > 0 ? true : task?.changed === null ? null : false;
|
|
742
|
-
const action = changed === true ? 'hold' : changed === null ? 'unverifiable' : 'continue';
|
|
743
|
-
return { ticket, point: 'BEFORE_PR', changed, source, action };
|
|
744
|
-
};
|
|
745
|
-
|
|
746
|
-
/**
|
|
747
|
-
* Revalidation at BEFORE_CLOSE — the aggregate over the item's marker and its
|
|
748
|
-
* state, pure. `task` is what {@link revalidationOf} returned against the last
|
|
749
|
-
* validation; `state` is the item's neutral state now. At close the item is
|
|
750
|
-
* expected `in-progress`: `closed` means someone else published it, `open`
|
|
751
|
-
* means someone moved it back, and either is a change the close must not
|
|
752
|
-
* paper over. Same three-valued `changed` and the same actions as BEFORE_PR;
|
|
753
|
-
* `task:updatedAt` is named before `task:state`.
|
|
754
|
-
*/
|
|
755
|
-
export const beforeCloseRevalidationOf = ({ ticket, task = { changed: null }, state = null }) => {
|
|
756
|
-
const source = [
|
|
757
|
-
...(task?.changed === true ? ['task:updatedAt'] : []),
|
|
758
|
-
...(state !== 'in-progress' ? ['task:state'] : []),
|
|
759
|
-
];
|
|
760
|
-
const changed = source.length > 0 ? true : task?.changed === null ? null : false;
|
|
761
|
-
const action = changed === true ? 'hold' : changed === null ? 'unverifiable' : 'continue';
|
|
762
|
-
return { ticket, point: 'BEFORE_CLOSE', changed, source, action };
|
|
702
|
+
return { changed: to === null ? null : from !== null && from !== to, task: { from, to } };
|
|
763
703
|
};
|
|
764
704
|
|
|
765
705
|
/**
|
|
@@ -933,6 +873,7 @@ export const stopConditionOf = ({
|
|
|
933
873
|
lastDeployVerdict = null,
|
|
934
874
|
consecutiveEscalations = 0,
|
|
935
875
|
killSwitch = false,
|
|
876
|
+
revalidationHold = null,
|
|
936
877
|
budgetExhausted = false,
|
|
937
878
|
queueReadable = true,
|
|
938
879
|
} = {}) => {
|
|
@@ -966,6 +907,16 @@ export const stopConditionOf = ({
|
|
|
966
907
|
'in-flight work is not what stopping cleanly means.',
|
|
967
908
|
};
|
|
968
909
|
}
|
|
910
|
+
if (revalidationHold) {
|
|
911
|
+
return {
|
|
912
|
+
kind: 'revalidation-hold',
|
|
913
|
+
success: false,
|
|
914
|
+
why:
|
|
915
|
+
`${revalidationHold.ticket} stopped at ${revalidationHold.checkpoint}: ` +
|
|
916
|
+
`${revalidationHold.result} (${revalidationHold.detectionId}). Resolve that ` +
|
|
917
|
+
'detection before selecting more work in this run.',
|
|
918
|
+
};
|
|
919
|
+
}
|
|
969
920
|
if (consecutiveEscalations >= 2) {
|
|
970
921
|
return {
|
|
971
922
|
kind: 'repeated-escalation',
|
|
@@ -17,8 +17,10 @@ import { execFileSync } from 'node:child_process';
|
|
|
17
17
|
import { duplicateOf, fingerprintOf, lifecycleOf, ownerOfLabels, validateProposal } from './core.mjs';
|
|
18
18
|
import { withAsOf } from './as-of.mjs';
|
|
19
19
|
import { recordEscalation, recordTakeUp } from '../run-state.mjs';
|
|
20
|
+
import { recordClaimTransition } from '../lib/claim-records.mjs';
|
|
20
21
|
|
|
21
22
|
export const name = 'github-issues';
|
|
23
|
+
export const claimedState = 'in-progress';
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* A dependency line, and everything after the keyword on it.
|
|
@@ -56,6 +58,7 @@ export const blockerIdsOf = (issue) => {
|
|
|
56
58
|
export const toTicket = (issue, states = {}) => {
|
|
57
59
|
const labels = labelNames(issue);
|
|
58
60
|
const priorityLabel = labels.map((label) => PRIORITY.exec(label)).find(Boolean);
|
|
61
|
+
const comments = Array.isArray(issue.comments) ? issue.comments : [];
|
|
59
62
|
|
|
60
63
|
return {
|
|
61
64
|
id: String(issue.number),
|
|
@@ -76,10 +79,7 @@ export const toTicket = (issue, states = {}) => {
|
|
|
76
79
|
blocks: [],
|
|
77
80
|
priority: priorityLabel ? Number(priorityLabel[1]) : 999,
|
|
78
81
|
createdAt: issue.createdAt ?? null,
|
|
79
|
-
//
|
|
80
|
-
// last-modified field, whose contract (moves on edits, comments and state
|
|
81
|
-
// changes) is assumed and not checked here. `null` when the listing did not
|
|
82
|
-
// carry it.
|
|
82
|
+
// Compatibility evidence only; `.rig/claims/` fingerprints decide drift.
|
|
83
83
|
updatedAt: issue.updatedAt ?? null,
|
|
84
84
|
// The body travels on the neutral shape so the hygiene checks live in one
|
|
85
85
|
// place (core.mjs) instead of once per adapter. This adapter also parses it
|
|
@@ -87,6 +87,18 @@ export const toTicket = (issue, states = {}) => {
|
|
|
87
87
|
// purpose: that is exactly the disagreement `body-claims-unlinked-blocker`
|
|
88
88
|
// exists to surface.
|
|
89
89
|
body: typeof issue.body === 'string' ? issue.body : null,
|
|
90
|
+
commentary: {
|
|
91
|
+
count: comments.length,
|
|
92
|
+
ids: comments
|
|
93
|
+
.map((comment) => comment?.id)
|
|
94
|
+
.filter((id) => id !== undefined && id !== null)
|
|
95
|
+
.map(String),
|
|
96
|
+
// GitHub CLI expands `comments` to `comments(first: 100)` but exposes no
|
|
97
|
+
// total or pageInfo beside the resulting array. Fewer than 100 proves the
|
|
98
|
+
// first page was also the last; exactly 100 cannot prove there is no 101st
|
|
99
|
+
// comment and must fail closed rather than fingerprint a partial thread.
|
|
100
|
+
complete: Array.isArray(issue.comments) && comments.length < 100,
|
|
101
|
+
},
|
|
90
102
|
triage: labels.includes('triage'),
|
|
91
103
|
trigger: labels.includes('trigger-auto')
|
|
92
104
|
? 'auto'
|
|
@@ -130,7 +142,7 @@ const ghText = (args) =>
|
|
|
130
142
|
|
|
131
143
|
const ghJson = (args) => JSON.parse(ghText(args));
|
|
132
144
|
|
|
133
|
-
const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt';
|
|
145
|
+
const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt,comments';
|
|
134
146
|
|
|
135
147
|
// --- the adapter contract ------------------------------------------------------
|
|
136
148
|
|
|
@@ -176,10 +188,20 @@ const rebaseline = (ticket) => {
|
|
|
176
188
|
}
|
|
177
189
|
};
|
|
178
190
|
|
|
179
|
-
export const claim = (ticket) => {
|
|
191
|
+
export const claim = (ticket, { projectRoot = process.cwd() } = {}) => {
|
|
180
192
|
ghText(['issue', 'edit', ticket.id, '--add-label', 'in-progress']);
|
|
193
|
+
let workflowClaimRecorded = false;
|
|
194
|
+
try {
|
|
195
|
+
workflowClaimRecorded =
|
|
196
|
+
recordClaimTransition({ projectRoot, ticket, claimedState }) !== null;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
process.stderr.write(
|
|
199
|
+
`#${ticket.id}: the workflow claim landed, but its durable acknowledgement was NOT recorded — ` +
|
|
200
|
+
`${error.message}\n`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
181
203
|
rebaseline(ticket);
|
|
182
|
-
return { ok: true };
|
|
204
|
+
return { ok: true, workflowClaimRecorded };
|
|
183
205
|
};
|
|
184
206
|
|
|
185
207
|
/**
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
citedPathsOf,
|
|
21
21
|
hygieneOf,
|
|
22
22
|
overtakenOf,
|
|
23
|
-
revalidationOf,
|
|
24
23
|
selectNext,
|
|
25
24
|
stopConditionOf,
|
|
26
25
|
} from './core.mjs';
|
|
@@ -30,6 +29,7 @@ import { changedSinceOf, headShaOf } from './as-of.mjs';
|
|
|
30
29
|
// apart from `state.mjs` so the read path does not drag the tier computation —
|
|
31
30
|
// and `detect-missed-gate.mjs` behind it — into a CLI that never calls either.
|
|
32
31
|
import { checkoutIsShippable, mainCheckoutRoot } from './checkout.mjs';
|
|
32
|
+
import { revalidateClaim, targetShaOf } from '../lib/claim-records.mjs';
|
|
33
33
|
|
|
34
34
|
const ADAPTERS = {
|
|
35
35
|
'plan-md': './plan-md.mjs',
|
|
@@ -317,12 +317,16 @@ const renderNext = (result, stop, revalidation = null) => {
|
|
|
317
317
|
return `queue: ${label}${stop.success ? '' : ' (needs attention)'}\n ${stop.why}\n`;
|
|
318
318
|
}
|
|
319
319
|
const lines = [`next: ${result.ticket.id} — ${result.ticket.title} [${result.ticket.tier}]`];
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
if (revalidation?.
|
|
320
|
+
// CURRENT and BASELINE_CREATED stay quiet. Every blocking claim result is
|
|
321
|
+
// visible in text mode too; an exit code with no reason is not operable.
|
|
322
|
+
if (revalidation?.action === 'hold' || revalidation?.action === 'unverifiable') {
|
|
323
|
+
const detail =
|
|
324
|
+
revalidation.source.length > 0
|
|
325
|
+
? revalidation.source.join(', ')
|
|
326
|
+
: (revalidation.evidence?.error ?? revalidation.sourcePointer);
|
|
323
327
|
lines.push(
|
|
324
|
-
`revalidate: ${revalidation.ticket}
|
|
325
|
-
|
|
328
|
+
`revalidate: ${revalidation.ticket} ${revalidation.action} — ${detail} — ` +
|
|
329
|
+
're-read the item before acting',
|
|
326
330
|
);
|
|
327
331
|
}
|
|
328
332
|
if (result.skipped.length > 0) {
|
|
@@ -530,12 +534,21 @@ if (invokedDirectly()) {
|
|
|
530
534
|
// pattern it matched on. The load lives here; the READ stays behind the
|
|
531
535
|
// declaration, because a session with no run directory has no state to read
|
|
532
536
|
// and must keep working exactly as before.
|
|
533
|
-
let
|
|
537
|
+
let readStateForSelection;
|
|
534
538
|
let stopInputsOf;
|
|
535
539
|
let recordTakeUp;
|
|
540
|
+
let recordRevalidationHold;
|
|
536
541
|
let previousTakeUp;
|
|
542
|
+
let previousRunEvidence;
|
|
537
543
|
try {
|
|
538
|
-
({
|
|
544
|
+
({
|
|
545
|
+
readStateForSelection,
|
|
546
|
+
stopInputsOf,
|
|
547
|
+
recordTakeUp,
|
|
548
|
+
recordRevalidationHold,
|
|
549
|
+
previousTakeUp,
|
|
550
|
+
previousRunEvidence,
|
|
551
|
+
} = await import('../run-state.mjs'));
|
|
539
552
|
} catch (error) {
|
|
540
553
|
process.stderr.write(
|
|
541
554
|
`run state: ${error.message}\n` +
|
|
@@ -586,7 +599,46 @@ if (invokedDirectly()) {
|
|
|
586
599
|
// `invariants.md` forbids. Selection therefore does NOT stop on the brake, and
|
|
587
600
|
// the `loop` skill tells the run to keep checking it between tasks.
|
|
588
601
|
//
|
|
589
|
-
|
|
602
|
+
let runState;
|
|
603
|
+
try {
|
|
604
|
+
runState = process.env.RIG_RUN_DIR
|
|
605
|
+
? readStateForSelection(process.env.RIG_RUN_DIR)
|
|
606
|
+
: {};
|
|
607
|
+
} catch (error) {
|
|
608
|
+
const stop = {
|
|
609
|
+
kind: 'run-state-unreadable',
|
|
610
|
+
success: false,
|
|
611
|
+
why: `${error.message}; the run may contain a stop condition, so selection is refused.`,
|
|
612
|
+
};
|
|
613
|
+
process.stdout.write(
|
|
614
|
+
args.json
|
|
615
|
+
? `${JSON.stringify({ stop, revalidation: null }, null, 2)}\n`
|
|
616
|
+
: `queue: ${stop.kind}\n ${stop.why}\n`,
|
|
617
|
+
);
|
|
618
|
+
process.exit(1);
|
|
619
|
+
}
|
|
620
|
+
// `state.json` is the fast stop input; the append-only journal is the durable
|
|
621
|
+
// run evidence that can reconstruct it after an absent or valid-but-empty
|
|
622
|
+
// state file. Reuse the shared temporal resolution rule rather than growing
|
|
623
|
+
// a second definition of "unresolved" in selection. A broken journal keeps
|
|
624
|
+
// its established lost-trace behaviour; a readable unresolved detection can
|
|
625
|
+
// never be erased merely by deleting the derived state cache.
|
|
626
|
+
if (process.env.RIG_RUN_DIR && !runState.revalidationHold) {
|
|
627
|
+
try {
|
|
628
|
+
const [{ readRun }, { unresolvedBlockingDetectionOf }] = await Promise.all([
|
|
629
|
+
import('../run-journal.mjs'),
|
|
630
|
+
import('../lib/revalidation-evidence.mjs'),
|
|
631
|
+
]);
|
|
632
|
+
const recovered = unresolvedBlockingDetectionOf(
|
|
633
|
+
readRun({ runDir: process.env.RIG_RUN_DIR }).events,
|
|
634
|
+
);
|
|
635
|
+
if (recovered) runState = { ...runState, revalidationHold: recovered };
|
|
636
|
+
} catch {
|
|
637
|
+
// The journal's read/write failure contract is handled at its existing
|
|
638
|
+
// call site below. This reconciliation only restores evidence it can
|
|
639
|
+
// validate; it never guesses a detection from unreadable bytes.
|
|
640
|
+
}
|
|
641
|
+
}
|
|
590
642
|
let stopInputs;
|
|
591
643
|
try {
|
|
592
644
|
// Read through the module that owns the vocabulary, never field by field
|
|
@@ -726,27 +778,107 @@ if (invokedDirectly()) {
|
|
|
726
778
|
// a default derived from `projectRoot` would land the trace inside the very
|
|
727
779
|
// template tree this repository publishes.
|
|
728
780
|
const runDir = process.env.RIG_RUN_DIR;
|
|
729
|
-
// Revalidation at SELECT
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
781
|
+
// Revalidation at SELECT: the selected item against its durable claim
|
|
782
|
+
// fingerprints. It runs with or without a declared run; only the evidence
|
|
783
|
+
// log is run-scoped. Otherwise an attended first SELECT would bypass the
|
|
784
|
+
// cross-harness baseline the next unattended resume requires.
|
|
733
785
|
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
// which it was (`this-run` | `previous-run` | null), and `baselineRun` names
|
|
738
|
-
// the earlier run, so the report can tell the three apart. A rig whose
|
|
739
|
-
// run-state module predates `previousTakeUp` keeps the per-run behaviour.
|
|
786
|
+
// Take-up markers remain attached as compatibility evidence. `baseline`
|
|
787
|
+
// says where that evidence came from; neither it nor `updatedAt` contributes
|
|
788
|
+
// to the claim result or decides whether the first claim may be created.
|
|
740
789
|
let revalidation = null;
|
|
741
|
-
|
|
742
|
-
|
|
790
|
+
const configuredProjectRoot = projectRootOfConfig(configPath);
|
|
791
|
+
const claimRoot = configuredProjectRoot ?? projectRoot;
|
|
792
|
+
if (result.ticket && configuredProjectRoot !== null) {
|
|
793
|
+
const own = runDir ? runState.takeUps?.[result.ticket.id] : undefined;
|
|
743
794
|
const prior =
|
|
744
|
-
own === undefined && typeof previousTakeUp === 'function'
|
|
795
|
+
runDir && own === undefined && typeof previousTakeUp === 'function'
|
|
745
796
|
? previousTakeUp(runDir, result.ticket.id)
|
|
746
797
|
: null;
|
|
747
798
|
const snapshot = own ?? prior?.updatedAt ?? null;
|
|
799
|
+
// SELECT events are the sole authority for whether this is first sight or
|
|
800
|
+
// a resume. A take-up may still supply the compatibility snapshot above,
|
|
801
|
+
// but it never enters this decision. Every sibling journal comes from the
|
|
802
|
+
// same bounded resolver as the marker lookup so the evidence windows cannot
|
|
803
|
+
// drift apart.
|
|
804
|
+
let selectedBefore = false;
|
|
805
|
+
let resumeEvidenceError = null;
|
|
806
|
+
if (runDir) {
|
|
807
|
+
let journal = null;
|
|
808
|
+
try {
|
|
809
|
+
journal = await import('../run-journal.mjs');
|
|
810
|
+
} catch {
|
|
811
|
+
selectedBefore = true;
|
|
812
|
+
resumeEvidenceError = 'current run journal module is unreadable or missing';
|
|
813
|
+
}
|
|
814
|
+
const selectedIn = (candidateRunDir) =>
|
|
815
|
+
journal.readRun({ runDir: candidateRunDir }).events.some(
|
|
816
|
+
(event) =>
|
|
817
|
+
event.kind === 'revalidation' &&
|
|
818
|
+
event.data?.point === 'SELECT' &&
|
|
819
|
+
String(event.data?.ticket) === String(result.ticket.id) &&
|
|
820
|
+
typeof event.data?.result === 'string' &&
|
|
821
|
+
typeof event.data?.sourcePointer === 'string',
|
|
822
|
+
);
|
|
823
|
+
if (journal) {
|
|
824
|
+
try {
|
|
825
|
+
selectedBefore = selectedIn(runDir);
|
|
826
|
+
} catch {
|
|
827
|
+
selectedBefore = true;
|
|
828
|
+
resumeEvidenceError = 'current run journal is unreadable or invalid';
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (journal && !selectedBefore) {
|
|
832
|
+
let candidates = [];
|
|
833
|
+
let complete;
|
|
834
|
+
try {
|
|
835
|
+
const previous = previousRunEvidence(runDir);
|
|
836
|
+
candidates = previous.runDirs;
|
|
837
|
+
complete = previous.complete;
|
|
838
|
+
} catch {
|
|
839
|
+
complete = false;
|
|
840
|
+
}
|
|
841
|
+
let unreadablePrior = false;
|
|
842
|
+
for (const candidateRunDir of candidates) {
|
|
843
|
+
try {
|
|
844
|
+
if (selectedIn(candidateRunDir)) {
|
|
845
|
+
selectedBefore = true;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
} catch {
|
|
849
|
+
unreadablePrior = true;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (!selectedBefore && (!complete || unreadablePrior)) {
|
|
853
|
+
selectedBefore = true;
|
|
854
|
+
resumeEvidenceError = !complete
|
|
855
|
+
? 'prior run journal search is incomplete or truncated by its safety limit'
|
|
856
|
+
: 'prior run journal is unreadable or invalid';
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
let claim = revalidateClaim({
|
|
861
|
+
projectRoot: claimRoot,
|
|
862
|
+
ticket: result.ticket,
|
|
863
|
+
point: 'SELECT',
|
|
864
|
+
claimedState: adapter.claimedState,
|
|
865
|
+
targetSha: targetShaOf(claimRoot),
|
|
866
|
+
allowCreate: true,
|
|
867
|
+
isResume: selectedBefore,
|
|
868
|
+
});
|
|
869
|
+
if (resumeEvidenceError && claim.result === 'UNVERIFIABLE') {
|
|
870
|
+
claim = {
|
|
871
|
+
...claim,
|
|
872
|
+
evidence: {
|
|
873
|
+
...claim.evidence,
|
|
874
|
+
error: `${resumeEvidenceError}; ${claim.evidence?.error ?? 'claim cannot be verified'}`,
|
|
875
|
+
},
|
|
876
|
+
};
|
|
877
|
+
}
|
|
748
878
|
revalidation = {
|
|
749
|
-
...
|
|
879
|
+
...claim,
|
|
880
|
+
observedAt: new Date().toISOString(),
|
|
881
|
+
task: { from: snapshot, to: result.ticket.updatedAt ?? null },
|
|
750
882
|
baseline: own !== undefined ? 'this-run' : prior ? 'previous-run' : null,
|
|
751
883
|
...(prior ? { baselineRun: prior.runDir } : {}),
|
|
752
884
|
};
|
|
@@ -821,6 +953,9 @@ if (invokedDirectly()) {
|
|
|
821
953
|
}
|
|
822
954
|
|
|
823
955
|
if (revalidation) {
|
|
956
|
+
if (revalidation.action === 'hold' || revalidation.action === 'unverifiable') {
|
|
957
|
+
recordRevalidationHold(runDir, revalidation);
|
|
958
|
+
}
|
|
824
959
|
// Its own try, after the journal's: a state file that cannot be written is
|
|
825
960
|
// not the journal failing, and the selection stands either way — the
|
|
826
961
|
// comparison was made and recorded; only the next baseline is lost.
|
|
@@ -840,4 +975,5 @@ if (invokedDirectly()) {
|
|
|
840
975
|
? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop, revalidation }, null, 2)}\n`
|
|
841
976
|
: renderNext(result, stop, revalidation),
|
|
842
977
|
);
|
|
978
|
+
process.exit(revalidation?.action === 'hold' || revalidation?.action === 'unverifiable' ? 2 : 0);
|
|
843
979
|
}
|