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/ledger.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Shard-then-fold storage for the two append-only
|
|
1
|
+
// Shard-then-fold storage for the two append-only Build ledgers (trust-log, build-log).
|
|
2
2
|
//
|
|
3
3
|
// The problem: both ledgers were ONE file per epic, so two people driving different stories of the
|
|
4
4
|
// SAME epic both appended to the same file → git merge conflict on push. The fix ("loose objects +
|
|
@@ -57,9 +57,30 @@ export function withLedgerLock(lockPath, fn, { retries = LOCK_RETRIES, waitMs =
|
|
|
57
57
|
} catch (e) {
|
|
58
58
|
if (e.code !== 'EEXIST') throw e;
|
|
59
59
|
// A holder that died leaves its lock behind forever; reclaim one that is provably too old.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
//
|
|
61
|
+
// NEITHER BRANCH BELOW MAY `continue`. Both used to, which skipped the retry cap AND the sleep
|
|
62
|
+
// underneath them — an unbounded, unpaused, fully synchronous loop. The reclaim path is the one
|
|
63
|
+
// that bites: `fs.rmdirSync` throws ENOTEMPTY when anything at all is inside the lock directory
|
|
64
|
+
// (a stray file, an editor's dotfile), so a lock that is older than LOCK_STALE_MS and not empty
|
|
65
|
+
// can NEVER be removed, and the old code retried it forever at 100% CPU. Because the loop is
|
|
66
|
+
// synchronous, node cannot reach its event loop, so the process also ignores SIGTERM: it has to
|
|
67
|
+
// be killed with -9. Observed here for 2h27m before it was noticed (2026-09-21).
|
|
68
|
+
//
|
|
69
|
+
// Now every path falls through to the cap and the sleep. A stale lock nobody can remove is
|
|
70
|
+
// REPORTED as YAD-STATE-006 after the normal retry window — and that error's hint already says
|
|
71
|
+
// to delete the lock directory it names, which is exactly the fix for a non-empty one.
|
|
72
|
+
let age = null;
|
|
73
|
+
try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch { /* it vanished — just retry */ }
|
|
74
|
+
if (age !== null && age > LOCK_STALE_MS) {
|
|
75
|
+
let reclaimed = false;
|
|
76
|
+
try { fs.rmdirSync(lockPath); reclaimed = true; } catch { /* someone else won, or it is not empty */ }
|
|
77
|
+
// TAKE IT IMMEDIATELY. Without this, a reclaim on the LAST pass deleted the stale lock and then
|
|
78
|
+
// reported "another process is writing" about a lock that no longer existed — the user re-ran
|
|
79
|
+
// and it worked, with no idea why. Losing the race here is normal: fall through and retry.
|
|
80
|
+
if (reclaimed) {
|
|
81
|
+
try { fs.mkdirSync(lockPath); break; } catch { /* another writer took it first */ }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
63
84
|
if (i >= retries) {
|
|
64
85
|
throw err('YAD-STATE-006', `another process is writing ${path.basename(lockPath, '.lock')} in ${path.basename(path.dirname(path.dirname(lockPath)))}`,
|
|
65
86
|
'wait for the other yad command to finish and re-run; if nothing else is running, delete the stale .lock directory the message names');
|
|
@@ -93,7 +114,7 @@ export const buildShardName = (e) => `${safe(e.story)}-${safe(e.task)}-${safe(e.
|
|
|
93
114
|
// `schemaVersion` is dropped here on purpose. It describes the shape of a FILE, and a shard file has
|
|
94
115
|
// one — but `fold()` copies these objects into the folded log's `ships`/`runs` array, where they stop
|
|
95
116
|
// being files and become records inside one. Carrying the stamp across that boundary would bake a
|
|
96
|
-
// per-record version into an append-only ledger for good:
|
|
117
|
+
// per-record version into an append-only ledger for good: once the shape moves — as it has, to 2 — a
|
|
97
118
|
// build-log.json stamped 2 would hold entries stamped 1 from shards folded today. The folded file
|
|
98
119
|
// states its own shape; its rows do not have one.
|
|
99
120
|
function readShardDir(dir) {
|
|
@@ -108,6 +129,35 @@ function readShardDir(dir) {
|
|
|
108
129
|
return out;
|
|
109
130
|
}
|
|
110
131
|
|
|
132
|
+
// The shards in `dir` that do NOT parse as a JSON object, by file name. `readShardDir` SKIPS such a
|
|
133
|
+
// shard on purpose — these ledgers are advisory evidence and one bad file must not abort a report. A
|
|
134
|
+
// caller that COUNTS PEOPLE cannot accept that: a skipped ship shard is an engineer-review approval
|
|
135
|
+
// that vanishes, and a person who vanishes makes the active count SMALLER, which lowers E72's cap and
|
|
136
|
+
// weakens every gate (E71). Such a caller asks this first and reports "unknown" rather than a number.
|
|
137
|
+
// Deliberately cheap: it parses, it does not validate a ship's fields.
|
|
138
|
+
// Returns { bad: [names], unreadable: null | reason }. It NEVER throws: `fs.existsSync` is true for a
|
|
139
|
+
// FILE as well as a directory, so a stray file named `build-log` under an epic's `.sdlc/` made
|
|
140
|
+
// `readdirSync` throw ENOTDIR — and since the caller counts people for `yad gate sync`, `gate status`
|
|
141
|
+
// and `gate open`, that exception escaped and turned three working commands into a crash. A directory
|
|
142
|
+
// the user cannot list (EACCES) did the same. Both are now "we could not read this", which is exactly
|
|
143
|
+
// what the counter needs to hear.
|
|
144
|
+
export function corruptShards(dir) {
|
|
145
|
+
let names;
|
|
146
|
+
try {
|
|
147
|
+
if (!fs.existsSync(dir)) return { bad: [], unreadable: null };
|
|
148
|
+
if (!fs.statSync(dir).isDirectory()) return { bad: [], unreadable: `${path.basename(dir)} is not a directory` };
|
|
149
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort();
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { bad: [], unreadable: `${path.basename(dir)} could not be listed: ${e.code || e.message}` };
|
|
152
|
+
}
|
|
153
|
+
const bad = [];
|
|
154
|
+
for (const name of names) {
|
|
155
|
+
const obj = readJSON(path.join(dir, name), null);
|
|
156
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) bad.push(name);
|
|
157
|
+
}
|
|
158
|
+
return { bad, unreadable: null };
|
|
159
|
+
}
|
|
160
|
+
|
|
111
161
|
// The half-applied-tidy guard key: a shard is a genuine duplicate of a folded entry ONLY when its FULL
|
|
112
162
|
// identity matches — NOT `uid` alone. `uid` is minted by an LLM skill, so two runs of DIFFERENT
|
|
113
163
|
// (story,repo,step) could share a short token; keying on uid alone would drop the second as a false
|
|
@@ -117,7 +167,7 @@ const trustKey = (e) => `${e.story}|${e.repo}|${e.step}|${e.uid}`;
|
|
|
117
167
|
|
|
118
168
|
// trust-log = the evidence base. EVERY entry is a distinct step run (re-runs of a step share
|
|
119
169
|
// story/repo/step), so we CONCATENATE folded runs + shards and never dedup by (story,repo,step) —
|
|
120
|
-
// that would drop re-run history the
|
|
170
|
+
// that would drop re-run history the run record `yad dial` shows counts. The only guard is a shard whose full
|
|
121
171
|
// identity (`trustKey`) already appears folded (a half-applied tidy). A corrupt folded file must throw
|
|
122
172
|
// (readJSONStrict), never silently read as empty — under-reporting the evidence base is a safety bug.
|
|
123
173
|
export function readTrustRuns(epicDir) {
|
|
@@ -147,7 +197,7 @@ export function readShips(epicDir) {
|
|
|
147
197
|
return [...byKey.values()];
|
|
148
198
|
}
|
|
149
199
|
|
|
150
|
-
// Record a RETROACTIVE ship for a pre-tracking story — one merged & shipped before the
|
|
200
|
+
// Record a RETROACTIVE ship for a pre-tracking story — one merged & shipped before the Build
|
|
151
201
|
// ledger existed, so it has no build-log ship and `yad checkpoint` can't carry its `status:` flip
|
|
152
202
|
// (issue #142). Writes ONE minimal ship shard, marked `retroactive: true`, so `readShips` now proves
|
|
153
203
|
// the story shipped and checkpoint carries the human's already-made flip. It is NOT a fabricated real
|
package/cli/lib.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Shared helpers for the `yad` CLI. Node >=18 built-ins only — no dependencies.
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { err } from './errors.mjs';
|
|
4
|
-
import { SCHEMA_VERSION } from './manifest.mjs';
|
|
4
|
+
import { MIRRORED_FILES, SCHEMA_VERSION, VERSION } from './manifest.mjs';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import * as readline from 'node:readline/promises';
|
|
7
7
|
import { stdin as input, stdout as output } from 'node:process';
|
|
@@ -13,9 +13,76 @@ import path from 'node:path';
|
|
|
13
13
|
// resolve from HERE, never from the user's cwd.
|
|
14
14
|
export const PKG_ROOT = fileURLToPath(new URL('../', import.meta.url));
|
|
15
15
|
|
|
16
|
+
// ---- --json (E1) ---------------------------------------------------------
|
|
17
|
+
// Every command answers `--json` with ONE object on stdout, built here and nowhere else:
|
|
18
|
+
// { jsonVersion, version, command, ok, ...the command's own keys, warnings }
|
|
19
|
+
// and a refusal adds `error`, `code` (a `YAD-` code, or null) and `hint` (or null). `jsonVersion` is
|
|
20
|
+
// the contract's own number: adding a key keeps it, renaming or removing one — or changing what a key
|
|
21
|
+
// means — bumps it. `version` is the package that answered.
|
|
22
|
+
//
|
|
23
|
+
// While a `--json` run is on, `log` (and so every helper built on it) writes to STDERR, so stdout
|
|
24
|
+
// holds the one object and nothing else — a progress line printed before the answer would otherwise
|
|
25
|
+
// make the whole output unparseable. Each `warn` is also collected into `warnings`, as plain text.
|
|
26
|
+
// A command never calls JSON.stringify toward stdout itself; a test greps for it.
|
|
27
|
+
export const JSON_VERSION = 1;
|
|
28
|
+
export const ENVELOPE_KEYS = ['jsonVersion', 'version', 'command'];
|
|
29
|
+
let jsonRun = null;
|
|
30
|
+
export function beginJSON(command) { jsonRun = { command, emitted: false, warnings: [], failures: [] }; }
|
|
31
|
+
export const inJSON = () => jsonRun !== null;
|
|
32
|
+
// A warning said some other way than `warn` (straight to stderr, before a command's own output) still
|
|
33
|
+
// belongs in `warnings`: a --json reader must never read `warnings: []` while stderr warned (E1 review).
|
|
34
|
+
export const collectWarning = (s) => { if (jsonRun) jsonRun.warnings.push(stripAnsi(s)); };
|
|
35
|
+
export const jsonEmitted = () => jsonRun?.emitted === true;
|
|
36
|
+
// The refusal a command SAID in prose (`fail`, then the `hand` under it), for the run that ends with a
|
|
37
|
+
// non-zero exit and no answer: most refusals are written that way, and turning each by hand into a
|
|
38
|
+
// JSON one is how one arm gets fixed and its twin missed. `bin/yad.mjs` answers with the first.
|
|
39
|
+
// Every failure after the first goes into `warnings`, so a sweep that skipped several epics says all of
|
|
40
|
+
// them (E1 review 2) — `error` holds one line, and none is lost.
|
|
41
|
+
export const jsonFailure = () => {
|
|
42
|
+
const [f, ...more] = jsonRun?.failures ?? [];
|
|
43
|
+
if (!f) return null;
|
|
44
|
+
// With its hint: for a repo whose push failed, the hint is the only recovery step.
|
|
45
|
+
for (const m of more) jsonRun.warnings.push(m.hint ? `${m.error} — ${m.hint}` : m.error);
|
|
46
|
+
const code = /\b(YAD-[A-Z]+-\d{3})\b/.exec(f.error)?.[1] ?? null;
|
|
47
|
+
return { error: f.error, hint: f.hint, code };
|
|
48
|
+
};
|
|
49
|
+
// For a test that drives several commands in one process.
|
|
50
|
+
export function endJSON() { jsonRun = null; }
|
|
51
|
+
// eslint-disable-next-line no-control-regex -- removing the colour codes is the point
|
|
52
|
+
export const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
53
|
+
|
|
54
|
+
// Written with console.log, which is stdout: during a --json run `log` has moved to stderr, so the
|
|
55
|
+
// answer is the only thing left there. Called outside a run (a test calling a command with
|
|
56
|
+
// `json: true`), it still answers, with `command: null` and no collected warnings.
|
|
57
|
+
export function emitJSON(payload = {}) {
|
|
58
|
+
const command = jsonRun?.command ?? null;
|
|
59
|
+
// A second answer would make stdout two documents; a bug, and said as one.
|
|
60
|
+
if (jsonRun?.emitted) throw new Error(`yad ${command}: a second JSON answer`);
|
|
61
|
+
const clash = ENVELOPE_KEYS.filter((k) => Object.hasOwn(payload, k));
|
|
62
|
+
if (clash.length) throw new Error(`yad ${command}: the answer sets the envelope key(s) ${clash.join(', ')}`);
|
|
63
|
+
if (jsonRun) jsonRun.emitted = true;
|
|
64
|
+
const { ok = true, warnings, ...rest } = payload;
|
|
65
|
+
// A refusal always carries all three keys, so a reader never tests whether one exists.
|
|
66
|
+
const refusal = ok === false && typeof rest.error === 'string'
|
|
67
|
+
? { error: stripAnsi(rest.error), code: rest.code ?? null, hint: rest.hint == null ? null : stripAnsi(rest.hint) } : {};
|
|
68
|
+
const all = [...(jsonRun?.warnings ?? []), ...(Array.isArray(warnings) ? warnings.map(stripAnsi) : [])];
|
|
69
|
+
console.log(JSON.stringify({ jsonVersion: JSON_VERSION, version: VERSION, command, ok, ...rest, ...refusal, warnings: all }, null, 2));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A refusal: one object under --json, the red line and its hint otherwise. Exit code 1 either way.
|
|
73
|
+
// `json` answers in JSON outside a CLI run too (a command called with `json: true`); `extra` carries
|
|
74
|
+
// keys a command adds to its refusal (never an envelope key).
|
|
75
|
+
export function refuse(message, hint = null, { code = null, json = false, ...extra } = {}) {
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
if (jsonRun || json) return emitJSON({ ok: false, error: stripAnsi(message), code, hint: hint === null || hint === undefined ? null : stripAnsi(hint), ...extra });
|
|
78
|
+
log(c.red(message));
|
|
79
|
+
if (hint) log(c.yellow(` → ${hint}`));
|
|
80
|
+
}
|
|
81
|
+
|
|
16
82
|
// ---- output -------------------------------------------------------------
|
|
17
|
-
|
|
18
|
-
const
|
|
83
|
+
// Colour follows the stream the prose goes to: stderr during a --json run, stdout otherwise.
|
|
84
|
+
const useColor = () => (jsonRun ? process.stderr.isTTY : output.isTTY) && !process.env.NO_COLOR;
|
|
85
|
+
const paint = (code, s) => (useColor() ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
19
86
|
export const c = {
|
|
20
87
|
bold: (s) => paint('1', s),
|
|
21
88
|
dim: (s) => paint('2', s),
|
|
@@ -24,13 +91,28 @@ export const c = {
|
|
|
24
91
|
red: (s) => paint('31', s),
|
|
25
92
|
cyan: (s) => paint('36', s),
|
|
26
93
|
};
|
|
27
|
-
export const log = (s = '') =>
|
|
94
|
+
export const log = (s = '') => {
|
|
95
|
+
if (!jsonRun) return console.log(s);
|
|
96
|
+
jsonRun.afterFail = false;
|
|
97
|
+
console.error(s);
|
|
98
|
+
};
|
|
28
99
|
export const step = (n, total, title) => log(`\n${c.cyan(`[${n}/${total}]`)} ${c.bold(title)}`);
|
|
29
100
|
export const ok = (s) => log(` ${c.green('✓')} ${s}`);
|
|
30
101
|
export const info = (s) => log(` ${c.dim('•')} ${s}`);
|
|
31
|
-
export const warn = (s) =>
|
|
32
|
-
|
|
33
|
-
|
|
102
|
+
export const warn = (s) => {
|
|
103
|
+
if (jsonRun) jsonRun.warnings.push(stripAnsi(s));
|
|
104
|
+
log(` ${c.yellow('!')} ${s}`);
|
|
105
|
+
};
|
|
106
|
+
export const fail = (s) => {
|
|
107
|
+
log(` ${c.red('✗')} ${s}`);
|
|
108
|
+
if (jsonRun) { jsonRun.failures.push({ error: stripAnsi(s), hint: null }); jsonRun.afterFail = true; }
|
|
109
|
+
};
|
|
110
|
+
// A hint belongs to a failure only when it is the very next line under it — never a later `hand`.
|
|
111
|
+
export const hand = (s) => {
|
|
112
|
+
const attach = jsonRun?.afterFail ? jsonRun.failures.at(-1) : null;
|
|
113
|
+
log(` ${c.yellow('→')} ${s}`);
|
|
114
|
+
if (attach) attach.hint = stripAnsi(s);
|
|
115
|
+
};
|
|
34
116
|
// Like `info`, but to STDERR — for diagnostics emitted by commands whose STDOUT must stay pure (e.g. a
|
|
35
117
|
// JSON bundle a tool parses). Keeps the human hint visible without corrupting machine-readable output.
|
|
36
118
|
export const note = (s) => console.error(` ${c.dim('•')} ${s}`);
|
|
@@ -45,14 +127,20 @@ export function closePrompts() {
|
|
|
45
127
|
rl?.close();
|
|
46
128
|
rl = undefined;
|
|
47
129
|
}
|
|
130
|
+
// A --json run never prompts: its stdin is a program's, and a question on stderr would hang it.
|
|
131
|
+
const noPromptInJSON = (question) => {
|
|
132
|
+
if (jsonRun) throw err('YAD-CLI-001', `a --json run cannot ask: ${stripAnsi(question)}`, 'pass the answer as a flag where one exists (`yad --help` lists them), set SDLC_NONINTERACTIVE=1 to take every default, or run without --json');
|
|
133
|
+
};
|
|
48
134
|
export async function ask(question, def = '') {
|
|
49
135
|
if (process.env.SDLC_NONINTERACTIVE) return def;
|
|
136
|
+
noPromptInJSON(question);
|
|
50
137
|
const suffix = def ? c.dim(` (${def})`) : '';
|
|
51
138
|
const a = (await getRl().question(` ${question}${suffix}: `)).trim();
|
|
52
139
|
return a || def;
|
|
53
140
|
}
|
|
54
141
|
export async function askYesNo(question, def = true) {
|
|
55
142
|
if (process.env.SDLC_NONINTERACTIVE) return def;
|
|
143
|
+
noPromptInJSON(question);
|
|
56
144
|
const hint = def ? 'Y/n' : 'y/N';
|
|
57
145
|
const a = (await getRl().question(` ${question} ${c.dim(`(${hint})`)} `)).trim().toLowerCase();
|
|
58
146
|
if (!a) return def;
|
|
@@ -124,7 +212,10 @@ export function dirMatches(src, dest) {
|
|
|
124
212
|
// file in a project that merely happened to live somewhere under a directory called `.sdlc` — the
|
|
125
213
|
// user's own .claude/settings.json included, which is exactly what this must never touch. A new kind
|
|
126
214
|
// nested deeper than that has to be added here on purpose.
|
|
127
|
-
|
|
215
|
+
// Exported so the dial stampers (migrate.mjs, epic-state.mjs) and doctor all decide "is this a step
|
|
216
|
+
// object" the same way. Three private copies drifted once already: one guarded arrays, one did not,
|
|
217
|
+
// so an array-valued step came out as `["x"]` from one and `{"0":"x"}` from the other.
|
|
218
|
+
export const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
128
219
|
const underSdlcDir = (p) => {
|
|
129
220
|
const dir = path.dirname(path.resolve(p));
|
|
130
221
|
return path.basename(dir) === '.sdlc' || path.basename(path.dirname(dir)) === '.sdlc';
|
|
@@ -132,16 +223,30 @@ const underSdlcDir = (p) => {
|
|
|
132
223
|
const carriesShape = (p, v) => isPlainObject(v) && underSdlcDir(p);
|
|
133
224
|
|
|
134
225
|
// Always FIRST in the serialized object. A stamp that moved around between writers would change the
|
|
135
|
-
// bytes without changing the meaning. An existing version is preserved, never forced back
|
|
136
|
-
// file already on
|
|
137
|
-
|
|
138
|
-
|
|
226
|
+
// bytes without changing the meaning. An existing version is preserved, never forced back or forward,
|
|
227
|
+
// so a file already on another shape survives being read and written by this release.
|
|
228
|
+
//
|
|
229
|
+
// Reading and writing do NOT default the same way, and the difference is load-bearing:
|
|
230
|
+
//
|
|
231
|
+
// read — a file with no version IS shape 1 (rule 1). Defaulting it to the engine's current shape
|
|
232
|
+
// would be the engine telling itself an old file is already current. Everything downstream
|
|
233
|
+
// believes it: `yad doctor` stops reporting drift, and a plain read-modify-write stamps the
|
|
234
|
+
// engine's shape onto content that was never migrated — after which `yad migrate` skips the
|
|
235
|
+
// file forever, because its own claim says there is nothing to do. Silent, permanent, and
|
|
236
|
+
// invisible until someone asks why a field never got renamed.
|
|
237
|
+
// write — a NEW object, one that was never read from disk and so has no version of its own, is
|
|
238
|
+
// written by this engine and therefore has this engine's shape.
|
|
239
|
+
//
|
|
240
|
+
// This was harmless while SCHEMA_VERSION was 1, because the two defaults were the same number. It
|
|
241
|
+
// stops being harmless the moment the shape moves, which is why it is fixed in the commit that moves it.
|
|
242
|
+
const readShape = (p, v) => (carriesShape(p, v) ? { schemaVersion: v.schemaVersion ?? 1, ...v } : v);
|
|
243
|
+
const writeShape = (p, v) => (carriesShape(p, v) ? { schemaVersion: v.schemaVersion ?? SCHEMA_VERSION, ...v } : v);
|
|
139
244
|
|
|
140
245
|
export function readJSON(p, def = null) {
|
|
141
246
|
try {
|
|
142
247
|
// "Read old, write new" (rule 2): an unstamped file reads back as shape 1, so a caller never has
|
|
143
248
|
// to ask whether the file it just loaded predates the stamp.
|
|
144
|
-
return
|
|
249
|
+
return readShape(p, JSON.parse(fs.readFileSync(p, 'utf8')));
|
|
145
250
|
} catch {
|
|
146
251
|
// The caller's own default is returned untouched: it is not a file, and stamping it would invent
|
|
147
252
|
// a shape for something that was never read from disk.
|
|
@@ -154,7 +259,7 @@ export function readJSON(p, def = null) {
|
|
|
154
259
|
export function readJSONStrict(p, def = null) {
|
|
155
260
|
if (!fs.existsSync(p)) return def;
|
|
156
261
|
try {
|
|
157
|
-
return
|
|
262
|
+
return readShape(p, JSON.parse(fs.readFileSync(p, 'utf8')));
|
|
158
263
|
} catch (e) {
|
|
159
264
|
throw err('YAD-STATE-001', `corrupt JSON in ${p}: ${e.message}`, 'fix the file or restore it from git — never delete a ledger blindly');
|
|
160
265
|
}
|
|
@@ -163,7 +268,7 @@ export function readJSONStrict(p, def = null) {
|
|
|
163
268
|
// then rename over the target. A killed process can never leave a truncated ledger
|
|
164
269
|
// file, and a failed rename never leaves a stray .tmp for `git add -A` to pick up.
|
|
165
270
|
export function writeJSON(p, obj) {
|
|
166
|
-
const data = JSON.stringify(
|
|
271
|
+
const data = JSON.stringify(writeShape(p, obj), null, 2) + '\n';
|
|
167
272
|
// Byte-identical content is not a write. The ledger writers are unconditional — they re-serialize
|
|
168
273
|
// whether or not anything changed — so this keeps an unchanged sync from touching the file at all
|
|
169
274
|
// (no mtime churn, nothing for a watcher or a `git add -A` to notice). A backstop, not the fix: the
|
|
@@ -185,7 +290,10 @@ export function writeJSON(p, obj) {
|
|
|
185
290
|
// ---- subprocess ---------------------------------------------------------
|
|
186
291
|
// Returns { ok, stdout, stderr, code }. Never throws on non-zero exit.
|
|
187
292
|
export function run(cmd, args = [], opts = {}) {
|
|
188
|
-
|
|
293
|
+
// A subprocess that shares our stdout (`npm run build`) would print into a --json answer: during a
|
|
294
|
+
// --json run its stdout is our stderr instead (E1).
|
|
295
|
+
const stdio = jsonRun && opts.stdio === 'inherit' ? ['inherit', 2, 'inherit'] : opts.stdio;
|
|
296
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts, ...(stdio === undefined ? {} : { stdio }) });
|
|
189
297
|
return {
|
|
190
298
|
ok: r.status === 0,
|
|
191
299
|
code: r.status,
|
|
@@ -196,8 +304,8 @@ export function run(cmd, args = [], opts = {}) {
|
|
|
196
304
|
}
|
|
197
305
|
export const has = (cmd) => run(process.platform === 'win32' ? 'where' : 'which', [cmd]).ok;
|
|
198
306
|
|
|
199
|
-
// Push HEAD to origin/<target>, rebasing onto it and retrying on rejection — both the
|
|
200
|
-
// sync and the
|
|
307
|
+
// Push HEAD to origin/<target>, rebasing onto it and retrying on rejection — both the Shape gate
|
|
308
|
+
// sync and the Build checkpoint push append-only ledgers to the default branch, so a concurrent
|
|
201
309
|
// push is a normal race, not an error. Returns { ok } after up to `attempts` tries; logs each retry.
|
|
202
310
|
// A failed `pull --rebase` is aborted so we never leave a wedged rebase for the next command.
|
|
203
311
|
export function pushWithRebase(cwd, target, { attempts = 3 } = {}) {
|
|
@@ -211,3 +319,61 @@ export function pushWithRebase(cwd, target, { attempts = 3 } = {}) {
|
|
|
211
319
|
}
|
|
212
320
|
return { ok: false };
|
|
213
321
|
}
|
|
322
|
+
|
|
323
|
+
// ---- the product config, which lives under two names for one major -----------------------------
|
|
324
|
+
//
|
|
325
|
+
// Read through `productConfigPath` (manifest.mjs), which every reader in cli/ now does. Write
|
|
326
|
+
// through here: BOTH names, every time.
|
|
327
|
+
//
|
|
328
|
+
// The duplicate is not sloppiness. `templates/checks/ledger-guard.sh` sits committed inside the
|
|
329
|
+
// user's own repository and opens `.sdlc/hub.json` by that literal path; it is refreshed by
|
|
330
|
+
// `yad update`, which is a separate act from `yad migrate`. So a migrated-but-not-updated project is
|
|
331
|
+
// a real state, and if only the new name existed its guard would find nothing, read no platform,
|
|
332
|
+
// call the ledger local, and stop rejecting human commits to it. An upgrade that silently disarms a
|
|
333
|
+
// safety gate is worse than a duplicated file.
|
|
334
|
+
//
|
|
335
|
+
// Order matters, and it follows which file is AUTHORITATIVE — the old name, this major
|
|
336
|
+
// (`productConfigPath`, cli/manifest.mjs). The new name is written first, so if the second write
|
|
337
|
+
// fails the readers are all still on the untouched old copy: nothing has half-changed underneath
|
|
338
|
+
// them. Writing the authoritative file first would leave every reader on new settings while the
|
|
339
|
+
// mirror still says something else, which is the harder failure to notice.
|
|
340
|
+
//
|
|
341
|
+
// `yad doctor` reports the two copies disagreeing either way, so a half-written pair is visible
|
|
342
|
+
// rather than silent.
|
|
343
|
+
// Write a file that lives under two names. Returns the paths it actually wrote — empty when there
|
|
344
|
+
// was nothing to do.
|
|
345
|
+
//
|
|
346
|
+
// The "nothing to do" case is load-bearing, not an optimisation. The ledger writers are
|
|
347
|
+
// UNCONDITIONAL: `yad gate` re-serializes and writes every time, and relies on `writeJSON` doing
|
|
348
|
+
// nothing when the bytes match, which is what keeps a read-only pre-merge run from touching the
|
|
349
|
+
// working tree at all. A naive mirror breaks that on day one — the new name does not exist yet, so
|
|
350
|
+
// every read-only run would create it and leave the tree dirty, and `gate ci` would try to push a
|
|
351
|
+
// file it never meant to write. So the comparison happens FIRST, against whichever name is currently
|
|
352
|
+
// readable; only a genuine change writes, and then it writes both.
|
|
353
|
+
export function writeMirrored(canonicalPath, legacyPath, obj) {
|
|
354
|
+
// Compare against the AUTHORITATIVE copy — the same one readers use (`preferring`,
|
|
355
|
+
// cli/manifest.mjs), which is the legacy name while it exists. Comparing against the other file
|
|
356
|
+
// would let the two rules disagree about whether anything changed.
|
|
357
|
+
const readable = fs.existsSync(legacyPath) ? legacyPath : canonicalPath;
|
|
358
|
+
const next = `${JSON.stringify(writeShape(canonicalPath, obj), null, 2)}\n`;
|
|
359
|
+
// "Unchanged" means BOTH names are already right. A half-made pair — one side missing — is work
|
|
360
|
+
// to do even when the readable copy matches, or the pair can never be repaired: the comparison
|
|
361
|
+
// says nothing changed, nothing is written, and `yad doctor` reports the missing file for ever.
|
|
362
|
+
const bothPresent = fs.existsSync(canonicalPath) && fs.existsSync(legacyPath);
|
|
363
|
+
try {
|
|
364
|
+
if (bothPresent && fs.readFileSync(readable, 'utf8') === next) return [];
|
|
365
|
+
} catch { /* unreadable — treat as a first write and fall through */ }
|
|
366
|
+
writeJSON(canonicalPath, obj);
|
|
367
|
+
writeJSON(legacyPath, obj);
|
|
368
|
+
return [canonicalPath, legacyPath];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function writeProductConfig(root, obj) {
|
|
372
|
+
const written = [];
|
|
373
|
+
for (const { canonical, legacy } of MIRRORED_FILES) {
|
|
374
|
+
const wrote = writeMirrored(path.join(root, canonical), path.join(root, legacy), obj);
|
|
375
|
+
if (wrote.length) written.push(canonical, legacy);
|
|
376
|
+
}
|
|
377
|
+
return written;
|
|
378
|
+
}
|
|
379
|
+
|