@iceinvein/agent-skills 0.2.0 → 0.4.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 +2 -2
- package/dist/cli/index.js +14 -10
- package/package.json +1 -1
- package/skills/index.json +4 -4
- package/skills/migrate/README.md +35 -23
- package/skills/migrate/SKILL.md +75 -15
- package/skills/migrate/bin/migrate.ts +90 -0
- package/skills/migrate/docs/architecture.md +61 -26
- package/skills/migrate/docs/reference.md +53 -8
- package/skills/migrate/fixtures/fake-gh.ts +113 -0
- package/skills/migrate/fixtures/flow-target/docs/WORK.md +12 -0
- package/skills/migrate/fixtures/flow-target/docs/modernisation/capability-map/.gitkeep +0 -0
- package/skills/migrate/fixtures/flow-target/tools/flow/src/cli.ts +156 -0
- package/skills/migrate/package.json +1 -1
- package/skills/migrate/references/phases/adjudicate.md +161 -0
- package/skills/migrate/references/phases/handoff.md +220 -0
- package/skills/migrate/references/phases/probe.md +2 -2
- package/skills/migrate/references/phases/queue.md +21 -14
- package/skills/migrate/references/run-ops.md +17 -13
- package/skills/migrate/scripts/__tests__/adapter-flow.test.ts +290 -0
- package/skills/migrate/scripts/__tests__/adapter-github.test.ts +232 -0
- package/skills/migrate/scripts/__tests__/adapter-markdown.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/adjudicate.test.ts +332 -0
- package/skills/migrate/scripts/__tests__/assumptions.test.ts +179 -0
- package/skills/migrate/scripts/__tests__/coverage.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +167 -7
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +9 -4
- package/skills/migrate/scripts/__tests__/forecast.test.ts +280 -0
- package/skills/migrate/scripts/__tests__/gates-handoff.test.ts +309 -0
- package/skills/migrate/scripts/__tests__/handoff-cmd.test.ts +308 -0
- package/skills/migrate/scripts/__tests__/handoff-order.test.ts +156 -0
- package/skills/migrate/scripts/adapters/flow.ts +280 -0
- package/skills/migrate/scripts/adapters/github.ts +260 -0
- package/skills/migrate/scripts/adapters/markdown.ts +175 -0
- package/skills/migrate/scripts/adjudicate-cmd.ts +243 -0
- package/skills/migrate/scripts/assumptions.ts +188 -0
- package/skills/migrate/scripts/check.ts +119 -320
- package/skills/migrate/scripts/coverage-cmd.ts +86 -0
- package/skills/migrate/scripts/coverage.ts +151 -0
- package/skills/migrate/scripts/dates.ts +17 -0
- package/skills/migrate/scripts/forecast-cmd.ts +124 -0
- package/skills/migrate/scripts/forecast.ts +264 -0
- package/skills/migrate/scripts/gates/adjudication.ts +30 -0
- package/skills/migrate/scripts/gates/census.ts +107 -0
- package/skills/migrate/scripts/gates/citations.ts +11 -0
- package/skills/migrate/scripts/gates/context.ts +76 -0
- package/skills/migrate/scripts/gates/coverage.ts +22 -0
- package/skills/migrate/scripts/gates/deltas.ts +15 -0
- package/skills/migrate/scripts/gates/handoff.ts +145 -0
- package/skills/migrate/scripts/gates/leaks.ts +11 -0
- package/skills/migrate/scripts/gates/parity.ts +15 -0
- package/skills/migrate/scripts/gates/queue.ts +9 -0
- package/skills/migrate/scripts/gates/refs.ts +97 -0
- package/skills/migrate/scripts/gates/run-state.ts +67 -0
- package/skills/migrate/scripts/gates/source.ts +28 -0
- package/skills/migrate/scripts/handoff-cmd.ts +186 -0
- package/skills/migrate/scripts/handoff.ts +330 -0
- package/skills/migrate/scripts/paths.ts +4 -0
- package/skills/migrate/scripts/types.ts +43 -0
- package/skills/migrate/scripts/validate.ts +12 -0
- package/skills/migrate/skill.json +2 -2
- package/skills/migrate/templates/forecast-assumptions.md +59 -0
- package/skills/sluice/SKILL.md +20 -7
- package/skills/sluice/references/deep-channel.md +20 -0
- package/skills/sluice/references/finish.md +4 -2
- package/skills/sluice/references/meter.md +38 -0
- package/skills/sluice/scripts/run-stats.sh +236 -0
- package/skills/sluice/skill.json +4 -3
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { balanceOf, boundsOf, censusKey } from '../census.ts'
|
|
2
|
+
import type { Census, Violation } from '../types.ts'
|
|
3
|
+
import { isRecord } from '../validate.ts'
|
|
4
|
+
import type { Gate } from './context.ts'
|
|
5
|
+
|
|
6
|
+
// A hand-edited census.jsonl never passes through census-cmd.ts's
|
|
7
|
+
// validateCensus call, so this is the only label available for a row that
|
|
8
|
+
// fails the check below: its 1-based position in the file, plus (when kind
|
|
9
|
+
// and the field censusKey needs both parse as strings) the same key
|
|
10
|
+
// census-cmd.ts uses to identify a record, so the message points at
|
|
11
|
+
// something the reader can find rather than an opaque line number alone.
|
|
12
|
+
function censusRowLabel(raw: unknown, line: number): string {
|
|
13
|
+
const base = `census.jsonl line ${line}`
|
|
14
|
+
if (!isRecord(raw)) return base
|
|
15
|
+
const kind = raw.kind
|
|
16
|
+
if (kind === 'lens' && typeof raw.surface === 'string')
|
|
17
|
+
return `${base} (${censusKey(raw as Census)})`
|
|
18
|
+
if (kind === 'attribute' && typeof raw.subject === 'string')
|
|
19
|
+
return `${base} (${censusKey(raw as Census)})`
|
|
20
|
+
if (kind === 'rule-sweep' && typeof raw.subject === 'string')
|
|
21
|
+
return `${base} (${censusKey(raw as Census)})`
|
|
22
|
+
if (kind === 'closer' && typeof raw.closer === 'string')
|
|
23
|
+
return `${base} (${censusKey(raw as Census)})`
|
|
24
|
+
return base
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Gate 2: census shape, balance and presence. Rows are validated in runCheck,
|
|
28
|
+
// not here, because gate 10 needs the same partition; this gate reports the
|
|
29
|
+
// shape failures and does the arithmetic. A row that failed validation is
|
|
30
|
+
// excluded from balanceOf, boundsOf, and the in_ledger + added reconciliation:
|
|
31
|
+
// all three assume a well-formed record, so running them on one that already
|
|
32
|
+
// failed shape validation would add confusing noise on top of the real defect
|
|
33
|
+
// rather than a second independent fact.
|
|
34
|
+
export const gate: Gate = (ctx): Violation[] => {
|
|
35
|
+
const violations: Violation[] = []
|
|
36
|
+
const surfacesWithCensus = new Set<string>()
|
|
37
|
+
const closersWithCensus = new Set<string>()
|
|
38
|
+
|
|
39
|
+
for (const row of ctx.censusRows) {
|
|
40
|
+
if (!row.ok) {
|
|
41
|
+
const label = censusRowLabel(row.raw, row.line)
|
|
42
|
+
for (const error of row.errors) {
|
|
43
|
+
violations.push({ gate: 'census', message: `${label}: ${error}` })
|
|
44
|
+
}
|
|
45
|
+
// A row that fails validation still ran and still named a surface or
|
|
46
|
+
// closer it claims to cover; only its shape is defective, not its
|
|
47
|
+
// existence. Registered defensively here (guarded the same way
|
|
48
|
+
// censusRowLabel is, since the row is not a trustworthy Census) so
|
|
49
|
+
// gate 2 does not also claim that surface or closer has no census
|
|
50
|
+
// record at all, which is a different and wrong accusation: that
|
|
51
|
+
// message means the lens never ran or never closed, not that it ran
|
|
52
|
+
// and produced something malformed. A row whose kind or identity
|
|
53
|
+
// field does not even parse as a string cannot make this claim, so it
|
|
54
|
+
// falls through to the genuinely-missing check below unregistered.
|
|
55
|
+
if (isRecord(row.raw)) {
|
|
56
|
+
if (row.raw.kind === 'lens' && typeof row.raw.surface === 'string') {
|
|
57
|
+
surfacesWithCensus.add(row.raw.surface)
|
|
58
|
+
} else if (row.raw.kind === 'closer' && typeof row.raw.closer === 'string') {
|
|
59
|
+
closersWithCensus.add(row.raw.closer)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
const record = row.record
|
|
65
|
+
const imbalance = balanceOf(record)
|
|
66
|
+
if (imbalance) violations.push({ gate: 'census', message: imbalance })
|
|
67
|
+
const outOfBounds = boundsOf(record)
|
|
68
|
+
if (outOfBounds) violations.push({ gate: 'census', message: outOfBounds })
|
|
69
|
+
if (record.kind === 'lens') {
|
|
70
|
+
surfacesWithCensus.add(record.surface)
|
|
71
|
+
// balanceOf only checks that the record's own numbers add up
|
|
72
|
+
// internally; nothing before this ties in_ledger + added to anything
|
|
73
|
+
// outside the record itself, so a lens census can balance perfectly
|
|
74
|
+
// while claiming a headcount elements.jsonl never received (total is
|
|
75
|
+
// self-reported and cannot be checked against anything, but in_ledger
|
|
76
|
+
// + added claims a specific number of rows now exist in the ledger
|
|
77
|
+
// for this surface, and that claim is directly countable).
|
|
78
|
+
const claimed = record.in_ledger + record.added
|
|
79
|
+
const actual = ctx.elements.filter((e) => e.surface === record.surface).length
|
|
80
|
+
if (actual !== claimed) {
|
|
81
|
+
violations.push({
|
|
82
|
+
gate: 'census',
|
|
83
|
+
message: `lens census for ${record.surface} claims in_ledger ${record.in_ledger} + added ${record.added} = ${claimed} element(s) in the ledger, but elements.jsonl has ${actual}`,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (record.kind === 'closer') closersWithCensus.add(record.closer)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const surface of ctx.cfg.surfaces) {
|
|
91
|
+
if (!surfacesWithCensus.has(surface)) {
|
|
92
|
+
violations.push({
|
|
93
|
+
gate: 'census',
|
|
94
|
+
message: `declared surface ${surface} has no lens census record; the lens did not run or did not close`,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const closer of ctx.cfg.closers) {
|
|
99
|
+
if (!closersWithCensus.has(closer)) {
|
|
100
|
+
violations.push({
|
|
101
|
+
gate: 'census',
|
|
102
|
+
message: `declared closer ${closer} has no census record`,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return violations
|
|
107
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resolveCitations } from '../citations.ts'
|
|
2
|
+
import type { Violation } from '../types.ts'
|
|
3
|
+
import type { Gate } from './context.ts'
|
|
4
|
+
|
|
5
|
+
// Gate 7: citations. On unless explicitly disabled. An FR citing a path that
|
|
6
|
+
// does not exist is the never-fabricate rule's only mechanical expression, so
|
|
7
|
+
// it should not be something a run has to remember to ask for.
|
|
8
|
+
export const gate: Gate = async (ctx): Promise<Violation[]> => {
|
|
9
|
+
if (!ctx.citations) return []
|
|
10
|
+
return resolveCitations(ctx.requirements, ctx.cfg.source.path)
|
|
11
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Config } from '../config.ts'
|
|
2
|
+
import type { LoadedHandoff } from '../handoff.ts'
|
|
3
|
+
import type { StorePaths } from '../paths.ts'
|
|
4
|
+
import type { Phase, PhaseState } from '../phases.ts'
|
|
5
|
+
import type {
|
|
6
|
+
Capability,
|
|
7
|
+
Census,
|
|
8
|
+
Delta,
|
|
9
|
+
Element,
|
|
10
|
+
QueueItem,
|
|
11
|
+
Requirement,
|
|
12
|
+
Violation,
|
|
13
|
+
} from '../types.ts'
|
|
14
|
+
|
|
15
|
+
// One census.jsonl row, validated, in file order. A failed row keeps its raw
|
|
16
|
+
// value and 1-based line: the line is the only label a hand-edited row has,
|
|
17
|
+
// and the raw value still names the surface or closer the row claims to
|
|
18
|
+
// cover, which gate 2 registers so it does not also report that surface as
|
|
19
|
+
// having no census record at all.
|
|
20
|
+
//
|
|
21
|
+
// Kept as one ordered list rather than split into valid and invalid halves,
|
|
22
|
+
// because gate 2 reports both kinds and its message order follows the file.
|
|
23
|
+
// Splitting them would group every shape error ahead of every arithmetic
|
|
24
|
+
// error, which reorders the output for any store holding both.
|
|
25
|
+
export type CensusRow =
|
|
26
|
+
| { ok: true; line: number; record: Census }
|
|
27
|
+
| { ok: false; line: number; raw: unknown; errors: string[] }
|
|
28
|
+
|
|
29
|
+
export function validCensus(rows: CensusRow[]): Census[] {
|
|
30
|
+
const out: Census[] = []
|
|
31
|
+
for (const row of rows) {
|
|
32
|
+
if (row.ok) out.push(row.record)
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Everything the gates read, loaded once by runCheck and handed to each gate
|
|
38
|
+
// unchanged. A gate is a pure function of this record: it may not read the
|
|
39
|
+
// filesystem for store content, so the store is parsed exactly once per run
|
|
40
|
+
// no matter how many gates consult it.
|
|
41
|
+
//
|
|
42
|
+
// `censusRows` is the one field this record does work for rather than merely
|
|
43
|
+
// carrying. validateCensus runs once, in runCheck, because two gates need the
|
|
44
|
+
// result and neither should re-derive it: gate 2 reports the shape failures
|
|
45
|
+
// and does the balance arithmetic, and gate 10 cross-checks the valid records'
|
|
46
|
+
// batch fields against phases.json. Before the split those two shared a local
|
|
47
|
+
// variable inside one function; this is what replaces that sharing without
|
|
48
|
+
// making one gate depend on another having run first.
|
|
49
|
+
export type GateContext = {
|
|
50
|
+
root: string
|
|
51
|
+
cfg: Config
|
|
52
|
+
paths: StorePaths
|
|
53
|
+
elements: Element[]
|
|
54
|
+
requirements: Requirement[]
|
|
55
|
+
capabilities: Capability[]
|
|
56
|
+
deltas: Delta[]
|
|
57
|
+
censusRows: CensusRow[]
|
|
58
|
+
// Loaded lazily, immediately before the handoff gate runs, because it is the
|
|
59
|
+
// only gate that reads it and a corrupt file must not break the others.
|
|
60
|
+
// `undefined` means "not loaded yet"; every other state is a LoadedHandoff.
|
|
61
|
+
handoff: LoadedHandoff | undefined
|
|
62
|
+
queueItems: QueueItem[]
|
|
63
|
+
queueErrors: string[]
|
|
64
|
+
phases: Record<Phase, PhaseState>
|
|
65
|
+
// The index in PHASES of the last phase this invocation gates. Only the
|
|
66
|
+
// run-state gate reads it today; every other gate reads the whole store
|
|
67
|
+
// regardless, because a coverage or census gap is a real failure whenever it
|
|
68
|
+
// is found rather than only once the run claims to have reached that phase.
|
|
69
|
+
// An index rather than a name, because both consumers compare positions.
|
|
70
|
+
terminusIndex: number
|
|
71
|
+
citations: boolean
|
|
72
|
+
leaks: boolean
|
|
73
|
+
gitBin: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type Gate = (ctx: GateContext) => Violation[] | Promise<Violation[]>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Violation } from '../types.ts'
|
|
2
|
+
import type { Gate } from './context.ts'
|
|
3
|
+
|
|
4
|
+
// Gate 1: coverage. Every element must carry a terminal disposition.
|
|
5
|
+
//
|
|
6
|
+
// The summary line runCheck returns ("612/612 mapped, ...") counts the same
|
|
7
|
+
// three dispositions this gate walks, but it is built in check.ts rather than
|
|
8
|
+
// here, because report-cmd.ts reads it out of CheckResult on a store that is
|
|
9
|
+
// expected to have violations. A gate returns violations; the summary is a
|
|
10
|
+
// property of the run.
|
|
11
|
+
export const gate: Gate = (ctx): Violation[] => {
|
|
12
|
+
const violations: Violation[] = []
|
|
13
|
+
for (const el of ctx.elements) {
|
|
14
|
+
if (el.disposition.kind === 'mapped') continue
|
|
15
|
+
if (el.disposition.kind === 'out-of-scope') continue
|
|
16
|
+
violations.push({
|
|
17
|
+
gate: 'coverage',
|
|
18
|
+
message: `${el.id} (${el.surface}) is still unaccounted`,
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
return violations
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Violation } from '../types.ts'
|
|
2
|
+
import type { Gate } from './context.ts'
|
|
3
|
+
|
|
4
|
+
// Gate 5: deltas. A sanctioned delta is only sanctioned once an owner has
|
|
5
|
+
// signed it; an unsigned row at gate time is a difference from the source
|
|
6
|
+
// nobody agreed to.
|
|
7
|
+
export const gate: Gate = (ctx): Violation[] => {
|
|
8
|
+
const violations: Violation[] = []
|
|
9
|
+
for (const delta of ctx.deltas) {
|
|
10
|
+
if (!delta.owner_signed) {
|
|
11
|
+
violations.push({ gate: 'deltas', message: `${delta.id} is not owner-signed` })
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return violations
|
|
15
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { Violation } from '../types.ts'
|
|
2
|
+
import type { Gate } from './context.ts'
|
|
3
|
+
|
|
4
|
+
// Gate 12: handoff. Whether the ratified requirements actually reached the
|
|
5
|
+
// emitted work.
|
|
6
|
+
//
|
|
7
|
+
// This is what stops a plain `migrate check` from meaning no more than "the
|
|
8
|
+
// phases are marked done". Every other gate proves the store is internally
|
|
9
|
+
// consistent about phases 0 through 5, and gate 10 asks whether the run
|
|
10
|
+
// happened; without this one, a store could pass with handoff.json absent and
|
|
11
|
+
// nothing emitted anywhere at all.
|
|
12
|
+
//
|
|
13
|
+
// The honest limit is the same in kind as gate 10's. This proves the emitted
|
|
14
|
+
// work covers the store's requirements. It cannot prove the issues were read
|
|
15
|
+
// or the roadmap was believed.
|
|
16
|
+
export const gate: Gate = (ctx): Violation[] => {
|
|
17
|
+
const violations: Violation[] = []
|
|
18
|
+
const loaded = ctx.handoff
|
|
19
|
+
if (!loaded || loaded.kind === 'absent') {
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
gate: 'handoff',
|
|
23
|
+
message:
|
|
24
|
+
'no handoff.json in the store; handoff has not run, so nothing has reached a delivery medium',
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
if (loaded.kind === 'invalid') {
|
|
29
|
+
// A present-but-unusable file is a different fact from an absent one, and
|
|
30
|
+
// saying "handoff has not run" about a file that exists would send an
|
|
31
|
+
// operator to re-run the command rather than to look at what is in it.
|
|
32
|
+
return loaded.errors.map((e) => ({
|
|
33
|
+
gate: 'handoff',
|
|
34
|
+
message: `handoff.json ${e}`,
|
|
35
|
+
}))
|
|
36
|
+
}
|
|
37
|
+
const handoff = loaded.value
|
|
38
|
+
|
|
39
|
+
const known = new Set(ctx.requirements.map((r) => r.id))
|
|
40
|
+
const keys = new Set(handoff.items.map((i) => i.key))
|
|
41
|
+
const emittedIds = new Set<string>()
|
|
42
|
+
const seenKeys = new Set<string>()
|
|
43
|
+
const seenFrs = new Set<string>()
|
|
44
|
+
|
|
45
|
+
for (const item of handoff.items) {
|
|
46
|
+
// Two work items under one key make `refs`, `dependsOn` and the coverage
|
|
47
|
+
// order all ambiguous, and a Set of keys hides the collision from every
|
|
48
|
+
// check below it.
|
|
49
|
+
if (seenKeys.has(item.key)) {
|
|
50
|
+
violations.push({
|
|
51
|
+
gate: 'handoff',
|
|
52
|
+
message: `work item key ${item.key} appears more than once`,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
seenKeys.add(item.key)
|
|
56
|
+
if (handoff.refs[item.key] === undefined) {
|
|
57
|
+
// `refs` is the only evidence in this file that anything actually
|
|
58
|
+
// reached the medium; every adapter writes one entry per item.
|
|
59
|
+
violations.push({
|
|
60
|
+
gate: 'handoff',
|
|
61
|
+
message: `work item ${item.key} has no entry in refs, so nothing records where it was emitted`,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
for (const fr of item.frs) {
|
|
65
|
+
if (seenFrs.has(fr)) {
|
|
66
|
+
// The emitted count is a sum over frs lengths while membership is a
|
|
67
|
+
// set, so without this one requirement in two work items satisfies
|
|
68
|
+
// both and is delivered twice.
|
|
69
|
+
violations.push({
|
|
70
|
+
gate: 'handoff',
|
|
71
|
+
message: `requirement ${fr} appears in more than one work item`,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
seenFrs.add(fr)
|
|
75
|
+
emittedIds.add(fr)
|
|
76
|
+
if (!known.has(fr)) {
|
|
77
|
+
violations.push({
|
|
78
|
+
gate: 'handoff',
|
|
79
|
+
message: `work item ${item.key} names requirement ${fr}, which is not in the registry`,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const dep of item.dependsOn) {
|
|
84
|
+
if (dep === item.key) {
|
|
85
|
+
violations.push({
|
|
86
|
+
gate: 'handoff',
|
|
87
|
+
message: `work item ${item.key} depends on itself`,
|
|
88
|
+
})
|
|
89
|
+
} else if (!keys.has(dep)) {
|
|
90
|
+
violations.push({
|
|
91
|
+
gate: 'handoff',
|
|
92
|
+
message: `work item ${item.key} depends on ${dep}, which is not a work item`,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// basis.order drives coverage's whole per-capability walk, so an order that
|
|
99
|
+
// does not match the emitted items makes coverage report a denominator
|
|
100
|
+
// narrower than the store without saying so.
|
|
101
|
+
for (const slug of handoff.basis.order) {
|
|
102
|
+
if (!keys.has(slug)) {
|
|
103
|
+
violations.push({
|
|
104
|
+
gate: 'handoff',
|
|
105
|
+
message: `handoff basis order names ${slug}, which is not a work item`,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const key of keys) {
|
|
110
|
+
if (!handoff.basis.order.includes(key)) {
|
|
111
|
+
violations.push({
|
|
112
|
+
gate: 'handoff',
|
|
113
|
+
message: `work item ${key} is missing from the handoff basis order, so coverage would not count it`,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Every requirement, not only the confirmed ones. An inferred requirement is
|
|
119
|
+
// something the build team must see and decide about, so handoff emits it;
|
|
120
|
+
// confidence starts mattering at the coverage denominator, not here.
|
|
121
|
+
for (const r of ctx.requirements) {
|
|
122
|
+
if (!emittedIds.has(r.id)) {
|
|
123
|
+
violations.push({
|
|
124
|
+
gate: 'handoff',
|
|
125
|
+
message: `${r.id} appears in no work item; re-run migrate handoff after the store changed`,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const confirmed = ctx.requirements.filter((r) => r.confidence.kind === 'confirmed').length
|
|
131
|
+
if (handoff.basis.confirmed !== confirmed) {
|
|
132
|
+
violations.push({
|
|
133
|
+
gate: 'handoff',
|
|
134
|
+
message: `handoff basis records ${handoff.basis.confirmed} confirmed requirement(s), but the store has ${confirmed}`,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
const emitted = handoff.items.reduce((n, i) => n + i.frs.length, 0)
|
|
138
|
+
if (handoff.basis.emitted !== emitted) {
|
|
139
|
+
violations.push({
|
|
140
|
+
gate: 'handoff',
|
|
141
|
+
message: `handoff basis records ${handoff.basis.emitted} emitted requirement(s), but its work items carry ${emitted}`,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
return violations
|
|
145
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { scanLeaks } from '../leaks.ts'
|
|
2
|
+
import type { Violation } from '../types.ts'
|
|
3
|
+
import type { Gate } from './context.ts'
|
|
4
|
+
|
|
5
|
+
// Gate 8: leaks, opt-in. Unlike citations, the scan shells out to `git log -S`
|
|
6
|
+
// per secret and its cost scales with history depth rather than with store
|
|
7
|
+
// size, which is why it stays behind a flag while citations do not.
|
|
8
|
+
export const gate: Gate = async (ctx): Promise<Violation[]> => {
|
|
9
|
+
if (!ctx.leaks) return []
|
|
10
|
+
return scanLeaks({ root: ctx.root, gitBin: ctx.gitBin })
|
|
11
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Violation } from '../types.ts'
|
|
2
|
+
import type { Gate } from './context.ts'
|
|
3
|
+
|
|
4
|
+
// Gate 6: parity coverage. A requirement whose confidence is `queued` is
|
|
5
|
+
// waiting on an owner decision, so it is not yet expected to carry a parity
|
|
6
|
+
// plan; every other requirement is.
|
|
7
|
+
export const gate: Gate = (ctx): Violation[] => {
|
|
8
|
+
const violations: Violation[] = []
|
|
9
|
+
for (const req of ctx.requirements) {
|
|
10
|
+
if (req.confidence.kind !== 'queued' && req.parity === null) {
|
|
11
|
+
violations.push({ gate: 'parity', message: `${req.id} has no parity plan` })
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return violations
|
|
15
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Violation } from '../types.ts'
|
|
2
|
+
import type { Gate } from './context.ts'
|
|
3
|
+
|
|
4
|
+
// Gate 4: queue grammar. loadQueue does the parsing and collects one error
|
|
5
|
+
// string per grammar violation; this gate only labels them, because a queue
|
|
6
|
+
// file that will not parse is equally a problem for `queue list`, `report`
|
|
7
|
+
// and `adjudicate`, all of which call loadQueue directly.
|
|
8
|
+
export const gate: Gate = (ctx): Violation[] =>
|
|
9
|
+
ctx.queueErrors.map((message) => ({ gate: 'queue', message }))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Violation } from '../types.ts'
|
|
2
|
+
import type { Gate } from './context.ts'
|
|
3
|
+
|
|
4
|
+
// A duplicate id or slug within one store file is a real defect the refs
|
|
5
|
+
// gate must catch on its own, not something it can assume another gate or
|
|
6
|
+
// command already ruled out: `import reqs` upserts by id, but
|
|
7
|
+
// capabilities.jsonl has no import path at all today, so hand-editing is
|
|
8
|
+
// currently the only way a row lands there, and nothing stops two rows
|
|
9
|
+
// from hand-editing into the same identity with different content. A
|
|
10
|
+
// gate whose soundness depends on another gate having run first is not
|
|
11
|
+
// independently a gate. Counted off the raw row arrays, not a `Set`:
|
|
12
|
+
// a `Set` collapses duplicates by construction, which is exactly the
|
|
13
|
+
// evidence (which id, how many rows) this check exists to preserve.
|
|
14
|
+
function duplicatesOf(values: string[]): Map<string, number> {
|
|
15
|
+
const counts = new Map<string, number>()
|
|
16
|
+
for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1)
|
|
17
|
+
const dups = new Map<string, number>()
|
|
18
|
+
for (const [v, count] of counts) {
|
|
19
|
+
if (count > 1) dups.set(v, count)
|
|
20
|
+
}
|
|
21
|
+
return dups
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Gate 3: referential integrity.
|
|
25
|
+
export const gate: Gate = (ctx): Violation[] => {
|
|
26
|
+
const violations: Violation[] = []
|
|
27
|
+
const reqIds = new Set(ctx.requirements.map((r) => r.id))
|
|
28
|
+
const elementIds = new Set(ctx.elements.map((e) => e.id))
|
|
29
|
+
const capSlugs = new Set(ctx.capabilities.map((c) => c.slug))
|
|
30
|
+
const queueIds = new Set(ctx.queueItems.map((q) => q.id))
|
|
31
|
+
|
|
32
|
+
for (const [id, count] of duplicatesOf(ctx.requirements.map((r) => r.id))) {
|
|
33
|
+
violations.push({
|
|
34
|
+
gate: 'refs',
|
|
35
|
+
message: `requirement id ${id} appears ${count} times in requirements.jsonl`,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
for (const [slug, count] of duplicatesOf(ctx.capabilities.map((c) => c.slug))) {
|
|
39
|
+
violations.push({
|
|
40
|
+
gate: 'refs',
|
|
41
|
+
message: `capability slug ${slug} appears ${count} times in capabilities.jsonl`,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
for (const [id, count] of duplicatesOf(ctx.elements.map((e) => e.id))) {
|
|
45
|
+
violations.push({
|
|
46
|
+
gate: 'refs',
|
|
47
|
+
message: `element id ${id} appears ${count} times in elements.jsonl`,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `field` names where the queue reference came from (`disposition.queue`,
|
|
52
|
+
// `confidence.queue`, `parity.queue`) so that one requirement citing the
|
|
53
|
+
// same missing queue id from two different fields produces two
|
|
54
|
+
// violations that read as two distinct citations to fix, not one
|
|
55
|
+
// ambiguous duplicate-looking line.
|
|
56
|
+
const needQueue = (id: string, owner: string, field: string): void => {
|
|
57
|
+
if (!queueIds.has(id)) {
|
|
58
|
+
violations.push({
|
|
59
|
+
gate: 'refs',
|
|
60
|
+
message: `${owner} references queue item ${id} via ${field}, which does not exist`,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const el of ctx.elements) {
|
|
65
|
+
if (el.disposition.kind === 'mapped' && !reqIds.has(el.disposition.fr)) {
|
|
66
|
+
violations.push({
|
|
67
|
+
gate: 'refs',
|
|
68
|
+
message: `${el.id} is mapped to ${el.disposition.fr}, which is not in the registry`,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
if (el.disposition.kind === 'out-of-scope') {
|
|
72
|
+
needQueue(el.disposition.queue, el.id, 'disposition.queue')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const req of ctx.requirements) {
|
|
76
|
+
if (!capSlugs.has(req.cap)) {
|
|
77
|
+
violations.push({
|
|
78
|
+
gate: 'refs',
|
|
79
|
+
message: `${req.id} names capability ${req.cap}, which is not in the partition`,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
if (req.confidence.kind === 'queued')
|
|
83
|
+
needQueue(req.confidence.queue, req.id, 'confidence.queue')
|
|
84
|
+
if (req.parity?.kind === 'rubric' && req.parity.level !== 'high') {
|
|
85
|
+
needQueue(req.parity.queue, req.id, 'parity.queue')
|
|
86
|
+
}
|
|
87
|
+
for (const ref of req.citations) {
|
|
88
|
+
if (ref.kind === 'ledger' && !elementIds.has(ref.id)) {
|
|
89
|
+
violations.push({
|
|
90
|
+
gate: 'refs',
|
|
91
|
+
message: `${req.id} cites ledger id ${ref.id}, which is not in the ledger`,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return violations
|
|
97
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { PHASES, type Phase } from '../phases.ts'
|
|
2
|
+
import type { Violation } from '../types.ts'
|
|
3
|
+
import { type Gate, validCensus } from './context.ts'
|
|
4
|
+
|
|
5
|
+
// Gate 10: run-state. Every other gate proves the store is internally
|
|
6
|
+
// consistent, which an empty store satisfies. This one asks whether the run
|
|
7
|
+
// that was supposed to fill it actually happened, which is the only reason
|
|
8
|
+
// exit 0 can mean "complete" rather than "nothing contradicts anything".
|
|
9
|
+
export const gate: Gate = (ctx): Violation[] => {
|
|
10
|
+
const violations: Violation[] = []
|
|
11
|
+
const terminusName = PHASES[ctx.terminusIndex]
|
|
12
|
+
|
|
13
|
+
for (let i = 0; i <= ctx.terminusIndex; i++) {
|
|
14
|
+
const p = PHASES[i]
|
|
15
|
+
if (!p) continue
|
|
16
|
+
const status = ctx.phases[p].status
|
|
17
|
+
if (status !== 'done') {
|
|
18
|
+
violations.push({
|
|
19
|
+
gate: 'run-state',
|
|
20
|
+
message: `phase ${p} is ${status}; every phase through ${terminusName} must be done`,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Checked across all eight phases, not just up to the terminus: a later
|
|
26
|
+
// phase marked done over a pending predecessor is hand-edited state, and it
|
|
27
|
+
// is worth naming whether or not the caller asked about that phase.
|
|
28
|
+
for (let i = 1; i < PHASES.length; i++) {
|
|
29
|
+
const current = PHASES[i]
|
|
30
|
+
const previous = PHASES[i - 1]
|
|
31
|
+
if (!current || !previous) continue
|
|
32
|
+
if (ctx.phases[current].status === 'done' && ctx.phases[previous].status === 'pending') {
|
|
33
|
+
violations.push({
|
|
34
|
+
gate: 'run-state',
|
|
35
|
+
message: `phase ${current} is done while ${previous} is still pending`,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A census record naming a batch phases.json never committed means the two
|
|
41
|
+
// disagree about what happened. Only checked when the record exists, since
|
|
42
|
+
// gate 2 already names a declared surface or closer that has none.
|
|
43
|
+
const committedIn = (phase: Phase): Set<string> =>
|
|
44
|
+
new Set(ctx.phases[phase].batches.map((b) => b.id))
|
|
45
|
+
const census = validCensus(ctx.censusRows)
|
|
46
|
+
const enumerateBatches = committedIn('enumerate')
|
|
47
|
+
for (const surface of ctx.cfg.surfaces) {
|
|
48
|
+
const record = census.find((r) => r.kind === 'lens' && r.surface === surface)
|
|
49
|
+
if (record && !enumerateBatches.has(record.batch)) {
|
|
50
|
+
violations.push({
|
|
51
|
+
gate: 'run-state',
|
|
52
|
+
message: `lens census for ${surface} names batch ${record.batch}, which phases.json has no record of committing in enumerate`,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const extractBatches = committedIn('extract')
|
|
57
|
+
for (const closer of ctx.cfg.closers) {
|
|
58
|
+
const record = census.find((r) => r.kind === 'closer' && r.closer === closer)
|
|
59
|
+
if (record && !extractBatches.has(record.batch)) {
|
|
60
|
+
violations.push({
|
|
61
|
+
gate: 'run-state',
|
|
62
|
+
message: `closer census for ${closer} names batch ${record.batch}, which phases.json has no record of committing in extract`,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return violations
|
|
67
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import type { Violation } from '../types.ts'
|
|
3
|
+
import type { Gate } from './context.ts'
|
|
4
|
+
|
|
5
|
+
async function sourceIsDirty(sourcePath: string, gitBin: string): Promise<boolean> {
|
|
6
|
+
if (!existsSync(`${sourcePath}/.git`)) return false
|
|
7
|
+
const proc = Bun.spawn([gitBin, 'status', '--porcelain'], {
|
|
8
|
+
cwd: sourcePath,
|
|
9
|
+
stdout: 'pipe',
|
|
10
|
+
stderr: 'ignore',
|
|
11
|
+
})
|
|
12
|
+
const out = await new Response(proc.stdout).text()
|
|
13
|
+
await proc.exited
|
|
14
|
+
return out.trim().length > 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Gate 9: source integrity. A source that is not a git checkout cannot be
|
|
18
|
+
// checked this way and is not reported as a violation; the CLI's own refusal
|
|
19
|
+
// to write any path under the source root is what covers that case.
|
|
20
|
+
export const gate: Gate = async (ctx): Promise<Violation[]> => {
|
|
21
|
+
if (!(await sourceIsDirty(ctx.cfg.source.path, ctx.gitBin))) return []
|
|
22
|
+
return [
|
|
23
|
+
{
|
|
24
|
+
gate: 'source',
|
|
25
|
+
message: `the source checkout at ${ctx.cfg.source.path} has uncommitted changes; it must stay read-only`,
|
|
26
|
+
},
|
|
27
|
+
]
|
|
28
|
+
}
|