yadflow 4.0.0-next.3 → 4.0.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 +699 -0
- package/README.md +85 -22
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +183 -29
- package/cli/capture.mjs +4 -2
- package/cli/checkpoint.mjs +10 -10
- package/cli/claims.mjs +4 -4
- package/cli/commit.mjs +48 -12
- package/cli/docs.mjs +15 -13
- package/cli/doctor.mjs +557 -141
- package/cli/epic-state.mjs +41 -18
- package/cli/errors.mjs +3 -2
- package/cli/find-product.mjs +197 -0
- package/cli/fold.mjs +4 -4
- package/cli/gate-local.mjs +13 -13
- package/cli/gate.mjs +111 -103
- package/cli/history.mjs +13 -13
- package/cli/hook.mjs +201 -30
- package/cli/index-command.mjs +6 -6
- package/cli/lib.mjs +91 -29
- package/cli/manifest.mjs +171 -104
- package/cli/migrate.mjs +208 -17
- package/cli/mode.mjs +22 -22
- package/cli/next.mjs +6 -6
- package/cli/openpr.mjs +32 -24
- package/cli/people.mjs +22 -10
- package/cli/plan.mjs +718 -45
- package/cli/platform.mjs +13 -7
- package/cli/product-index.mjs +4 -4
- package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
- package/cli/protection.mjs +19 -9
- package/cli/reconcile.mjs +98 -23
- package/cli/repo-publish.mjs +22 -16
- package/cli/repo.mjs +128 -33
- package/cli/report.mjs +4 -2
- package/cli/review.mjs +26 -19
- package/cli/setup.mjs +105 -36
- package/cli/ship.mjs +1 -1
- package/cli/skip.mjs +4 -4
- package/cli/tidy.mjs +7 -7
- package/cli/update-commit.mjs +22 -19
- package/cli/workspace.mjs +495 -0
- package/package.json +7 -4
- package/skills/sdlc/config.yaml +14 -13
- package/skills/sdlc/module-help.csv +8 -8
- package/skills/yad-analysis/SKILL.md +6 -4
- package/skills/yad-architecture/SKILL.md +4 -3
- package/skills/yad-backfill/SKILL.md +11 -5
- package/skills/yad-backfill/references/backfill.md +33 -3
- package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-checks/SKILL.md +48 -27
- package/skills/yad-checks/references/check-gates.md +213 -53
- package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
- package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
- package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
- package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
- package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
- package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
- package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
- package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
- package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
- package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
- package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
- package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
- package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
- package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
- package/skills/yad-commit/SKILL.md +18 -7
- package/skills/yad-connect-design/SKILL.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +1 -1
- package/skills/yad-connect-docs/SKILL.md +16 -11
- package/skills/yad-connect-docs/references/docs-registry.md +8 -8
- package/skills/yad-connect-learning/SKILL.md +1 -1
- package/skills/yad-connect-learning/references/learning-registry.md +1 -1
- package/skills/yad-connect-repos/SKILL.md +31 -8
- package/skills/yad-connect-repos/references/code-context.md +1 -1
- package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
- package/skills/yad-connect-repos/references/repos-registry.md +1 -1
- package/skills/yad-connect-testing/SKILL.md +1 -1
- package/skills/yad-connect-testing/references/testing-registry.md +1 -1
- package/skills/yad-discovery/SKILL.md +2 -2
- package/skills/yad-discovery/references/discovery-schema.md +1 -1
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-docs/references/data-mapping.md +1 -1
- package/skills/yad-docs/templates/app/package-lock.json +8 -8
- package/skills/yad-docs/templates/app/package.json +1 -1
- package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
- package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
- package/skills/yad-engineer-review/SKILL.md +2 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
- package/skills/yad-epic/SKILL.md +6 -4
- package/skills/yad-epic/references/state-schema.md +13 -12
- package/skills/yad-open-pr/SKILL.md +1 -1
- package/skills/yad-pair-review/SKILL.md +5 -5
- package/skills/yad-pair-review/references/session-state.md +2 -2
- package/skills/yad-pr-template/SKILL.md +14 -12
- package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
- package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
- package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
- package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
- package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
- package/skills/yad-report/SKILL.md +1 -1
- package/skills/yad-review-companion/SKILL.md +3 -3
- package/skills/yad-review-gate/SKILL.md +22 -23
- package/skills/yad-review-gate/references/gating.md +13 -13
- package/skills/yad-run/SKILL.md +2 -2
- package/skills/yad-run/references/run-loop.md +2 -2
- package/skills/yad-spec/SKILL.md +2 -1
- package/skills/yad-spec/references/spec-handoff.md +19 -10
- package/skills/yad-stories/SKILL.md +2 -2
- package/skills/yad-sync-repos/SKILL.md +19 -3
- package/skills/yad-test-cases/SKILL.md +2 -2
- package/skills/yad-ui/SKILL.md +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
- package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
package/cli/history.mjs
CHANGED
|
@@ -204,13 +204,13 @@ export function readState(root, id) {
|
|
|
204
204
|
return Array.isArray(r.value?.steps) ? { state: r.value, steps: r.value.steps } : { why: 'it has no list of steps' };
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
// The Product's settings, strictly: {
|
|
207
|
+
// The Product's settings, strictly: { productConfig } or { why }. A file that does not parse is said, never read
|
|
208
208
|
// as "team mode, no engagement rule" — that would print a confident count the gate itself refuses.
|
|
209
|
-
function
|
|
209
|
+
function readProductConfig(root) {
|
|
210
210
|
const r = readJsonFile(productConfigPath(root));
|
|
211
|
-
if (r.missing) return {
|
|
211
|
+
if (r.missing) return { productConfig: null };
|
|
212
212
|
if (r.why) return { why: r.why };
|
|
213
|
-
return isPlainObject(r.value) ? {
|
|
213
|
+
return isPlainObject(r.value) ? { productConfig: r.value } : { why: 'it is not an object' };
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
// Why a recorded approval does not count, in the order the gate asks — or null when it counts:
|
|
@@ -227,15 +227,15 @@ function notCountedReason(a, { approved, stale, named, reqEng }) {
|
|
|
227
227
|
const NOT_JUDGED = { stale: null, counted: null, notCounted: null };
|
|
228
228
|
|
|
229
229
|
// The whole story of one item, for `show`. Throws nothing: every part that cannot be read says why.
|
|
230
|
-
// `
|
|
231
|
-
export function itemHistory(root, item, {
|
|
230
|
+
// `productConfig` / `productConfigWhy`: the Product's settings, or why they could not be read.
|
|
231
|
+
export function itemHistory(root, item, { productConfig = null, productConfigWhy = null } = {}) {
|
|
232
232
|
const epicDir = epicRoot(root, item.id);
|
|
233
233
|
const stateRead = readState(root, item.id);
|
|
234
234
|
const approvalsRead = readJsonFile(path.join(epicDir, '.sdlc', 'approvals.json'));
|
|
235
235
|
const approvals = approvalsRead.missing ? [] : Array.isArray(approvalsRead.value) ? approvalsRead.value : null;
|
|
236
236
|
const approvalsWhy = approvals ? null : approvalsRead.why || 'it is not a list';
|
|
237
|
-
const reqEng = requireEngagement(
|
|
238
|
-
const solo = isSolo(
|
|
237
|
+
const reqEng = requireEngagement(productConfig);
|
|
238
|
+
const solo = isSolo(productConfig);
|
|
239
239
|
// The steps THIS item's route lets be skipped — what `gatePredicate` asks before it honours a skip.
|
|
240
240
|
const optional = stateRead.state ? optionalStepsFor(stateRead.state) : [];
|
|
241
241
|
const out = [];
|
|
@@ -302,7 +302,7 @@ export function itemHistory(root, item, { hub = null, hubWhy = null } = {}) {
|
|
|
302
302
|
};
|
|
303
303
|
if (notJudged) return { ...fields, ...NOT_JUDGED };
|
|
304
304
|
const stale = !approved ? null : accepted === null ? null : isStaleHash(a.artifactHash, accepted);
|
|
305
|
-
if (approved && (stale === null || solo ||
|
|
305
|
+
if (approved && (stale === null || solo || productConfigWhy)) return { ...fields, stale, counted: null, notCounted: null };
|
|
306
306
|
// Named by the gate's own test (`gatePredicate`): any text that is not blank, even text with
|
|
307
307
|
// nothing printable in it — the gate counts that record, so this must too.
|
|
308
308
|
const named = typeof a.approver === 'string' && a.approver.trim() !== '';
|
|
@@ -327,7 +327,7 @@ export function itemHistory(root, item, { hub = null, hubWhy = null } = {}) {
|
|
|
327
327
|
steps: stateRead.steps ? out : null,
|
|
328
328
|
stepsWhy: stateRead.steps ? null : stateRead.why,
|
|
329
329
|
approvalsWhy,
|
|
330
|
-
|
|
330
|
+
productConfigWhy,
|
|
331
331
|
};
|
|
332
332
|
}
|
|
333
333
|
|
|
@@ -451,8 +451,8 @@ function showCmd(root, read, id, { json }) {
|
|
|
451
451
|
if (bad) return refuse(json, `${id} could not be read — ${printable(bad.why)}`, 'fix or restore its files — `yad doctor` checks them');
|
|
452
452
|
const item = read.items.find((i) => i.id === id);
|
|
453
453
|
if (!item) return refuse(json, `no work item ${id} in this Product`, '`yad history` lists the work items');
|
|
454
|
-
const
|
|
455
|
-
const story = itemHistory(root, item, {
|
|
454
|
+
const productConfigRead = readProductConfig(root);
|
|
455
|
+
const story = itemHistory(root, item, { productConfig: productConfigRead.productConfig ?? null, productConfigWhy: productConfigRead.why ?? null });
|
|
456
456
|
// Steps that cannot be read are a failure in both forms: a script reading `--json` must see it too.
|
|
457
457
|
if (!story.steps) process.exitCode = 1;
|
|
458
458
|
if (json) {
|
|
@@ -474,7 +474,7 @@ function showCmd(root, read, id, { json }) {
|
|
|
474
474
|
['shape', isShapeDone(item) ? 'done (Build is not shown here)' : null],
|
|
475
475
|
].filter(([, v]) => v);
|
|
476
476
|
for (const [k, v] of facts) log(` ${c.dim(`${k}:`)} ${v}`);
|
|
477
|
-
if (story.
|
|
477
|
+
if (story.productConfigWhy) warn(`the Product's settings could not be read — ${story.productConfigWhy}; whether an approval counts is not told`);
|
|
478
478
|
if (story.approvalsWhy) {
|
|
479
479
|
warn(`.sdlc/approvals.json could not be read — ${story.approvalsWhy}; no approvals are shown`);
|
|
480
480
|
hand('restore it from git — `yad doctor` checks it');
|
package/cli/hook.mjs
CHANGED
|
@@ -11,22 +11,23 @@
|
|
|
11
11
|
// exit 0 allow
|
|
12
12
|
// exit 2 deny — the reason is on stderr, for the agent to read
|
|
13
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.
|
|
14
|
+
// to the model — so `hooks/ledger-guard.mjs` wires it with no adapter logic.
|
|
15
15
|
//
|
|
16
16
|
// Cursor's `preToolUse` is NOT that. It is a permission hook: it wants a JSON verdict on stdout and
|
|
17
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.
|
|
18
|
+
// `--format cursor` below answers in that protocol instead, and `hooks/ledger-guard-cursor.mjs` is
|
|
19
19
|
// what Cursor's entry points at. Check which kind a harness is before wiring a new one.
|
|
20
20
|
//
|
|
21
21
|
// FAIL-OPEN, deliberately. No Product, unreadable config, an unparseable payload, no git — every one of
|
|
22
22
|
// those ALLOWS, with a note on stderr. This is a local guardrail, and one that failed closed would
|
|
23
23
|
// brick an agent's ability to edit anything the moment a config went sideways. The asymmetry is the
|
|
24
24
|
// design: `ledger-guard` in CI fails closed and is what actually protects the ledger.
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
25
26
|
import fs from 'node:fs';
|
|
26
27
|
import path from 'node:path';
|
|
27
28
|
import { fileURLToPath } from 'node:url';
|
|
28
29
|
import { note, readJSON, run } from './lib.mjs';
|
|
29
|
-
import { isVerifiedLedger , productConfigPath, HOOK_PROJECT_DIR_ENVS } from './manifest.mjs';
|
|
30
|
+
import { isVerifiedLedger, mirrorDrift, productConfigPath, HOOK_PROJECT_DIR_ENVS, PROJECT_FILES } from './manifest.mjs';
|
|
30
31
|
import { DISCOVERY_EPIC, FOUNDATION_DIR, FOUNDATION_EPIC } from './epic-state.mjs';
|
|
31
32
|
|
|
32
33
|
// The CI-owned files, exactly as `templates/checks/ledger-guard.sh` lists them. NOT `contract-lock.json`
|
|
@@ -155,14 +156,14 @@ export function payloadPaths(payload) {
|
|
|
155
156
|
return [...new Set(out)];
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
// The Product a path belongs to: the nearest ancestor holding `.sdlc/hub.json`.
|
|
159
|
+
// The Product a path belongs to: the nearest ancestor holding `.sdlc/product.json` or `.sdlc/hub.json`.
|
|
159
160
|
//
|
|
160
161
|
// Resolved from the PATH, never from the session. The documented layout puts code repos BESIDE the
|
|
161
162
|
// Product (`project/{product,backend,mobile}` — see `insideWorkspace` in setup.mjs), so a session opened
|
|
162
163
|
// at the workspace has no `hub.json` under its root, and a session-rooted lookup would find nothing
|
|
163
164
|
// and silently allow a mutation inside `project/product/epics/…` — the multi-repo, parallel-agent
|
|
164
165
|
// setup this hook was reported from.
|
|
165
|
-
export function
|
|
166
|
+
export function productRootFor(abs) {
|
|
166
167
|
let dir = path.dirname(path.resolve(abs));
|
|
167
168
|
for (;;) {
|
|
168
169
|
if (fs.existsSync(productConfigPath(dir))) return dir;
|
|
@@ -196,7 +197,7 @@ export function baseDirFor(env = process.env, runner = run, payloadCwd = null) {
|
|
|
196
197
|
if (typeof payloadCwd === 'string' && payloadCwd) return payloadCwd;
|
|
197
198
|
// `process.cwd()` BEFORE the git toplevel, which is the opposite of the old order and fixes a real
|
|
198
199
|
// miss: the documented layout puts the Product in a subdirectory of its repo, so the toplevel is the
|
|
199
|
-
// repo, `
|
|
200
|
+
// repo, `productRootFor` walks up from there, finds no `hub.json`, and ALLOWS a ledger edit it must
|
|
200
201
|
// refuse. Anchoring too deep still tends to resolve into the Product and deny; anchoring too
|
|
201
202
|
// shallow misses entirely, so the shallower guess goes last.
|
|
202
203
|
if (process.cwd()) return process.cwd();
|
|
@@ -214,8 +215,8 @@ const fold = (s) => s.toLowerCase();
|
|
|
214
215
|
// and `git fetch` never fast-forwards it, so probing `main` would report an epic whose review PR has
|
|
215
216
|
// already merged as absent from the base and wave a real mutation straight through. That is the
|
|
216
217
|
// stale-clone case, and it is the common one, not an edge.
|
|
217
|
-
export function resolveHookBase(productRoot,
|
|
218
|
-
const cfg =
|
|
218
|
+
export function resolveHookBase(productRoot, productConfig, runner = run) {
|
|
219
|
+
const cfg = productConfig?.default_branch || '';
|
|
219
220
|
const head = runner('git', ['-C', productRoot, 'symbolic-ref', '--short', '--quiet', 'refs/remotes/origin/HEAD']);
|
|
220
221
|
for (const base of [cfg ? `origin/${cfg}` : '', head.ok ? head.stdout : '', 'origin/main']) {
|
|
221
222
|
if (!base || base === 'origin/') continue;
|
|
@@ -243,8 +244,8 @@ export function resolveHookBase(productRoot, hub, runner = run) {
|
|
|
243
244
|
// null means the base could not be read at all — "unknown", which ALLOWS. The working tree cannot
|
|
244
245
|
// stand in for the base ref: a seed writes `state.json` first, so using that as proof would deny
|
|
245
246
|
// every remaining file of the same seed.
|
|
246
|
-
export function seededSlugs(productRoot,
|
|
247
|
-
const base = resolveHookBase(productRoot,
|
|
247
|
+
export function seededSlugs(productRoot, productConfig, runner = run) {
|
|
248
|
+
const base = resolveHookBase(productRoot, productConfig, runner);
|
|
248
249
|
if (!base) return null;
|
|
249
250
|
const tree = runner('git', [
|
|
250
251
|
'-C', productRoot, '-c', 'core.quotePath=false', 'ls-tree', '-r', '--name-only', '-z', base, '--', 'epics', FOUNDATION_DIR,
|
|
@@ -264,6 +265,35 @@ export function seededSlugs(productRoot, hub, runner = run) {
|
|
|
264
265
|
}
|
|
265
266
|
|
|
266
267
|
|
|
268
|
+
// The door, as the AGENT is told it (E49). Committing past the guard is a person's decision, so the
|
|
269
|
+
// agent is told the command exists and that it is not the agent's to run. The commit refusal names the
|
|
270
|
+
// same command to the person (`commitDenyMessage`) — change the two together.
|
|
271
|
+
const PERSON_DOOR = [
|
|
272
|
+
'If a person decides this change must land anyway, they commit it with',
|
|
273
|
+
'`yad commit --manual --reason "<why>"` — their call, not the agent\'s. The check on the pull request',
|
|
274
|
+
'still fails it.',
|
|
275
|
+
'',
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
// The settings a guard decision reads. Non-strict on purpose: a hub.json that does not parse is a real
|
|
279
|
+
// problem, but refusing every edit in the repo is not this hook's way of reporting it (`yad doctor`
|
|
280
|
+
// says so properly).
|
|
281
|
+
//
|
|
282
|
+
// E122: when `product.json` and `hub.json` say different things, every other command refuses; the hook
|
|
283
|
+
// cannot — it runs inside the agent's tool loop — so it GUARDS: the Product is verified when EITHER copy
|
|
284
|
+
// says so. A drift must never disarm the guard; the worst it can do is refuse an edit a local Product
|
|
285
|
+
// would have allowed, and the message then says why and what ends it.
|
|
286
|
+
export function guardConfig(productRoot) {
|
|
287
|
+
const product = path.join(productRoot, PROJECT_FILES.productConfig);
|
|
288
|
+
const legacy = path.join(productRoot, PROJECT_FILES.productConfigLegacy);
|
|
289
|
+
if (!mirrorDrift(product, legacy)) return { productConfig: readJSON(productConfigPath(productRoot), null), drift: false };
|
|
290
|
+
const both = [readJSON(product, null), readJSON(legacy, null)];
|
|
291
|
+
return { productConfig: both.find(isVerifiedLedger) ?? both[0], drift: true };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const driftLine = (productRoot) =>
|
|
295
|
+
`note: ${path.join(productRoot, PROJECT_FILES.productConfig)} and ${PROJECT_FILES.productConfigLegacy} say different things, and one of them is verified, so this edit is guarded — run \`yad migrate\` to choose the copy to keep`;
|
|
296
|
+
|
|
267
297
|
// What the agent is told when the edit is refused. Names the command that owns each transition —
|
|
268
298
|
// the whole point of #171 was that the ledger write had no command behind it.
|
|
269
299
|
export function denyMessage({ epic, rel, productRoot }) {
|
|
@@ -271,18 +301,19 @@ export function denyMessage({ epic, rel, productRoot }) {
|
|
|
271
301
|
`[yad] Blocked: ${rel} is CI-owned gate state.`,
|
|
272
302
|
'',
|
|
273
303
|
'This Product runs in verified mode, where CI is the sole writer of the gate ledger. The `ledger-guard`',
|
|
274
|
-
'check rejects any non-bot commit that changes it, so this edit
|
|
275
|
-
'
|
|
304
|
+
'check on the review PR/MR rejects any non-bot commit that changes it, so this edit would fail that',
|
|
305
|
+
'check and have to be reverted.',
|
|
276
306
|
'',
|
|
277
307
|
'Use the command that owns the transition instead:',
|
|
278
308
|
` author step done → review opened yad gate open ${epic} <artifact>`,
|
|
279
309
|
' the full advance at merge CI runs `yad gate ci --merged` — nothing to do locally',
|
|
280
|
-
` a genuinely broken ledger yad gate repair ${epic}`,
|
|
310
|
+
` a genuinely broken ledger yad gate repair ${epic} --push`,
|
|
281
311
|
'',
|
|
282
312
|
'Commit the ARTIFACT only (the .md you authored) and hand off to `yad-review-gate`; the ledger',
|
|
283
313
|
'follows on merge.',
|
|
284
314
|
'',
|
|
285
|
-
|
|
315
|
+
...PERSON_DOOR,
|
|
316
|
+
`product: ${productRoot} · a person can skip this hook for one command: YAD_HOOK_DISABLE=1`,
|
|
286
317
|
].join('\n');
|
|
287
318
|
}
|
|
288
319
|
|
|
@@ -297,38 +328,178 @@ export function indexDenyMessage({ rel, productRoot }) {
|
|
|
297
328
|
'To read it as the files say it is now: yad index --json',
|
|
298
329
|
'To see whether the committed one is behind: yad doctor',
|
|
299
330
|
'',
|
|
300
|
-
|
|
331
|
+
...PERSON_DOOR,
|
|
332
|
+
`product: ${productRoot} · a person can skip this hook for one command: YAD_HOOK_DISABLE=1`,
|
|
301
333
|
].join('\n');
|
|
302
334
|
}
|
|
303
335
|
|
|
336
|
+
// A Windows path some harnesses send in URL form: `/c:/Users/…`. Cursor has been reported sending exactly
|
|
337
|
+
// that, and `path.resolve` reads it as `C:\c:\Users\…` — a path under no Product, so a ledger edit would
|
|
338
|
+
// be allowed. The leading slash before a drive letter is dropped on Windows; everywhere else a path is
|
|
339
|
+
// left as it came (E113).
|
|
340
|
+
export const windowsPath = (p, platform = process.platform) => (platform === 'win32' && /^\/[A-Za-z]:[\\/]/.test(p) ? p.slice(1) : p);
|
|
341
|
+
|
|
304
342
|
// The decision, with git injectable so the tests can drive every branch. Returns
|
|
305
343
|
// `{ allow: true }` or `{ allow: false, message, epic, rel }`.
|
|
306
|
-
|
|
344
|
+
//
|
|
345
|
+
// `all` keeps going after the first refused path and returns every one of them in `hits` (E48). An
|
|
346
|
+
// agent's tool call writes one file, so the first is the answer; a commit carries many, and a message
|
|
347
|
+
// naming only the first would send the person round the refuse-unstage-retry loop once per file.
|
|
348
|
+
// The message is then the caller's to build — `{ allow: false, hits }` with no `message`.
|
|
349
|
+
export function ledgerGuardDecision(paths, { env = process.env, runner = run, payloadCwd = null, all = false } = {}) {
|
|
307
350
|
if (env.YAD_HOOK_DISABLE) return { allow: true, skipped: 'YAD_HOOK_DISABLE' };
|
|
308
351
|
if (!paths.length) return { allow: true };
|
|
309
352
|
const base = baseDirFor(env, runner, payloadCwd);
|
|
310
353
|
// One `ls-tree` per Product, not one per candidate path: a MultiEdit carries many paths and this runs
|
|
311
354
|
// inside the agent's tool loop.
|
|
312
|
-
const
|
|
355
|
+
const seededByProduct = new Map();
|
|
356
|
+
const hits = [];
|
|
313
357
|
for (const candidate of paths) {
|
|
314
|
-
const abs = path.resolve(base, candidate);
|
|
315
|
-
const productRoot =
|
|
358
|
+
const abs = path.resolve(base, windowsPath(candidate));
|
|
359
|
+
const productRoot = productRootFor(abs);
|
|
316
360
|
if (!productRoot) continue;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const hub = readJSON(productConfigPath(productRoot), null);
|
|
320
|
-
if (!isVerifiedLedger(hub)) continue;
|
|
361
|
+
const { productConfig, drift } = guardConfig(productRoot);
|
|
362
|
+
if (!isVerifiedLedger(productConfig)) continue;
|
|
321
363
|
const rel = path.relative(productRoot, abs).split(path.sep).join('/');
|
|
322
364
|
const hit = protectedLedgerPath(rel);
|
|
323
365
|
if (!hit) continue;
|
|
324
|
-
if (hit.kind
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
366
|
+
if (hit.kind !== 'index') {
|
|
367
|
+
if (!seededByProduct.has(productRoot)) seededByProduct.set(productRoot, seededSlugs(productRoot, productConfig, runner));
|
|
368
|
+
const seeded = seededByProduct.get(productRoot);
|
|
369
|
+
if (seeded === null) continue; // base unreadable — unknown allows
|
|
370
|
+
if (!seeded.has(fold(hit.epic))) continue; // creation, not mutation (#162)
|
|
371
|
+
}
|
|
372
|
+
if (!all) {
|
|
373
|
+
const message = hit.kind === 'index' ? indexDenyMessage({ rel, productRoot }) : denyMessage({ epic: hit.epic, rel, productRoot });
|
|
374
|
+
return { allow: false, epic: hit.epic, rel, message: drift ? `${message}\n${driftLine(productRoot)}` : message };
|
|
375
|
+
}
|
|
376
|
+
hits.push({ ...hit, abs, productRoot, drift });
|
|
330
377
|
}
|
|
331
|
-
return { allow: true };
|
|
378
|
+
return hits.length ? { allow: false, hits } : { allow: true };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---- the git pre-commit half (E48) ---------------------------------------------------------------
|
|
382
|
+
//
|
|
383
|
+
// The same rule as the harness hook, asked by git instead of by an agent: `yad hook ledger-guard
|
|
384
|
+
// --staged` reads the files the commit is about to record and refuses the commit when one of them is a
|
|
385
|
+
// CI-owned file the `ledger-guard` check in CI would refuse. It catches a PERSON's hand commit, which the
|
|
386
|
+
// harness hook never sees. Installed per clone by `yad check --fix` as `.git/hooks/pre-commit` (see
|
|
387
|
+
// `gitHookActions` in plan.mjs); verified mode only, exactly like the harness hook.
|
|
388
|
+
//
|
|
389
|
+
// PARITY WITH THE CI CHECK is the whole claim, so each rule below is one the CI script already has:
|
|
390
|
+
// - A MERGE commit passes. CI lists a commit's files with `git diff-tree`, which prints nothing for a
|
|
391
|
+
// merge, so CI never judges one. Without this, a `git pull` that merges the bot's ledger commits
|
|
392
|
+
// into a person's branch would be refused here while CI accepts it.
|
|
393
|
+
// - The yad-gate-sync BOT passes, matched the way CI's `trusted_bot` matches it (the name or email
|
|
394
|
+
// holds `yad-gate-sync`). CI then also checks the platform-Verified signature, which only the
|
|
395
|
+
// platform can answer — a commit that claims the name without it still fails there.
|
|
396
|
+
// - A DELETION counts: CI fails a deleted `state.json` (delete-then-re-seed would reset the carve-out).
|
|
397
|
+
// - A RENAME is two paths, the old and the new (`--no-renames`), as E114 settled for every changed list.
|
|
398
|
+
// Known limit: `git commit --amend` is judged by what it changes against the commit it replaces — git does
|
|
399
|
+
// not tell a pre-commit hook it is an amend. So amending a commit that ALREADY changed the ledger passes
|
|
400
|
+
// here, and amending one to UNDO that change is refused; the message names the way through for the second.
|
|
401
|
+
// The pull request's check judges both correctly.
|
|
402
|
+
//
|
|
403
|
+
// The list is read with `-z` and spawned directly: `run()` trims its output, which would cut a leading
|
|
404
|
+
// space off the first name.
|
|
405
|
+
export const GIT_BOT = 'yad-gate-sync';
|
|
406
|
+
// The environment yad's OWN ledger commits run git with (`yad gate ci` / `gate sync`, `yad gate repair`).
|
|
407
|
+
// They are the commands the refusal names, so the hook must not refuse them — the CI check never judges
|
|
408
|
+
// them either: it runs on pull requests, and these commits land on the default branch.
|
|
409
|
+
export const OWNING_COMMIT_ENV = () => ({ ...process.env, YAD_HOOK_DISABLE: '1' });
|
|
410
|
+
function gitRaw(args, cwd) {
|
|
411
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
|
|
412
|
+
return { ok: r.status === 0, stdout: r.stdout || '' };
|
|
413
|
+
}
|
|
414
|
+
export function stagedLedgerCandidates(cwd = process.cwd(), git = gitRaw) {
|
|
415
|
+
const top = git(['rev-parse', '--show-toplevel'], cwd);
|
|
416
|
+
if (!top.ok) return { paths: [], skipped: 'not inside a git repository' };
|
|
417
|
+
const root = top.stdout.replace(/\r?\n$/, '');
|
|
418
|
+
if (git(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], root).ok) return { paths: [], skipped: 'a merge commit — the CI check does not judge merges either' };
|
|
419
|
+
const ident = git(['var', 'GIT_AUTHOR_IDENT'], root);
|
|
420
|
+
if (ident.ok && ident.stdout.includes(GIT_BOT)) return { paths: [], skipped: `the ${GIT_BOT} bot's commit` };
|
|
421
|
+
const names = git(['-c', 'core.quotePath=false', 'diff', '--cached', '--name-only', '--no-renames', '-z'], root);
|
|
422
|
+
if (!names.ok) return { paths: [], skipped: 'could not list the staged files' };
|
|
423
|
+
return { root, paths: names.stdout.split('\0').filter(Boolean).map((p) => path.join(root, p)) };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// What the person is told when the commit is refused. It names every refused file, how to take them
|
|
427
|
+
// back out of the commit, the command that owns each change, and the door.
|
|
428
|
+
//
|
|
429
|
+
// THE DOOR IS HONEST ABOUT WHAT IT OPENS. `yad commit --manual --reason` (E49 — it sets
|
|
430
|
+
// `YAD_HOOK_DISABLE=1` for its own `git commit` and records the reason as a `Ledger-Override:` trailer),
|
|
431
|
+
// the bare `YAD_HOOK_DISABLE=1`, or git's own `--no-verify` lets the commit through on this machine, and
|
|
432
|
+
// nothing more: the `ledger-guard` check still judges it on the pull request (and only there — a direct
|
|
433
|
+
// push to an unprotected default branch meets no check, which is why the message says "pull request").
|
|
434
|
+
// A door that implied more would be a lie about what protects the ledger.
|
|
435
|
+
export function commitDenyMessage({ hits, top }) {
|
|
436
|
+
const from = (h) => path.relative(top, h.abs).split(path.sep).join('/');
|
|
437
|
+
const files = hits.map(from);
|
|
438
|
+
const epics = [...new Set(hits.filter((h) => h.kind !== 'index').map((h) => h.epic))];
|
|
439
|
+
const epic = epics.length === 1 ? epics[0] : '<epic>';
|
|
440
|
+
const index = hits.some((h) => h.kind === 'index');
|
|
441
|
+
const roots = [...new Set(hits.map((h) => h.productRoot))];
|
|
442
|
+
const quote = (f) => (/^[\w./@+-]+$/.test(f) ? f : `'${f.replace(/'/g, `'\\''`)}'`);
|
|
443
|
+
return [
|
|
444
|
+
`[yad] Commit refused: it changes ${files.length === 1 ? 'a file' : `${files.length} files`} only CI may change on this Product.`,
|
|
445
|
+
...files.map((f) => ` ${f}`),
|
|
446
|
+
'',
|
|
447
|
+
'This Product runs in verified mode, where CI is the sole writer of the gate ledger and the Product',
|
|
448
|
+
'index. The `ledger-guard` check on a pull request rejects any commit that changes those files unless',
|
|
449
|
+
`it is a Verified commit by the ${GIT_BOT} bot, so this commit would fail that check.`,
|
|
450
|
+
'',
|
|
451
|
+
`Take ${files.length === 1 ? 'it' : 'them'} out of this commit (run from the top of the repo):`,
|
|
452
|
+
` git restore --staged -- ${files.map(quote).join(' ')}`,
|
|
453
|
+
`and commit without \`-a\`, and without ${files.length === 1 ? 'that path' : 'those paths'} after \`git commit\`.`,
|
|
454
|
+
'',
|
|
455
|
+
'Then use the command that owns the change:',
|
|
456
|
+
...(epics.length ? [
|
|
457
|
+
` author step done → review opened yad gate open ${epic} <artifact>`,
|
|
458
|
+
' the full advance at merge CI runs `yad gate ci --merged` — nothing to do locally',
|
|
459
|
+
` a genuinely broken ledger yad gate repair ${epic} --push`,
|
|
460
|
+
] : []),
|
|
461
|
+
...(index ? [' the Product index CI rebuilds it at merge; read it with `yad index --json`'] : []),
|
|
462
|
+
'',
|
|
463
|
+
...[...new Set(hits.filter((h) => h.drift).map((h) => h.productRoot))].map((r) => `${driftLine(r)}\n`),
|
|
464
|
+
'Amending a commit to UNDO a ledger change it made? `git commit --amend` is compared with the commit it',
|
|
465
|
+
'replaces, so this hook cannot tell. Run it as `YAD_HOOK_DISABLE=1 git commit --amend`; the check on the',
|
|
466
|
+
'pull request judges the result against its parent.',
|
|
467
|
+
'To commit anyway, with the reason recorded in the commit (it adds a `Ledger-Override:` line):',
|
|
468
|
+
' yad commit --manual --reason "<why>" --type <type> -m "<subject>" (add --force above 3 files)',
|
|
469
|
+
'The check on the pull request still fails it, and quotes the reason.',
|
|
470
|
+
`product: ${roots.join(', ')}`,
|
|
471
|
+
].join('\n');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// The `--staged` entry point, for the git pre-commit hook. It never reads stdin: git gives a
|
|
475
|
+
// pre-commit hook nothing there, and a caller with an open pipe would hang on it (the note at
|
|
476
|
+
// `runLedgerGuardHook`). Exit 0 allows, exit 2 refuses; the wrapper git runs turns only a 2 into a
|
|
477
|
+
// refusal, so a crash here allows (fail-open, as the harness hook does).
|
|
478
|
+
// Git exports GIT_DIR (absolute, in a linked worktree), GIT_WORK_TREE and friends to a hook. The base
|
|
479
|
+
// read runs `git -C <productRoot> ls-tree … -- epics`, and with GIT_DIR set git takes `-C`'s directory
|
|
480
|
+
// as the top of the work tree — so a Product in a subfolder listed nothing, every epic read as a seed,
|
|
481
|
+
// and the mutation was allowed. Git finds the repo from the directory alone, worktrees included.
|
|
482
|
+
const REPO_ENV = /^GIT_(DIR|WORK_TREE|PREFIX|INDEX_FILE|COMMON_DIR|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES)$/;
|
|
483
|
+
export const cleanGitRunner = (env = process.env) => {
|
|
484
|
+
const clean = Object.fromEntries(Object.entries(env).filter(([k]) => !REPO_ENV.test(k)));
|
|
485
|
+
return (cmd, args = [], opts = {}) => run(cmd, args, { ...opts, env: clean });
|
|
486
|
+
};
|
|
487
|
+
// What the hook would refuse in the staged commit: `{ root, hits }`, `hits` empty when it would allow.
|
|
488
|
+
// `yad commit --manual` (E49) asks the same question, so an override is only recorded when there is
|
|
489
|
+
// something to override.
|
|
490
|
+
export function stagedLedgerHits({ env = process.env, cwd = process.cwd(), git = gitRaw, runner = cleanGitRunner(env) } = {}) {
|
|
491
|
+
const staged = stagedLedgerCandidates(cwd, git);
|
|
492
|
+
if (!staged.paths.length) return { root: staged.root, hits: [] };
|
|
493
|
+
const verdict = ledgerGuardDecision(staged.paths, { env, runner, payloadCwd: staged.root, all: true });
|
|
494
|
+
return { root: staged.root, hits: verdict.allow ? [] : verdict.hits };
|
|
495
|
+
}
|
|
496
|
+
export function runStagedLedgerGuard({ env = process.env, cwd = process.cwd(), git = gitRaw, runner = cleanGitRunner(env) } = {}) {
|
|
497
|
+
if (env.YAD_HOOK_DISABLE) return 0;
|
|
498
|
+
const { root, hits } = stagedLedgerHits({ env, cwd, git, runner });
|
|
499
|
+
if (!hits.length) return 0;
|
|
500
|
+
console.error(commitDenyMessage({ hits, top: root }));
|
|
501
|
+
process.exitCode = 2;
|
|
502
|
+
return 2;
|
|
332
503
|
}
|
|
333
504
|
|
|
334
505
|
// Read the harness payload off stdin. Absent, empty, or unparseable all mean "nothing to inspect" —
|
package/cli/index-command.mjs
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
// It never commits: the index rides the commit that carries the change, like the ledger it summarizes.
|
|
14
14
|
import { c, log, ok, info, warn, fail, hand, exists, emitJSON } from './lib.mjs';
|
|
15
15
|
import path from 'node:path';
|
|
16
|
-
import { productConfigPath, isVerifiedLedger, SCHEMA_VERSION } from './manifest.mjs';
|
|
16
|
+
import { productConfigPath, isVerifiedLedger, SCHEMA_VERSION, settingsEditHint } from './manifest.mjs';
|
|
17
17
|
import { loadProduct } from './gate.mjs';
|
|
18
|
-
import { productGit, resolveDefaultBranch } from './
|
|
18
|
+
import { productGit, resolveDefaultBranch } from './productcommit.mjs';
|
|
19
19
|
import { buildIndex, writeIndex, indexFreshness, INDEX_FILE } from './product-index.mjs';
|
|
20
20
|
import { printable } from './epic-state.mjs';
|
|
21
21
|
|
|
@@ -40,11 +40,11 @@ export async function runIndex(root, { json = false } = {}) {
|
|
|
40
40
|
if (json) return emitJSON({ schemaVersion: SCHEMA_VERSION, ...built.index });
|
|
41
41
|
|
|
42
42
|
log(c.bold('\nyad index'));
|
|
43
|
-
const {
|
|
43
|
+
const { productConfig } = loadProduct(root);
|
|
44
44
|
const items = built.index.items;
|
|
45
45
|
const unreadable = items.filter((i) => i.unreadable);
|
|
46
46
|
const summary = `${items.length} work item${items.length === 1 ? '' : 's'}${unreadable.length ? `, ${unreadable.length} unreadable` : ''}`;
|
|
47
|
-
if (isVerifiedLedger(
|
|
47
|
+
if (isVerifiedLedger(productConfig)) {
|
|
48
48
|
const fresh = indexFreshness(root, built);
|
|
49
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
50
|
if (fresh.state === 'current') ok(`${INDEX_FILE} is current (${summary})`);
|
|
@@ -59,10 +59,10 @@ export async function runIndex(root, { json = false } = {}) {
|
|
|
59
59
|
}
|
|
60
60
|
const git = productGit(root);
|
|
61
61
|
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
62
|
-
const defaultBranch = resolveDefaultBranch(git,
|
|
62
|
+
const defaultBranch = resolveDefaultBranch(git, productConfig);
|
|
63
63
|
if (branch !== defaultBranch) {
|
|
64
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 (
|
|
65
|
+
hand(`switch to '${defaultBranch}' and re-run (if '${defaultBranch}' is wrong, set default_branch in ${settingsEditHint(root)}); \`yad index --json\` prints it here without writing`);
|
|
66
66
|
process.exitCode = 1;
|
|
67
67
|
return;
|
|
68
68
|
}
|