yadflow 3.18.1 → 4.0.0-next.1
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 +355 -0
- package/README.md +79 -26
- package/bin/commands.mjs +41 -0
- package/bin/yad.mjs +437 -124
- package/cli/artifact-status.mjs +34 -15
- package/cli/checkpoint.mjs +69 -49
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/companion.mjs +2 -2
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1472 -97
- package/cli/epic-state.mjs +3478 -232
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +4 -1
- package/cli/gate.mjs +1002 -209
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +266 -55
- package/cli/hubcommit.mjs +6 -17
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +57 -7
- package/cli/lib.mjs +184 -18
- package/cli/manifest.mjs +367 -56
- package/cli/migrate.mjs +726 -53
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +349 -90
- package/cli/openpr.mjs +191 -39
- package/cli/people.mjs +654 -0
- package/cli/plan.mjs +417 -132
- package/cli/platform.mjs +110 -129
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +38 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +21 -15
- package/cli/review.mjs +24 -27
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +139 -287
- package/cli/ship.mjs +7 -6
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +42 -17
- package/cli/tidy.mjs +20 -20
- package/cli/update-commit.mjs +22 -22
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +166 -87
- package/skills/sdlc/module-help.csv +35 -35
- package/skills/yad-analysis/SKILL.md +125 -65
- package/skills/yad-architecture/SKILL.md +34 -23
- package/skills/yad-architecture/references/contract-format.md +10 -8
- package/skills/yad-backfill/SKILL.md +14 -8
- package/skills/yad-backfill/references/backfill.md +1 -1
- package/skills/yad-change/SKILL.md +127 -52
- package/skills/yad-change/references/triage.md +42 -28
- package/skills/yad-checks/SKILL.md +89 -45
- package/skills/yad-checks/references/check-gates.md +315 -92
- package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
- package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
- package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
- package/skills/yad-commit/SKILL.md +6 -6
- package/skills/yad-connect-design/SKILL.md +6 -6
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +12 -12
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +92 -54
- package/skills/yad-connect-repos/references/code-context.md +6 -6
- package/skills/yad-connect-repos/references/hub-config.md +68 -58
- package/skills/yad-connect-repos/references/repos-registry.md +10 -9
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +6 -6
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +8 -8
- package/skills/yad-discovery/SKILL.md +130 -94
- package/skills/yad-discovery/references/discovery-schema.md +23 -7
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +16 -11
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +32 -17
- package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +88 -24
- package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
- package/skills/yad-epic/SKILL.md +178 -100
- package/skills/yad-epic/references/state-schema.md +626 -117
- package/skills/yad-hub-bridge/SKILL.md +66 -48
- package/skills/yad-hub-bridge/references/bridge.md +110 -83
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
- package/skills/yad-implement/SKILL.md +29 -15
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +64 -29
- package/skills/yad-pair-review/SKILL.md +18 -16
- package/skills/yad-pair-review/references/session-state.md +4 -4
- package/skills/yad-pr-template/SKILL.md +48 -27
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
- package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +12 -9
- package/skills/yad-review-gate/SKILL.md +198 -79
- package/skills/yad-review-gate/references/gating.md +230 -54
- package/skills/yad-run/SKILL.md +86 -56
- package/skills/yad-run/references/run-loop.md +67 -45
- package/skills/yad-ship/SKILL.md +18 -14
- package/skills/yad-spec/SKILL.md +31 -17
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +114 -56
- package/skills/yad-stories/SKILL.md +42 -27
- package/skills/yad-stories/references/story-schema.md +10 -9
- package/skills/yad-stub/SKILL.md +59 -48
- package/skills/yad-sync-repos/SKILL.md +3 -3
- package/skills/yad-test-cases/SKILL.md +37 -30
- package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +46 -25
- package/cli/roster.mjs +0 -164
- package/skills/sdlc/install.sh +0 -68
package/cli/hook.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `yad hook ledger-guard` — the harness-side half of the ledger rule (#171).
|
|
2
2
|
//
|
|
3
|
-
// In
|
|
4
|
-
// commit that changes `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or `epics/*/reviews/*.md`.
|
|
3
|
+
// In verified mode the gate ledger is CI-owned: `templates/checks/ledger-guard.sh` rejects any non-bot
|
|
4
|
+
// commit that changes `epics/*/.sdlc/{state,approvals,comments,product-prs,hub-prs}.json` or `epics/*/reviews/*.md`.
|
|
5
5
|
// That gate is the authority, but it only speaks at CI time — an agent that hand-edits `state.json`
|
|
6
6
|
// learns twenty minutes later, from a failed pipeline with nothing connecting cause to effect. This
|
|
7
7
|
// hook says the same thing at the moment of the edit, and names the command that owns the transition.
|
|
@@ -10,69 +10,162 @@
|
|
|
10
10
|
// stdin a harness tool-call payload as JSON (optional; `--path <p>` works instead)
|
|
11
11
|
// exit 0 allow
|
|
12
12
|
// exit 2 deny — the reason is on stderr, for the agent to read
|
|
13
|
-
// Claude Code's PreToolUse protocol is exactly that
|
|
14
|
-
// the model
|
|
15
|
-
// the same two exit codes.
|
|
13
|
+
// Claude Code's `PreToolUse` protocol is exactly that — exit 2 blocks the call and feeds stderr back
|
|
14
|
+
// to the model — so `hooks/ledger-guard.sh` wires it with no adapter logic.
|
|
16
15
|
//
|
|
17
|
-
//
|
|
16
|
+
// Cursor's `preToolUse` is NOT that. It is a permission hook: it wants a JSON verdict on stdout and
|
|
17
|
+
// treats an empty or off-schema answer as a refusal, so the contract above would block every write.
|
|
18
|
+
// `--format cursor` below answers in that protocol instead, and `hooks/ledger-guard-cursor.sh` is
|
|
19
|
+
// what Cursor's entry points at. Check which kind a harness is before wiring a new one.
|
|
20
|
+
//
|
|
21
|
+
// FAIL-OPEN, deliberately. No Product, unreadable config, an unparseable payload, no git — every one of
|
|
18
22
|
// those ALLOWS, with a note on stderr. This is a local guardrail, and one that failed closed would
|
|
19
23
|
// brick an agent's ability to edit anything the moment a config went sideways. The asymmetry is the
|
|
20
24
|
// design: `ledger-guard` in CI fails closed and is what actually protects the ledger.
|
|
21
25
|
import fs from 'node:fs';
|
|
22
26
|
import path from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
23
28
|
import { note, readJSON, run } from './lib.mjs';
|
|
24
|
-
import {
|
|
29
|
+
import { isVerifiedLedger , productConfigPath, HOOK_PROJECT_DIR_ENVS } from './manifest.mjs';
|
|
30
|
+
import { DISCOVERY_EPIC, FOUNDATION_DIR, FOUNDATION_EPIC } from './epic-state.mjs';
|
|
25
31
|
|
|
26
32
|
// The CI-owned files, exactly as `templates/checks/ledger-guard.sh` lists them. NOT `contract-lock.json`
|
|
27
33
|
// (artifact-side: the architect commits it with the architecture) and NOT `change.json` — both are a
|
|
28
34
|
// human's to write, and both are exempt in the gate too.
|
|
29
|
-
|
|
35
|
+
// Both names of the PR ledger: it was renamed `hub-prs.json` -> `product-prs.json` and the engine
|
|
36
|
+
// writes both for one major, so refusing only one would leave the other hand-editable.
|
|
37
|
+
const LEDGER_FILES = new Set(['state.json', 'approvals.json', 'comments.json', 'product-prs.json', 'hub-prs.json']);
|
|
30
38
|
|
|
31
39
|
// `epics/<epic>/.sdlc/<ledger>.json` or `epics/<epic>/reviews/<name>.md` → { epic, rel }; else null.
|
|
32
|
-
// Takes a
|
|
40
|
+
// Takes a Product-relative POSIX path.
|
|
33
41
|
//
|
|
34
42
|
// Depth is matched the way the CI gate matches it, not more strictly. Its arms are bash `case`
|
|
35
43
|
// globs — `epics/*/.sdlc/state.json` — and a bash glob's `*` spans `/`, so the gate ALSO rejects
|
|
36
44
|
// `epics/EP-a/nested/.sdlc/state.json`. Requiring exactly four segments here would have let a path
|
|
37
45
|
// through locally that CI blocks, in a guard whose whole claim is that its scope is the gate's.
|
|
38
46
|
// The slug is the second segment either way (the gate's `${f#epics/}` / `${_slug%%/*}`).
|
|
47
|
+
//
|
|
48
|
+
// The Foundation (E75) is the same rule in its own folder: `foundation/.sdlc/<ledger>.json` and
|
|
49
|
+
// `foundation/reviews/*.md`, under the fixed id `EP-foundation`. The gate's arms for it are globs of the
|
|
50
|
+
// same depth-blind kind, so the same depth-blind match is used here.
|
|
51
|
+
//
|
|
52
|
+
// The Product index (E19) is the one protected path outside an epic: `.sdlc/index.json`, exactly, and
|
|
53
|
+
// nothing else at the Product root (product.json, hub.json, repos.json are a person's to edit). It belongs
|
|
54
|
+
// to no epic and is never a seed, so it is returned with `epic: null` and no carve-out applies.
|
|
55
|
+
export const INDEX_REL = '.sdlc/index.json';
|
|
39
56
|
export function protectedLedgerPath(rel) {
|
|
57
|
+
if (rel === INDEX_REL) return { epic: null, rel, kind: 'index' };
|
|
58
|
+
const ledgers = [...LEDGER_FILES].map((f) => f.replace('.', '\\.')).join('|');
|
|
59
|
+
const classify = (epic) => {
|
|
60
|
+
if (new RegExp(`/\\.sdlc/(?:${ledgers})$`).test(rel)) return { epic, rel, kind: 'state' };
|
|
61
|
+
if (/\/reviews\/.*\.md$/.test(rel)) return { epic, rel, kind: 'review' };
|
|
62
|
+
return null;
|
|
63
|
+
};
|
|
64
|
+
if (rel.startsWith(`${FOUNDATION_DIR}/`)) return classify(FOUNDATION_EPIC);
|
|
40
65
|
if (!rel.startsWith('epics/')) return null;
|
|
41
66
|
const epic = rel.slice('epics/'.length).split('/')[0];
|
|
42
67
|
if (!epic || epic === '.' || epic === '..') return null;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
68
|
+
return classify(epic);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Every path a tool call would write.
|
|
72
|
+
//
|
|
73
|
+
// The named keys are Claude Code's, and they are checked first because they are documented and exact:
|
|
74
|
+
// `file_path` (Edit/Write), `notebook_path` (NotebookEdit), `path`, and a `MultiEdit`-style `edits[]`
|
|
75
|
+
// array of `file_path`.
|
|
76
|
+
//
|
|
77
|
+
// THE SHAPE RULE EXISTS BECAUSE THE SECOND HARNESS DOES NOT DOCUMENT ITS KEYS (E11). Cursor publishes
|
|
78
|
+
// the payload envelope — `tool_name`, `tool_input`, `cwd` — but not what `tool_input` is called
|
|
79
|
+
// inside for Write, Edit or Delete. Inventing a name from memory would produce the worst outcome this
|
|
80
|
+
// hook has: an entry wired, reported healthy by `yad doctor`, reading a key that does not exist,
|
|
81
|
+
// finding no path, and allowing every ledger edit. So instead of guessing a vendor's spelling, any
|
|
82
|
+
// key whose NAME ends in `path`, `file`, `paths` or `files` (in snake_case or camelCase) is read as
|
|
83
|
+
// one — a structural rule, which `target_file`, `filePath` and `file_path` all satisfy without this
|
|
84
|
+
// code having to know which harness sent them.
|
|
85
|
+
//
|
|
86
|
+
// It matches on the KEY, never on the value. A rule that scanned `tool_input`'s values for anything
|
|
87
|
+
// shaped like `epics/…/state.json` would refuse an ordinary edit to a document that merely QUOTES a
|
|
88
|
+
// ledger path — and a false deny is far worse here than a miss, because the whole design fails open
|
|
89
|
+
// on purpose and CI is what fails closed.
|
|
90
|
+
//
|
|
91
|
+
// An unrecognised payload still yields nothing, which allows — see the fail-open note above.
|
|
92
|
+
// A key is path-shaped when one of its WORDS is. Splitting on `_` and on camelCase humps is what
|
|
93
|
+
// makes `target_file`, `filePath`, `notebook_path` and `dest_path` all match while `profile` does not
|
|
94
|
+
// — `profile` is one word, and that word is not in the set. Matching a bare suffix instead would
|
|
95
|
+
// claim it, and a false path is a false DENY, which is the worse error in a guard that fails open by
|
|
96
|
+
// design. `filename`/`filepath` are single words, so they are in the set in their own right.
|
|
97
|
+
const PATH_WORDS = new Set(['file', 'files', 'filename', 'filenames', 'filepath', 'filepaths', 'path', 'paths', 'dest', 'destination']);
|
|
98
|
+
|
|
99
|
+
// `uri` and `url` are deliberately NOT path words — a `url` is usually somewhere to fetch, and
|
|
100
|
+
// claiming one as a file would be a false path, which is a false DENY. But a `file://` URL is
|
|
101
|
+
// unambiguously a local file, and some editors send exactly that. So those keys are read only when
|
|
102
|
+
// the VALUE says it is a file, and the scheme is stripped so the rest of the guard sees a path.
|
|
103
|
+
// `decodeURIComponent` because a URL escapes the spaces and other characters a real path may hold.
|
|
104
|
+
const LOCATOR_WORDS = new Set(['uri', 'url', 'uris', 'urls']);
|
|
105
|
+
const FILE_URL = /^file:\/\//i;
|
|
106
|
+
const fileUrlToPath = (value) => {
|
|
107
|
+
if (!FILE_URL.test(value)) return null;
|
|
108
|
+
try { return fileURLToPath(value); } catch { return null; }
|
|
109
|
+
};
|
|
110
|
+
const keyWords = (key) => String(key).split(/[_\-\s]+|(?=[A-Z])/).map((w) => w.toLowerCase()).filter(Boolean);
|
|
111
|
+
const isPathKey = (key) => keyWords(key).some((w) => PATH_WORDS.has(w));
|
|
112
|
+
const isLocatorKey = (key) => keyWords(key).some((w) => LOCATOR_WORDS.has(w));
|
|
113
|
+
|
|
114
|
+
// Walk the tool input for path-shaped keys, a bounded distance down. Harnesses nest: an `args` or
|
|
115
|
+
// `parameters` wrapper, a `files: [{ path, content }]` batch. A top-level-only scan missed all of it
|
|
116
|
+
// and allowed the edit, which is the failure this rule exists to avoid. The depth cap keeps a
|
|
117
|
+
// pathological payload from costing anything — this runs inside the agent's tool loop, on every call.
|
|
118
|
+
const MAX_PAYLOAD_DEPTH = 4;
|
|
119
|
+
|
|
120
|
+
function pathsFromInput(input, out, depth = 0) {
|
|
121
|
+
if (!input || typeof input !== 'object' || depth > MAX_PAYLOAD_DEPTH) return;
|
|
122
|
+
for (const [key, value] of Object.entries(input)) {
|
|
123
|
+
if (typeof value === 'string') {
|
|
124
|
+
if (!value) continue;
|
|
125
|
+
if (isPathKey(key)) out.push(value);
|
|
126
|
+
else if (isLocatorKey(key)) { const p = fileUrlToPath(value); if (p) out.push(p); }
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(value)) {
|
|
130
|
+
for (const v of value) {
|
|
131
|
+
// A string in a path-shaped list (`paths`, `files`) is a path; an object in ANY list may hold
|
|
132
|
+
// one under a key of its own, which is the `files: [{ path }]` batch shape.
|
|
133
|
+
if (typeof v === 'string') {
|
|
134
|
+
if (!v) continue;
|
|
135
|
+
if (isPathKey(key)) out.push(v);
|
|
136
|
+
else if (isLocatorKey(key)) { const p = fileUrlToPath(v); if (p) out.push(p); }
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
pathsFromInput(v, out, depth + 1);
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
pathsFromInput(value, out, depth + 1);
|
|
144
|
+
}
|
|
47
145
|
}
|
|
48
146
|
|
|
49
|
-
// Every path a tool call would write. Covers the shapes harnesses actually send: a single
|
|
50
|
-
// `file_path` (Edit/Write), `notebook_path` (NotebookEdit), and a `MultiEdit`-style `edits[]` array.
|
|
51
|
-
// An unrecognised payload yields nothing, which allows — see the fail-open note above.
|
|
52
147
|
export function payloadPaths(payload) {
|
|
53
148
|
const out = [];
|
|
54
149
|
const input = payload?.tool_input;
|
|
55
150
|
if (!input || typeof input !== 'object') return out;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
for (const edit of input.edits) if (typeof edit?.file_path === 'string' && edit.file_path) out.push(edit.file_path);
|
|
61
|
-
}
|
|
151
|
+
// One walk. Claude's documented `file_path` / `notebook_path` / `path`, and its `edits[]` array,
|
|
152
|
+
// are all path-shaped keys under the rule above, so naming them separately would only be a second
|
|
153
|
+
// list to forget to update.
|
|
154
|
+
pathsFromInput(input, out);
|
|
62
155
|
return [...new Set(out)];
|
|
63
156
|
}
|
|
64
157
|
|
|
65
|
-
// The
|
|
158
|
+
// The Product a path belongs to: the nearest ancestor holding `.sdlc/hub.json`.
|
|
66
159
|
//
|
|
67
160
|
// Resolved from the PATH, never from the session. The documented layout puts code repos BESIDE the
|
|
68
|
-
//
|
|
161
|
+
// Product (`project/{product,backend,mobile}` — see `insideWorkspace` in setup.mjs), so a session opened
|
|
69
162
|
// at the workspace has no `hub.json` under its root, and a session-rooted lookup would find nothing
|
|
70
163
|
// and silently allow a mutation inside `project/product/epics/…` — the multi-repo, parallel-agent
|
|
71
164
|
// setup this hook was reported from.
|
|
72
165
|
export function hubRootFor(abs) {
|
|
73
166
|
let dir = path.dirname(path.resolve(abs));
|
|
74
167
|
for (;;) {
|
|
75
|
-
if (fs.existsSync(
|
|
168
|
+
if (fs.existsSync(productConfigPath(dir))) return dir;
|
|
76
169
|
const parent = path.dirname(dir);
|
|
77
170
|
if (parent === dir) return null;
|
|
78
171
|
dir = parent;
|
|
@@ -80,9 +173,33 @@ export function hubRootFor(abs) {
|
|
|
80
173
|
}
|
|
81
174
|
|
|
82
175
|
// Where a RELATIVE path in the payload is anchored. Only used to make such a path absolute, so the
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
176
|
+
// Product walk-up above has somewhere to start.
|
|
177
|
+
//
|
|
178
|
+
// Every harness's project-root variable is tried, in the order the adapters declare them, before
|
|
179
|
+
// anything is guessed (E11).
|
|
180
|
+
//
|
|
181
|
+
// A correction worth keeping, because the obvious reasoning is wrong: Cursor's docs say it also
|
|
182
|
+
// exports `CLAUDE_PROJECT_DIR`, as a deliberate compatibility alias for `CURSOR_PROJECT_DIR`. So
|
|
183
|
+
// "reading only Claude Code's variable" did NOT leave Cursor falling through — both names are set
|
|
184
|
+
// there and point at the same directory, which is also why first-wins ordering is safe rather than
|
|
185
|
+
// merely untested. The real gap this order closes is a harness that sets neither, and the case below
|
|
186
|
+
// it: no variable at all, where the git toplevel used to win over the process cwd.
|
|
187
|
+
//
|
|
188
|
+
// `env` is a parameter precisely so a test can pin each branch without setting real environment
|
|
189
|
+
// variables.
|
|
190
|
+
export function baseDirFor(env = process.env, runner = run, payloadCwd = null) {
|
|
191
|
+
for (const name of HOOK_PROJECT_DIR_ENVS) {
|
|
192
|
+
if (env[name]) return env[name];
|
|
193
|
+
}
|
|
194
|
+
// The harness's own working directory for this tool call, when the payload carries one. It is the
|
|
195
|
+
// directory a relative path in that same payload is relative TO, so it beats any guess.
|
|
196
|
+
if (typeof payloadCwd === 'string' && payloadCwd) return payloadCwd;
|
|
197
|
+
// `process.cwd()` BEFORE the git toplevel, which is the opposite of the old order and fixes a real
|
|
198
|
+
// miss: the documented layout puts the Product in a subdirectory of its repo, so the toplevel is the
|
|
199
|
+
// repo, `hubRootFor` walks up from there, finds no `hub.json`, and ALLOWS a ledger edit it must
|
|
200
|
+
// refuse. Anchoring too deep still tends to resolve into the Product and deny; anchoring too
|
|
201
|
+
// shallow misses entirely, so the shallower guess goes last.
|
|
202
|
+
if (process.cwd()) return process.cwd();
|
|
86
203
|
const top = runner('git', ['rev-parse', '--show-toplevel']);
|
|
87
204
|
return top.ok && top.stdout ? top.stdout : process.cwd();
|
|
88
205
|
}
|
|
@@ -97,12 +214,12 @@ const fold = (s) => s.toLowerCase();
|
|
|
97
214
|
// and `git fetch` never fast-forwards it, so probing `main` would report an epic whose review PR has
|
|
98
215
|
// already merged as absent from the base and wave a real mutation straight through. That is the
|
|
99
216
|
// stale-clone case, and it is the common one, not an edge.
|
|
100
|
-
export function resolveHookBase(
|
|
217
|
+
export function resolveHookBase(productRoot, hub, runner = run) {
|
|
101
218
|
const cfg = hub?.default_branch || '';
|
|
102
|
-
const head = runner('git', ['-C',
|
|
219
|
+
const head = runner('git', ['-C', productRoot, 'symbolic-ref', '--short', '--quiet', 'refs/remotes/origin/HEAD']);
|
|
103
220
|
for (const base of [cfg ? `origin/${cfg}` : '', head.ok ? head.stdout : '', 'origin/main']) {
|
|
104
221
|
if (!base || base === 'origin/') continue;
|
|
105
|
-
if (runner('git', ['-C',
|
|
222
|
+
if (runner('git', ['-C', productRoot, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`]).ok) return base;
|
|
106
223
|
}
|
|
107
224
|
return null;
|
|
108
225
|
}
|
|
@@ -113,11 +230,11 @@ export function resolveHookBase(hubRoot, hub, runner = run) {
|
|
|
113
230
|
// rides the first review PR/MR. Mutating a ledger that is already on the base is what only the bot
|
|
114
231
|
// may do.
|
|
115
232
|
//
|
|
116
|
-
// Read with `ls-tree` from the
|
|
117
|
-
// resolved from the repository TOP LEVEL and `-C` does not re-anchor it, so a
|
|
233
|
+
// Read with `ls-tree` from the Product, never with a `<rev>:<path>` probe: a rev:path spec is always
|
|
234
|
+
// resolved from the repository TOP LEVEL and `-C` does not re-anchor it, so a Product sitting in a
|
|
118
235
|
// subdirectory of its repo (a monorepo, or a workspace that is itself a repo) would miss on every
|
|
119
|
-
// probe and the guard would allow everything, silently. `ls-tree` run with `-C
|
|
120
|
-
// cwd-relative pathspec and prints cwd-relative paths, so both halves stay
|
|
236
|
+
// probe and the guard would allow everything, silently. `ls-tree` run with `-C productRoot` takes a
|
|
237
|
+
// cwd-relative pathspec and prints cwd-relative paths, so both halves stay Product-relative.
|
|
121
238
|
//
|
|
122
239
|
// Slugs are FOLDED because the gate folds them: on a case-insensitive filesystem `epics/ep-x/…` and
|
|
123
240
|
// `epics/EP-X/…` are the same file, so a byte-exact compare lets a mutation be laundered as a
|
|
@@ -126,29 +243,34 @@ export function resolveHookBase(hubRoot, hub, runner = run) {
|
|
|
126
243
|
// null means the base could not be read at all — "unknown", which ALLOWS. The working tree cannot
|
|
127
244
|
// stand in for the base ref: a seed writes `state.json` first, so using that as proof would deny
|
|
128
245
|
// every remaining file of the same seed.
|
|
129
|
-
export function seededSlugs(
|
|
130
|
-
const base = resolveHookBase(
|
|
246
|
+
export function seededSlugs(productRoot, hub, runner = run) {
|
|
247
|
+
const base = resolveHookBase(productRoot, hub, runner);
|
|
131
248
|
if (!base) return null;
|
|
132
249
|
const tree = runner('git', [
|
|
133
|
-
'-C',
|
|
250
|
+
'-C', productRoot, '-c', 'core.quotePath=false', 'ls-tree', '-r', '--name-only', '-z', base, '--', 'epics', FOUNDATION_DIR,
|
|
134
251
|
]);
|
|
135
252
|
if (!tree.ok) return null;
|
|
136
253
|
const slugs = new Set();
|
|
137
254
|
for (const p of tree.stdout.split('\0')) {
|
|
138
255
|
const m = /^epics\/([^/]+)\/\.sdlc\/state\.json$/.exec(p);
|
|
139
256
|
if (m) slugs.add(fold(m[1]));
|
|
257
|
+
if (p === `${FOUNDATION_DIR}/.sdlc/state.json`) slugs.add(fold(FOUNDATION_EPIC)); // E75
|
|
140
258
|
}
|
|
259
|
+
// One product level: with the old spelling on base, a Foundation ledger replaces a CI-owned one
|
|
260
|
+
// rather than creating something new — the same rule the CI gate applies.
|
|
261
|
+
if (slugs.has(fold(DISCOVERY_EPIC))) slugs.add(fold(FOUNDATION_EPIC));
|
|
262
|
+
else if (slugs.has(fold(FOUNDATION_EPIC))) slugs.add(fold(DISCOVERY_EPIC)); // the mirror
|
|
141
263
|
return slugs;
|
|
142
264
|
}
|
|
143
265
|
|
|
144
266
|
|
|
145
267
|
// What the agent is told when the edit is refused. Names the command that owns each transition —
|
|
146
268
|
// the whole point of #171 was that the ledger write had no command behind it.
|
|
147
|
-
export function denyMessage({ epic, rel,
|
|
269
|
+
export function denyMessage({ epic, rel, productRoot }) {
|
|
148
270
|
return [
|
|
149
271
|
`[yad] Blocked: ${rel} is CI-owned gate state.`,
|
|
150
272
|
'',
|
|
151
|
-
'This
|
|
273
|
+
'This Product runs in verified mode, where CI is the sole writer of the gate ledger. The `ledger-guard`',
|
|
152
274
|
'check rejects any non-bot commit that changes it, so this edit cannot reach the default branch —',
|
|
153
275
|
'it would fail the review PR/MR and have to be reverted.',
|
|
154
276
|
'',
|
|
@@ -160,35 +282,51 @@ export function denyMessage({ epic, rel, hubRoot }) {
|
|
|
160
282
|
'Commit the ARTIFACT only (the .md you authored) and hand off to `yad-review-gate`; the ledger',
|
|
161
283
|
'follows on merge.',
|
|
162
284
|
'',
|
|
163
|
-
`hub: ${
|
|
285
|
+
`hub: ${productRoot} · override for one command: YAD_HOOK_DISABLE=1`,
|
|
286
|
+
].join('\n');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// The index is derived — the hint is to read it, or rebuild it the one way that can be committed.
|
|
290
|
+
export function indexDenyMessage({ rel, productRoot }) {
|
|
291
|
+
return [
|
|
292
|
+
`[yad] Blocked: ${rel} is the Product index — derived from every work item's files, and CI-owned here.`,
|
|
293
|
+
'',
|
|
294
|
+
'This Product runs in verified mode. CI rebuilds the index in the same commit that records a merged',
|
|
295
|
+
'review, and the `ledger-guard` check rejects any other commit that changes it.',
|
|
296
|
+
'',
|
|
297
|
+
'To read it as the files say it is now: yad index --json',
|
|
298
|
+
'To see whether the committed one is behind: yad doctor',
|
|
299
|
+
'',
|
|
300
|
+
`hub: ${productRoot} · override for one command: YAD_HOOK_DISABLE=1`,
|
|
164
301
|
].join('\n');
|
|
165
302
|
}
|
|
166
303
|
|
|
167
304
|
// The decision, with git injectable so the tests can drive every branch. Returns
|
|
168
305
|
// `{ allow: true }` or `{ allow: false, message, epic, rel }`.
|
|
169
|
-
export function ledgerGuardDecision(paths, { env = process.env, runner = run } = {}) {
|
|
306
|
+
export function ledgerGuardDecision(paths, { env = process.env, runner = run, payloadCwd = null } = {}) {
|
|
170
307
|
if (env.YAD_HOOK_DISABLE) return { allow: true, skipped: 'YAD_HOOK_DISABLE' };
|
|
171
308
|
if (!paths.length) return { allow: true };
|
|
172
|
-
const base = baseDirFor(env, runner);
|
|
173
|
-
// One `ls-tree` per
|
|
309
|
+
const base = baseDirFor(env, runner, payloadCwd);
|
|
310
|
+
// One `ls-tree` per Product, not one per candidate path: a MultiEdit carries many paths and this runs
|
|
174
311
|
// inside the agent's tool loop.
|
|
175
312
|
const seededByHub = new Map();
|
|
176
313
|
for (const candidate of paths) {
|
|
177
314
|
const abs = path.resolve(base, candidate);
|
|
178
|
-
const
|
|
179
|
-
if (!
|
|
315
|
+
const productRoot = hubRootFor(abs);
|
|
316
|
+
if (!productRoot) continue;
|
|
180
317
|
// Non-strict on purpose: a hub.json that does not parse is a real problem, but refusing every
|
|
181
318
|
// edit in the repo is not this hook's way of reporting it (`yad doctor` says so properly).
|
|
182
|
-
const hub = readJSON(
|
|
183
|
-
if (!
|
|
184
|
-
const rel = path.relative(
|
|
319
|
+
const hub = readJSON(productConfigPath(productRoot), null);
|
|
320
|
+
if (!isVerifiedLedger(hub)) continue;
|
|
321
|
+
const rel = path.relative(productRoot, abs).split(path.sep).join('/');
|
|
185
322
|
const hit = protectedLedgerPath(rel);
|
|
186
323
|
if (!hit) continue;
|
|
187
|
-
if (
|
|
188
|
-
|
|
324
|
+
if (hit.kind === 'index') return { allow: false, epic: null, rel, message: indexDenyMessage({ rel, productRoot }) };
|
|
325
|
+
if (!seededByHub.has(productRoot)) seededByHub.set(productRoot, seededSlugs(productRoot, hub, runner));
|
|
326
|
+
const seeded = seededByHub.get(productRoot);
|
|
189
327
|
if (seeded === null) continue; // base unreadable — unknown allows
|
|
190
328
|
if (!seeded.has(fold(hit.epic))) continue; // creation, not mutation (#162)
|
|
191
|
-
return { allow: false, epic: hit.epic, rel, message: denyMessage({ epic: hit.epic, rel,
|
|
329
|
+
return { allow: false, epic: hit.epic, rel, message: denyMessage({ epic: hit.epic, rel, productRoot }) };
|
|
192
330
|
}
|
|
193
331
|
return { allow: true };
|
|
194
332
|
}
|
|
@@ -207,16 +345,89 @@ function readPayload() {
|
|
|
207
345
|
}
|
|
208
346
|
}
|
|
209
347
|
|
|
348
|
+
// HOW A HARNESS IS TOLD THE VERDICT. Two protocols, because the second harness does not accept the
|
|
349
|
+
// first (E11).
|
|
350
|
+
//
|
|
351
|
+
// `exit` is the original and the default: exit 0 allows, exit 2 denies, the reason goes to stderr for
|
|
352
|
+
// the model to read. Claude Code reads exactly that.
|
|
353
|
+
//
|
|
354
|
+
// `cursor` is for a harness whose PERMISSION hooks are answered in JSON on stdout. Cursor's docs are
|
|
355
|
+
// explicit that for a permission hook — and `preToolUse` is one — "invalid JSON or a response that
|
|
356
|
+
// doesn't match the hook's schema blocks the action". Printing nothing, which is what `exit` does on
|
|
357
|
+
// an allow, is invalid JSON. So the exit protocol under Cursor would have blocked EVERY file write in
|
|
358
|
+
// a verified project: fail-CLOSED on everything, the one outcome this guard's whole design forbids.
|
|
359
|
+
//
|
|
360
|
+
// WHAT THE REASON IS FOR, since three decisions below turn on it. A block alone is what CI already
|
|
361
|
+
// delivers, twenty minutes later. The only thing this hook adds is the sentence naming `yad gate
|
|
362
|
+
// open` at the moment of the edit — so a choice that keeps the block but loses the sentence has given
|
|
363
|
+
// up everything the hook was built for, while still passing any test that asks whether a deny denies.
|
|
364
|
+
//
|
|
365
|
+
// 1. THE FIELD NAMES are Cursor's documented permission-hook schema: `permission`, `user_message`,
|
|
366
|
+
// `agent_message`, all snake_case. An off-schema response still BLOCKS, so a camelCase field would
|
|
367
|
+
// refuse the write and discard the sentence, silently.
|
|
368
|
+
// 2. A DENY EXITS 0, not 2. The JSON is the authoritative answer and exit 0 is what tells Cursor to
|
|
369
|
+
// read it; exit 2 blocks too, but is documented as the code for "no JSON to read". The reason also
|
|
370
|
+
// goes to stderr, where Cursor logs it, so it is never only in a channel we cannot confirm.
|
|
371
|
+
// 3. THE ALLOW RESPONSE is a fixed literal with nothing interpolated into it, because a malformed
|
|
372
|
+
// allow is a block. The deny response does carry text, and if a field there were ever rejected as
|
|
373
|
+
// off-schema the response is invalid — which blocks, which is what a deny wanted anyway. Both
|
|
374
|
+
// failure directions are safe, in opposite ways, on purpose.
|
|
375
|
+
export const HOOK_FORMATS = ['exit', 'cursor'];
|
|
376
|
+
const CURSOR_ALLOW = '{"permission":"allow"}';
|
|
377
|
+
// `user_message` is what the person sees in the client, `agent_message` what the model reads. The
|
|
378
|
+
// same text serves both: the person needs to know why their agent stopped, and the reason already
|
|
379
|
+
// names the command rather than just refusing.
|
|
380
|
+
const cursorDeny = (message) => JSON.stringify({ permission: 'deny', user_message: message, agent_message: message });
|
|
381
|
+
|
|
210
382
|
// The `yad hook ledger-guard` entry point. `paths` (from `--path`) is additive to the payload, so
|
|
211
383
|
// a harness with no JSON contract can call the guard directly.
|
|
212
|
-
|
|
384
|
+
// Tool names that cannot write a file, in either harness's vocabulary. A call naming one of these is
|
|
385
|
+
// allowed without being judged.
|
|
386
|
+
//
|
|
387
|
+
// AN ALLOWLIST OF READS, never a check that the tool IS a write, and the asymmetry is the point: a
|
|
388
|
+
// harness this code has never heard of sends a tool name nobody here recognises, and that call must
|
|
389
|
+
// still be judged. Recognising only known writers would wave it straight through.
|
|
390
|
+
//
|
|
391
|
+
// It exists because the write/read distinction otherwise lives entirely in the `matcher` inside the
|
|
392
|
+
// team's settings file — which the merge deliberately refuses to correct, and which both harnesses
|
|
393
|
+
// document `""` and `*` as widening to everything. A team that widens it gets a refusal on every READ
|
|
394
|
+
// of `state.json`, which `yad status` and the review-gate skill do routinely, and which an agent has
|
|
395
|
+
// to do to obey the deny message it was just handed. The code's own stance is that a false deny is
|
|
396
|
+
// far worse than a miss.
|
|
397
|
+
const READ_ONLY_TOOLS = new Set(['read', 'grep', 'glob', 'search', 'list', 'ls', 'webfetch', 'websearch']);
|
|
398
|
+
const isReadOnlyCall = (payload) => {
|
|
399
|
+
const name = payload?.tool_name;
|
|
400
|
+
return typeof name === 'string' && READ_ONLY_TOOLS.has(name.toLowerCase());
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
export function runLedgerGuardHook({ paths = [], format = 'exit' } = {}) {
|
|
213
404
|
// Only read stdin when there is nothing else to go on. `readFileSync(0)` blocks until EOF, and a
|
|
214
405
|
// caller that passes `--path` (the documented stdin-free alternative) may well have inherited an
|
|
215
406
|
// open pipe from a long-lived parent — `isTTY` is false there, so the TTY guard does not trip and
|
|
216
407
|
// the hook would hang forever. A hang is strictly worse for an agent than a block.
|
|
217
408
|
const payload = paths.length ? null : readPayload();
|
|
218
|
-
const all = [...new Set([...payloadPaths(payload), ...paths])];
|
|
219
|
-
const verdict = ledgerGuardDecision(all
|
|
409
|
+
const all = isReadOnlyCall(payload) ? [...paths] : [...new Set([...payloadPaths(payload), ...paths])];
|
|
410
|
+
const verdict = ledgerGuardDecision(all, {
|
|
411
|
+
// `cwd` is what the harness says this tool call ran in, and it is what a relative path in the same
|
|
412
|
+
// payload is relative to. It was already being parsed and thrown away.
|
|
413
|
+
payloadCwd: typeof payload?.cwd === 'string' ? payload.cwd : null,
|
|
414
|
+
});
|
|
415
|
+
// An unknown format is treated as `exit` rather than refused: this runs inside an agent's tool loop,
|
|
416
|
+
// and a usage error there would be a non-zero exit on every single call. It is SAID on stderr
|
|
417
|
+
// though, because the fallback is not harmless everywhere — a harness that needed `cursor` and got
|
|
418
|
+
// `exit` sees an empty stdout on an allow and blocks the write, with nothing else to explain it.
|
|
419
|
+
if (!HOOK_FORMATS.includes(format)) {
|
|
420
|
+
note(`yad hook: unknown --format '${format}' — using '${HOOK_FORMATS[0]}' (known: ${HOOK_FORMATS.join(', ')})`);
|
|
421
|
+
}
|
|
422
|
+
if (format === 'cursor') {
|
|
423
|
+
if (verdict.allow) {
|
|
424
|
+
process.stdout.write(`${CURSOR_ALLOW}\n`);
|
|
425
|
+
return 0;
|
|
426
|
+
}
|
|
427
|
+
process.stdout.write(`${cursorDeny(verdict.message)}\n`);
|
|
428
|
+
console.error(verdict.message);
|
|
429
|
+
return 0;
|
|
430
|
+
}
|
|
220
431
|
if (verdict.allow) return 0;
|
|
221
432
|
console.error(verdict.message);
|
|
222
433
|
process.exitCode = 2;
|
package/cli/hubcommit.mjs
CHANGED
|
@@ -1,34 +1,23 @@
|
|
|
1
|
-
// Shared machinery for committing the machine-written
|
|
1
|
+
// Shared machinery for committing the machine-written Build ledgers to the Product — used by both
|
|
2
2
|
// `yad checkpoint` (sync new state) and `yad tidy up` (fold finished shards). Both must commit ONLY on
|
|
3
3
|
// the default branch, so their `[skip ci]` commit never enters a PR's base..HEAD range (where it would
|
|
4
4
|
// strand required checks and fail verified-commits). This module is the single home of that guard.
|
|
5
|
-
import fs from 'node:fs';
|
|
6
|
-
import path from 'node:path';
|
|
7
5
|
import { warn, fail, hand, run } from './lib.mjs';
|
|
8
6
|
|
|
9
|
-
export const
|
|
10
|
-
|
|
11
|
-
const readFileSafe = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } };
|
|
7
|
+
export const productGit = (root) => (...args) => run('git', args, { cwd: root });
|
|
12
8
|
|
|
13
9
|
// A direct-to-default push (`yad update --push`, `yad repo refresh --push`) will be rejected by the
|
|
14
|
-
// yad-update-guard unless the commits are signed
|
|
15
|
-
//
|
|
16
|
-
//
|
|
10
|
+
// yad-update-guard unless the commits are signed. Warn up front (never block) so the operator isn't
|
|
11
|
+
// surprised by a reddened default branch. There is no author allowlist to check since E62: write access
|
|
12
|
+
// decides who can author.
|
|
17
13
|
export function preflightGuardReadiness(root) {
|
|
18
|
-
const gitcfg = (k) => run('git', ['config', '--get', k], { cwd: root }).stdout;
|
|
19
14
|
// Only commit.gpgsign actually enables signing — user.signingkey merely picks WHICH key once
|
|
20
15
|
// signing is on, so it must not count (it would hide the warning while commits stay unsigned).
|
|
21
16
|
const signing = run('git', ['config', '--bool', '--get', 'commit.gpgsign'], { cwd: root }).stdout === 'true';
|
|
22
17
|
if (!signing) warn('commit signing is not enabled (git config commit.gpgsign true) — the yad-update-guard requires a platform-Verified signature; unsigned pushes will fail the gate.');
|
|
23
|
-
const email = gitcfg('user.email').toLowerCase();
|
|
24
|
-
const allow = readFileSafe(path.join(root, '.sdlc', 'verified-authors'));
|
|
25
|
-
const known = allow.split('\n').map((l) => l.trim().toLowerCase()).filter((l) => l && !l.startsWith('#'));
|
|
26
|
-
if (known.length && email && !known.includes(email)) {
|
|
27
|
-
warn(`your git email <${email}> is not in .sdlc/verified-authors — the yad-update-guard will reject these commits (add it to the hub roster and re-run \`yad check --fix\`).`);
|
|
28
|
-
}
|
|
29
18
|
}
|
|
30
19
|
|
|
31
|
-
// The default branch:
|
|
20
|
+
// The default branch: Product config, else the remote's published default (origin/HEAD), else 'main'.
|
|
32
21
|
// NEVER the current branch — falling back to it (as gate.mjs does, safe there because CI checks out the
|
|
33
22
|
// default branch) would make the guard below a no-op on a WIP branch and let an unsigned commit land in
|
|
34
23
|
// a future PR's range.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// `yad index` — rebuild `.sdlc/index.json`, the Product's one-file front door (E19), on demand.
|
|
2
|
+
//
|
|
3
|
+
// yad index rebuild and write it — the default branch only, on a local Product. There is
|
|
4
|
+
// no override: a branch never carries the index (E19 review — `--allow-branch`
|
|
5
|
+
// was the one way round the user's decision, and `gate repair` already keeps
|
|
6
|
+
// the index off a branch even when it allows one)
|
|
7
|
+
// yad index --json print what it holds, built live from the files; writes nothing, any branch
|
|
8
|
+
//
|
|
9
|
+
// WHO WRITES IT (the user's decision): the default branch only, so a branch never carries a rewrite of a
|
|
10
|
+
// file every other branch rewrites too. On a VERIFIED Product the ledger is CI's alone (rule 9): CI
|
|
11
|
+
// rebuilds the index when it records a merge, and a local write could not be committed past the ledger
|
|
12
|
+
// guard — so this command writes nothing there and says so, the way a local `gate open` does.
|
|
13
|
+
// It never commits: the index rides the commit that carries the change, like the ledger it summarizes.
|
|
14
|
+
import { c, log, ok, info, warn, fail, hand, exists, emitJSON } from './lib.mjs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { productConfigPath, isVerifiedLedger, SCHEMA_VERSION } from './manifest.mjs';
|
|
17
|
+
import { loadProduct } from './gate.mjs';
|
|
18
|
+
import { productGit, resolveDefaultBranch } from './hubcommit.mjs';
|
|
19
|
+
import { buildIndex, writeIndex, indexFreshness, INDEX_FILE } from './product-index.mjs';
|
|
20
|
+
import { printable } from './epic-state.mjs';
|
|
21
|
+
|
|
22
|
+
export async function runIndex(root, { json = false } = {}) {
|
|
23
|
+
if (!exists(productConfigPath(root))) {
|
|
24
|
+
fail('no Product here (.sdlc/product.json) — run `yad index` from the Product');
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
let built;
|
|
29
|
+
try {
|
|
30
|
+
built = buildIndex(root);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
fail(`the epics folder could not be listed (${e.code || e.message}), so there is no honest index to build`);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// A read: the index as the files say it is right now, on whatever branch this is. Nothing is written,
|
|
37
|
+
// so no branch or ledger rule applies.
|
|
38
|
+
// The same keys as the file, `schemaVersion` first as `writeJSON` stamps it, inside E1's envelope:
|
|
39
|
+
// this answer IS a file, so it keeps the file's shape number beside the contract's own.
|
|
40
|
+
if (json) return emitJSON({ schemaVersion: SCHEMA_VERSION, ...built.index });
|
|
41
|
+
|
|
42
|
+
log(c.bold('\nyad index'));
|
|
43
|
+
const { hub } = loadProduct(root);
|
|
44
|
+
const items = built.index.items;
|
|
45
|
+
const unreadable = items.filter((i) => i.unreadable);
|
|
46
|
+
const summary = `${items.length} work item${items.length === 1 ? '' : 's'}${unreadable.length ? `, ${unreadable.length} unreadable` : ''}`;
|
|
47
|
+
if (isVerifiedLedger(hub)) {
|
|
48
|
+
const fresh = indexFreshness(root, built);
|
|
49
|
+
info(`this Product's ledger is verified: CI rebuilds ${INDEX_FILE} when it records a merge on the default branch, and a local write could not be committed — nothing written`);
|
|
50
|
+
if (fresh.state === 'current') ok(`${INDEX_FILE} is current (${summary})`);
|
|
51
|
+
else warn(`${INDEX_FILE} is ${fresh.state === 'missing' ? 'not built yet' : fresh.state === 'unreadable' ? `unreadable — ${fresh.why}` : 'behind the work items'}; the next review CI records rebuilds it`);
|
|
52
|
+
reportUnreadable(built.index);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!exists(path.join(root, '.git'))) {
|
|
56
|
+
fail('not a git repo, so yad cannot tell whether this is the default branch — the index is written there only');
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const git = productGit(root);
|
|
61
|
+
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
62
|
+
const defaultBranch = resolveDefaultBranch(git, hub);
|
|
63
|
+
if (branch !== defaultBranch) {
|
|
64
|
+
fail(`on '${branch}', not the default branch '${defaultBranch}' — ${INDEX_FILE} is written on the default branch only, so a branch never carries it`);
|
|
65
|
+
hand(`switch to '${defaultBranch}' and re-run (set default_branch in .sdlc/product.json if '${defaultBranch}' is wrong); \`yad index --json\` prints it here without writing`);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
let changed;
|
|
70
|
+
try {
|
|
71
|
+
changed = writeIndex(root, built);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
fail(`${INDEX_FILE} could not be written (${e.code || e.message})`);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (changed) ok(`wrote ${INDEX_FILE} (${summary}) — commit it with the change it describes`);
|
|
78
|
+
else ok(`${INDEX_FILE} is already current (${summary})`);
|
|
79
|
+
reportUnreadable(built.index);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Every item the index could not read is said, with the reason: it is in the file, and it is on screen.
|
|
83
|
+
function reportUnreadable(index) {
|
|
84
|
+
for (const i of index.items.filter((x) => x.unreadable)) warn(`${printable(i.dir)}: listed as unreadable — ${printable(i.why)}`);
|
|
85
|
+
for (const d of index.unlisted || []) warn(`${printable(d)} holds a ledger that is not read as a work item — listed as unlisted`);
|
|
86
|
+
if (index.items.some((x) => x.unreadable) || index.unlisted) hand('fix or restore the files named above, then run `yad index` again');
|
|
87
|
+
}
|