yadflow 3.10.0 → 3.11.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 +15 -0
- package/README.md +3 -0
- package/bin/yad.mjs +18 -2
- package/cli/manifest.mjs +4 -0
- package/cli/repo-publish.mjs +118 -18
- package/cli/repo.mjs +3 -2
- package/cli/setup.mjs +28 -0
- package/cli/update-notice.mjs +179 -0
- package/package.json +1 -1
- package/skills/yad-checks/references/check-gates.md +5 -1
- package/skills/yad-checks/templates/github/yad-checks.yml +14 -0
- package/skills/yad-connect-repos/references/code-context.md +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
# [3.11.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.1...v3.11.0) (2026-07-09)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* notify when a newer yadflow is published ([9b7a5bf](https://github.com/abdelrahmannasr/yadflow/commit/9b7a5bfca4f27ab08ce4e3e48f2ba331c3e8bfd5))
|
|
7
|
+
|
|
8
|
+
## [3.10.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.0...v3.10.1) (2026-07-08)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* re-run pr-template gate on an edited PR body ([17ad94a](https://github.com/abdelrahmannasr/yadflow/commit/17ad94a4881610b4b653700be50a4eddd7036c5d))
|
|
14
|
+
* stop yad repo refresh --push stranding the regenerated pack.md ([f0b5f4c](https://github.com/abdelrahmannasr/yadflow/commit/f0b5f4ce9f22afcd6078aae3ea1dd5a64885be35))
|
|
15
|
+
|
|
1
16
|
# [3.10.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.9.4...v3.10.0) (2026-07-08)
|
|
2
17
|
|
|
3
18
|
|
package/README.md
CHANGED
|
@@ -63,6 +63,9 @@ Every step stops at a gate until a human approves. New here? **Walk it lesson-by
|
|
|
63
63
|
[guided tutorial](https://abdelrahmannasr.github.io/yadflow/tutorial/)**, or read the
|
|
64
64
|
[team guide](TEAM-GUIDE.md).
|
|
65
65
|
|
|
66
|
+
Running `yad` tells you when a new release is out — upgrade with `npm install yadflow -g`, then
|
|
67
|
+
`yad update` to re-sync this project's skills. See [staying up to date](docs/CLI.md#staying-up-to-date).
|
|
68
|
+
|
|
66
69
|
## What `npx yadflow setup` installs
|
|
67
70
|
|
|
68
71
|

|
package/bin/yad.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { syncStatuses } from '../cli/artifact-status.mjs';
|
|
|
22
22
|
import { runThread, runReconcile } from '../cli/thread.mjs';
|
|
23
23
|
import { runReport } from '../cli/report.mjs';
|
|
24
24
|
import { runUsage } from '../cli/usage.mjs';
|
|
25
|
+
import { maybeNotifyUpdate } from '../cli/update-notice.mjs';
|
|
25
26
|
|
|
26
27
|
const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
|
|
27
28
|
|
|
@@ -146,7 +147,11 @@ ${c.bold('Options')}
|
|
|
146
147
|
--push check --fix / update: commit + push applied changes to the default branch
|
|
147
148
|
--allow-branch check --fix --push / update --push / repo refresh --push: allow committing on a non-default branch
|
|
148
149
|
-h, --help Show this help
|
|
149
|
-
-v, --version Print version
|
|
150
|
+
-v, --version Print version
|
|
151
|
+
|
|
152
|
+
${c.bold('Environment')}
|
|
153
|
+
YAD_NO_UPDATE_NOTIFIER=1 Silence the "update available" notice (also off in CI)
|
|
154
|
+
YAD_NO_REPORT=1 Never offer to file a bug report after a failure`;
|
|
150
155
|
|
|
151
156
|
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason']);
|
|
152
157
|
|
|
@@ -364,4 +369,15 @@ main()
|
|
|
364
369
|
} catch { /* reporting is best-effort — never mask the original failure */ }
|
|
365
370
|
}
|
|
366
371
|
})
|
|
367
|
-
.
|
|
372
|
+
// Runs for every command, success or failure, after any report prompt. Prints to stderr and never
|
|
373
|
+
// touches process.exitCode, so a command's stdout contract and exit status are unaffected.
|
|
374
|
+
// The try/finally is load-bearing, not defensive noise: a rejection here would escape as an
|
|
375
|
+
// unhandled rejection (exit 1 on an otherwise successful command) AND skip closePrompts(), leaving
|
|
376
|
+
// the readline handle open so the process never exits.
|
|
377
|
+
.finally(async () => {
|
|
378
|
+
try {
|
|
379
|
+
await maybeNotifyUpdate();
|
|
380
|
+
} catch { /* the notice is never worth failing or hanging a command over */ } finally {
|
|
381
|
+
closePrompts();
|
|
382
|
+
}
|
|
383
|
+
});
|
package/cli/manifest.mjs
CHANGED
|
@@ -10,6 +10,10 @@ import { readFileSync } from 'node:fs';
|
|
|
10
10
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
11
11
|
export const VERSION = pkg.version;
|
|
12
12
|
|
|
13
|
+
// The published npm package name — the registry path the update check queries, and the name in the
|
|
14
|
+
// `npm install <name> -g` line it prints. Read from package.json so a rename can never desync them.
|
|
15
|
+
export const PKG_NAME = pkg.name;
|
|
16
|
+
|
|
13
17
|
// The upstream yadflow repo, as `owner/name` — where `yad report` files issues. Derived from
|
|
14
18
|
// package.json `bugs.url` (the single source of truth) so it tracks a fork/rename automatically;
|
|
15
19
|
// falls back to the canonical slug if the field is ever malformed.
|
package/cli/repo-publish.mjs
CHANGED
|
@@ -5,8 +5,13 @@
|
|
|
5
5
|
// analogue of `yad checkpoint` (cli/checkpoint.mjs) and reuses its default-branch commit machinery.
|
|
6
6
|
//
|
|
7
7
|
// Invariants (shared with checkpoint):
|
|
8
|
-
// 1.
|
|
9
|
-
//
|
|
8
|
+
// 1. Commit an EXPLICIT allowlist via `git commit -- <paths>` (--only) — the tracked code-maps, the
|
|
9
|
+
// registry, and (only when its change is the managed pack-ignore block alone) the hub `.gitignore`.
|
|
10
|
+
// NEVER `git add -A` and NEVER a whole-index `git reset`: both would mutate unrelated staged work.
|
|
11
|
+
// The repomix pack.md is gitignored (setup + publish scaffold the ignore via ensurePackIgnored) and
|
|
12
|
+
// never committed as content. A pack committed BEFORE that ignore existed is self-healed here: its
|
|
13
|
+
// on-disk file is held aside across the --only commit so the deletion is recorded (the regenerable
|
|
14
|
+
// cache is restored right after), clearing the stranded working tree — see the commit block below.
|
|
10
15
|
// 2. Commit ONLY on the hub's default branch (unless --allow-branch), so the `[skip ci]` audit
|
|
11
16
|
// commit never enters a PR's base..HEAD range where it would strand required checks.
|
|
12
17
|
import fs from 'node:fs';
|
|
@@ -16,6 +21,7 @@ import { PROJECT_FILES } from './manifest.mjs';
|
|
|
16
21
|
import { loadHub } from './gate.mjs';
|
|
17
22
|
import { resolveCommitterLogin } from './platform.mjs';
|
|
18
23
|
import { hubGit, resolveDefaultBranch, guardDefaultBranch, preflightGuardReadiness } from './hubcommit.mjs';
|
|
24
|
+
import { ensurePackIgnored, PACK_IGNORE_BLOCK } from './setup.mjs';
|
|
19
25
|
import { checkpointAuthor } from './checkpoint.mjs';
|
|
20
26
|
|
|
21
27
|
// Collapse any whitespace/newline runs to a single space — keeps a hostile `git user.name` or a stray
|
|
@@ -25,6 +31,9 @@ const oneLine = (s = '') => String(s).replace(/\s+/g, ' ').trim();
|
|
|
25
31
|
// The tracked code-map for a repo: the registered path, else the conventional location.
|
|
26
32
|
const codeMapOf = (repo) => repo.codeMap || path.posix.join('.sdlc/code-context', repo.name, 'code-map.md');
|
|
27
33
|
|
|
34
|
+
// The repomix pack for a repo: the registered path, else the conventional location.
|
|
35
|
+
const packOf = (repo) => repo.contextPack || path.posix.join('.sdlc/code-context', repo.name, 'pack.md');
|
|
36
|
+
|
|
28
37
|
// PURE — the repo-relative pathspecs to stage: the registry plus each registered repo's code-map that
|
|
29
38
|
// exists on disk. When `name` is given (a scoped `yad repo refresh <name> --push`), only that repo's
|
|
30
39
|
// code-map is staged, so an unrelated repo's uncommitted code-map never rides along in a named refresh's
|
|
@@ -41,6 +50,57 @@ export function codeMapPathspecs(root, registry = { repos: [] }, name = null) {
|
|
|
41
50
|
return out;
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
// PURE — each registered repo's on-disk pack path (scoped by `name` like codeMapPathspecs). These are
|
|
54
|
+
// UNTRACK candidates, not content to stage: the pack is gitignored, but a hub that committed it before
|
|
55
|
+
// the ignore existed would otherwise strand a dirty pack on every refresh. publishCodeContext keeps only
|
|
56
|
+
// the still-tracked ones and records their removal in the audit commit (see the self-heal block there).
|
|
57
|
+
export function packPathspecs(root, registry = { repos: [] }, name = null) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const repo of registry.repos || []) {
|
|
60
|
+
if (name && repo.name !== name) continue;
|
|
61
|
+
const rel = packOf(repo);
|
|
62
|
+
if (fs.existsSync(path.join(root, rel))) out.push(rel);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// True iff committing `.gitignore` would carry ONLY the managed pack-ignore block (comments + glob) and
|
|
68
|
+
// nothing else. Guards invariant 1: a hub whose `.gitignore` also has unrelated uncommitted edits must
|
|
69
|
+
// keep them OUT of the `[skip ci]` audit commit. The publish commit is `git commit -- <paths>` (--only,
|
|
70
|
+
// reads the WORKING TREE), so this compares the working tree — an untracked `.gitignore` must be wholly
|
|
71
|
+
// managed; a tracked one must differ from HEAD by the managed block alone (added, nothing removed).
|
|
72
|
+
// `git` is a hubGit-style accessor; `root` is the hub root. Mirrors checkpoint's stagedStoryIsStatusOnly.
|
|
73
|
+
export function ignoreChangeIsManagedOnly(git, root) {
|
|
74
|
+
const gi = path.join(root, '.gitignore');
|
|
75
|
+
if (!fs.existsSync(gi)) return false;
|
|
76
|
+
const managed = new Set(PACK_IGNORE_BLOCK.map((l) => l.trim()));
|
|
77
|
+
if (!git('ls-files', '--error-unmatch', '--', '.gitignore').ok) {
|
|
78
|
+
// untracked: every non-blank line of the whole file must be a managed line
|
|
79
|
+
let seen = 0;
|
|
80
|
+
for (const l of fs.readFileSync(gi, 'utf8').split('\n')) {
|
|
81
|
+
const body = l.trim();
|
|
82
|
+
if (body === '') continue;
|
|
83
|
+
if (!managed.has(body)) return false;
|
|
84
|
+
seen++;
|
|
85
|
+
}
|
|
86
|
+
return seen > 0;
|
|
87
|
+
}
|
|
88
|
+
// tracked: the working-tree-vs-HEAD diff must ADD only managed lines and remove nothing
|
|
89
|
+
const d = git('diff', '-U0', 'HEAD', '--', '.gitignore');
|
|
90
|
+
if (!d.ok) return false;
|
|
91
|
+
let added = 0;
|
|
92
|
+
for (const ln of d.stdout.split('\n')) {
|
|
93
|
+
if (ln.startsWith('+++') || ln.startsWith('---') || ln.startsWith('@@')) continue; // headers/hunks
|
|
94
|
+
if (ln.startsWith('-')) return false; // we only ever append — any removal ⇒ not ours
|
|
95
|
+
if (ln.startsWith('+')) {
|
|
96
|
+
const body = ln.slice(1).trim();
|
|
97
|
+
if (body !== '' && !managed.has(body)) return false; // a non-managed added line ⇒ a user edit
|
|
98
|
+
added++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return added > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
44
104
|
// PURE — turn the staged pathspecs into the subject `label` + the body's file list. A `<name>` is
|
|
45
105
|
// pulled from any `.sdlc/code-context/<name>/code-map.md` path; a commit that only touched the
|
|
46
106
|
// registry is labelled `registry`.
|
|
@@ -49,7 +109,9 @@ export function summarizeCodeContext(files = []) {
|
|
|
49
109
|
const basenames = [];
|
|
50
110
|
for (const f of files) {
|
|
51
111
|
basenames.push(f);
|
|
52
|
-
|
|
112
|
+
// A repo name comes from either its code-map (content) or a pack removal (self-heal), so the
|
|
113
|
+
// subject label stays accurate even when untracking a stranded pack is the only change.
|
|
114
|
+
const m = f.match(/\.sdlc\/code-context\/([^/]+)\/(?:code-map|pack)\.md$/);
|
|
53
115
|
if (m) repos.add(m[1]);
|
|
54
116
|
}
|
|
55
117
|
let label;
|
|
@@ -88,11 +150,26 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
|
|
|
88
150
|
const defaultBranch = resolveDefaultBranch(git, hub);
|
|
89
151
|
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad repo refresh --push' })) return;
|
|
90
152
|
|
|
153
|
+
// Stage the EXPLICIT allowlist (code-maps + registry), scoped so an unrelated pre-staged file is never
|
|
154
|
+
// swept in and the user's index is left untouched — mirrors `runCheckpoint`. NEVER `git add -A` and
|
|
155
|
+
// NEVER a whole-index `git reset` (both would mutate unrelated staged work). The commit below is
|
|
156
|
+
// `git commit -- <paths>` (--only), which reads the WORKING TREE for the named paths only.
|
|
91
157
|
const pathspecs = codeMapPathspecs(root, registry, name);
|
|
92
|
-
if (
|
|
158
|
+
if (pathspecs.length) {
|
|
159
|
+
const add = git('add', '--', ...pathspecs);
|
|
160
|
+
if (!add.ok) { fail(`git add failed — ${add.stderr.split('\n')[0] || add.code}`); process.exitCode = 1; return; }
|
|
161
|
+
}
|
|
93
162
|
|
|
94
|
-
|
|
95
|
-
|
|
163
|
+
// Make the "pack is gitignored" assumption true (idempotent; packRepo also does this on refresh) so a
|
|
164
|
+
// hub whose pack was tracked before the ignore existed stops stranding a dirty tree. Publish `.gitignore`
|
|
165
|
+
// ONLY when the change is the managed pack-ignore block alone (invariant 1) — a hub whose `.gitignore`
|
|
166
|
+
// also carries unrelated uncommitted edits keeps them OUT of this audit commit; the pack is still ignored
|
|
167
|
+
// on disk and the human commits their own `.gitignore` edits through their own change. When we do carry
|
|
168
|
+
// it, `git add` makes an untracked `.gitignore` known so the --only commit can include it.
|
|
169
|
+
const ignoreChanged = ensurePackIgnored(root);
|
|
170
|
+
const commitIgnore = ignoreChangeIsManagedOnly(git, root);
|
|
171
|
+
if (commitIgnore) git('add', '--', '.gitignore');
|
|
172
|
+
else if (ignoreChanged) hand('.gitignore has unrelated uncommitted edits — commit them yourself so the pack ignore is published (it is already ignored locally)');
|
|
96
173
|
|
|
97
174
|
// Push HEAD to its OWN branch — on the default branch this is the same; with --allow-branch it keeps a
|
|
98
175
|
// WIP branch from being force-published onto the default branch. Shared by the fresh-commit path and
|
|
@@ -105,7 +182,20 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
|
|
|
105
182
|
return false;
|
|
106
183
|
};
|
|
107
184
|
|
|
108
|
-
|
|
185
|
+
// Self-heal (invariant 1): a pack committed before it was gitignored strands the working tree on every
|
|
186
|
+
// refresh. Any STILL-TRACKED pack must be recorded as removed in this commit — but `git rm --cached`
|
|
187
|
+
// can't be committed under --only (which reads the working tree, where the regenerated pack still
|
|
188
|
+
// exists). So we untrack it below by momentarily removing the on-disk file across the commit (restored
|
|
189
|
+
// right after), letting --only record a clean deletion while the regenerable cache is preserved.
|
|
190
|
+
const trackedPacks = packPathspecs(root, registry, name)
|
|
191
|
+
.filter((p) => git('ls-files', '--error-unmatch', '--', p).ok);
|
|
192
|
+
|
|
193
|
+
// The exact files this audit commit will touch: the staged allowlist (+ managed `.gitignore`) plus any
|
|
194
|
+
// pack removal. Scopes the --only commit and, being all known to git, is a safe commit pathspec.
|
|
195
|
+
const ignoreSpec = commitIgnore ? ['.gitignore'] : [];
|
|
196
|
+
const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs, ...ignoreSpec).stdout.split('\n').filter(Boolean);
|
|
197
|
+
const fileset = [...staged, ...trackedPacks];
|
|
198
|
+
if (!fileset.length) {
|
|
109
199
|
// Nothing new to commit. A non-push run is simply done. But on a push run a PRIOR run may have
|
|
110
200
|
// committed and then FAILED to push (the commit sits ahead of origin) — a plain re-run must land
|
|
111
201
|
// that commit, not silently no-op and exit 0 while it stays stranded. Push any unpushed commit(s).
|
|
@@ -117,27 +207,37 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
|
|
|
117
207
|
pushHead();
|
|
118
208
|
return;
|
|
119
209
|
}
|
|
120
|
-
// The exact files staged from the allowlist — scopes the commit to ONLY the allowlist, so any
|
|
121
|
-
// unrelated pre-staged file never rides along.
|
|
122
|
-
const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs).stdout.split('\n').filter(Boolean);
|
|
123
210
|
|
|
124
211
|
// Only relevant when we are about to push a commit straight to the default branch: warn (never block)
|
|
125
212
|
// if signing/allowlisting would make the yad-update-guard reject it. Gated on `push` and deferred to
|
|
126
213
|
// here so it isn't noise on a guard-refused branch or a nothing-to-commit run.
|
|
127
214
|
if (push) preflightGuardReadiness(root);
|
|
128
215
|
|
|
129
|
-
const { label, basenames } = summarizeCodeContext(
|
|
216
|
+
const { label, basenames } = summarizeCodeContext(fileset);
|
|
130
217
|
const author = checkpointAuthor(resolveCommitterLogin(root, hub?.roster || []), git('config', 'user.name').stdout);
|
|
131
218
|
const message = buildCodeMapMessage({ label, author, basenames });
|
|
132
219
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
220
|
+
// Untrack the packs by holding their bytes and removing the files across the --only commit, then
|
|
221
|
+
// restoring them (now gitignored ⇒ a clean tree). try/finally so the regenerable cache is always put
|
|
222
|
+
// back, even on a commit failure.
|
|
223
|
+
const held = [];
|
|
224
|
+
try {
|
|
225
|
+
for (const p of trackedPacks) {
|
|
226
|
+
const abs = path.join(root, p);
|
|
227
|
+
held.push({ abs, buf: fs.readFileSync(abs) });
|
|
228
|
+
fs.rmSync(abs);
|
|
229
|
+
}
|
|
230
|
+
const cm = git('commit', '-m', message, '--', ...fileset); // --only: adds from the tree, packs as deletions
|
|
231
|
+
if (!cm.ok) {
|
|
232
|
+
git('reset', '-q', '--', ...pathspecs, ...ignoreSpec); // unstage only OUR allowlist for a clean retry
|
|
233
|
+
fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return { message };
|
|
236
|
+
}
|
|
237
|
+
} finally {
|
|
238
|
+
for (const h of held) if (!fs.existsSync(h.abs)) fs.writeFileSync(h.abs, h.buf);
|
|
139
239
|
}
|
|
140
|
-
ok(`published ${
|
|
240
|
+
ok(`published ${fileset.length} file(s): ${c.dim(label)}`);
|
|
141
241
|
|
|
142
242
|
if (!push) return { message };
|
|
143
243
|
pushHead();
|
package/cli/repo.mjs
CHANGED
|
@@ -73,8 +73,9 @@ export async function runRepo(root, { action = 'list', name, today, push = false
|
|
|
73
73
|
if (push) {
|
|
74
74
|
// Publish whatever tracked code-context now differs (the AI-regenerated code-maps + the stamped
|
|
75
75
|
// registry) straight to the hub's default branch as one audit-trail commit. The pack itself is
|
|
76
|
-
// gitignored
|
|
77
|
-
//
|
|
76
|
+
// gitignored (packRepo scaffolds the ignore; publish self-heals a pre-ignore tracked pack); the
|
|
77
|
+
// code-map is regenerated by the AI (yad-connect-repos) — run that first if a repo's map is stale,
|
|
78
|
+
// then `yad repo refresh --push` lands it.
|
|
78
79
|
await publishCodeContext(root, { push: true, allowBranch, name });
|
|
79
80
|
} else {
|
|
80
81
|
hand('regenerate the code-map in Claude Code (yad-connect-repos) — the pack is cached, the map is the AI step');
|
package/cli/setup.mjs
CHANGED
|
@@ -740,12 +740,40 @@ export async function runSetup(root, opts = {}) {
|
|
|
740
740
|
log(c.dim('Re-run anytime: `yad check` (report) / `yad check --fix` (reconcile).'));
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
+
// The repomix pack is a large, regenerable artifact — the hub tracks the AI-authored code-map, not the
|
|
744
|
+
// pack. `yad repo refresh --push` relies on the pack being gitignored (repo-publish.mjs never stages it);
|
|
745
|
+
// this makes that assumption true in every hub, so a regenerated pack never strands as a dirty tree.
|
|
746
|
+
export const PACK_IGNORE_GLOB = '.sdlc/code-context/*/pack.md';
|
|
747
|
+
|
|
748
|
+
// The exact lines ensurePackIgnored appends — a comment pair + the glob. Kept as data (not inline
|
|
749
|
+
// strings) so the publish gate can verify a staged `.gitignore` change is ONLY this managed block and
|
|
750
|
+
// never sweep an unrelated user edit into the audit commit (repo-publish.mjs, invariant 1).
|
|
751
|
+
export const PACK_IGNORE_BLOCK = [
|
|
752
|
+
'# Repomix code-context packs are large, regenerable artifacts (yad repo refresh) — the',
|
|
753
|
+
'# tracked code-map.md is the reviewed AI output; the pack itself is never committed.',
|
|
754
|
+
PACK_IGNORE_GLOB,
|
|
755
|
+
];
|
|
756
|
+
|
|
757
|
+
// Idempotently ensure the hub `.gitignore` ignores the repomix pack. No-op (returns false) if the line
|
|
758
|
+
// is already present (as its own entry); otherwise appends the managed block to a fresh or existing file
|
|
759
|
+
// and returns true.
|
|
760
|
+
export function ensurePackIgnored(root) {
|
|
761
|
+
const gi = path.join(root, '.gitignore');
|
|
762
|
+
const lines = exists(gi) ? fs.readFileSync(gi, 'utf8').split('\n') : [];
|
|
763
|
+
if (lines.some((l) => l.trim() === PACK_IGNORE_GLOB)) return false;
|
|
764
|
+
const body = lines.join('\n').replace(/\n*$/, '');
|
|
765
|
+
const prefix = body ? `${body}\n\n` : '';
|
|
766
|
+
fs.writeFileSync(gi, `${prefix}${PACK_IGNORE_BLOCK.join('\n')}\n`);
|
|
767
|
+
return true;
|
|
768
|
+
}
|
|
769
|
+
|
|
743
770
|
// Deterministic repomix pack (code-map generation itself is an AI step, handed off).
|
|
744
771
|
export function packRepo(root, repo) {
|
|
745
772
|
const repoRoot = path.resolve(root, repo.path);
|
|
746
773
|
const out = path.join(root, repo.contextPack);
|
|
747
774
|
if (!has('npx')) { warn(`${repo.name}: npx missing — skipped repomix pack`); return false; }
|
|
748
775
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
776
|
+
ensurePackIgnored(root); // keep the pack out of git before it is (re)written — see repo-publish.mjs invariant 1
|
|
749
777
|
info(`${repo.name}: packing with repomix …`);
|
|
750
778
|
const r = run('npx', ['repomix@latest', '--compress', '--include-logs', '--style', 'markdown', '-o', out], { cwd: repoRoot });
|
|
751
779
|
if (r.ok) { ok(`${repo.name}: cached ${repo.contextPack}`); hand(`${repo.name}: generate the code-map in Claude Code (yad-connect-repos)`); return true; }
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// "A new yadflow is out" — the update disclaimer printed after every `yad` command.
|
|
2
|
+
//
|
|
3
|
+
// Three rules make this safe to run on every invocation:
|
|
4
|
+
// 1. It never throws and never touches process.exitCode. A dead registry, an unwritable home, or a
|
|
5
|
+
// malformed cache degrades to silence, never to a failed command.
|
|
6
|
+
// 2. It prints to STDERR (the `note()` convention in lib.mjs), so `--json` commands, the grounding
|
|
7
|
+
// bundles, and `yad -v` keep a machine-readable STDOUT.
|
|
8
|
+
// 3. It is cache-first: the network is touched at most once per TTL. Every other run is pure disk.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately NOT suppressed on a non-TTY. Skills invoke `yad` through an agent's Bash tool, where
|
|
11
|
+
// stdout/stderr are piped — the usual "only notify on a TTY" guard would hide the notice from exactly
|
|
12
|
+
// the case we most want it in. `CI` is the suppression signal instead.
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { VERSION, PKG_NAME, UPSTREAM_REPO } from './manifest.mjs';
|
|
16
|
+
import { c, exists, readJSON, writeJSON, PKG_ROOT } from './lib.mjs';
|
|
17
|
+
|
|
18
|
+
export const DAY_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
const FETCH_TIMEOUT_MS = 1500;
|
|
20
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
21
|
+
|
|
22
|
+
// An env var counts as "set" only when it carries a meaningful value — `CI=false` and `CI=0` are
|
|
23
|
+
// common in shells that always export the name.
|
|
24
|
+
const truthy = (v) => !!v && v !== '0' && v !== 'false';
|
|
25
|
+
|
|
26
|
+
// ---- semver -------------------------------------------------------------
|
|
27
|
+
// A deliberately small parser: we only ever compare a released `x.y.z` against another. Anything the
|
|
28
|
+
// registry hands us that is not a clean triple (garbage, a range, undefined) yields null → no notice.
|
|
29
|
+
export function parseVersion(v) {
|
|
30
|
+
if (typeof v !== 'string') return null;
|
|
31
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(v.trim());
|
|
32
|
+
if (!m) return null;
|
|
33
|
+
return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ?? null };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The canonical `x.y.z` form. `parseVersion` tolerates a leading `v`, so anything interpolated into
|
|
37
|
+
// the banner or the release-tag URL must be normalized first — otherwise a `v`-prefixed `latest`
|
|
38
|
+
// (from a mirror registry or a hand-edited cache) yields a dead `.../releases/tag/vv3.11.0` link.
|
|
39
|
+
export function normalizeVersion(v) {
|
|
40
|
+
const p = parseVersion(v);
|
|
41
|
+
return p ? `${p.major}.${p.minor}.${p.patch}${p.pre ? `-${p.pre}` : ''}` : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// True when `latest` is a release strictly newer than `current`. A prerelease `latest` never nags a
|
|
45
|
+
// user on a stable version — dist-tags.latest should never be one, but a mis-tagged publish would
|
|
46
|
+
// otherwise pester every user until it was fixed. Prereleases are not ordered against each other
|
|
47
|
+
// (rc.2 does not "beat" rc.1); the only prerelease transition we announce is rc → its stable.
|
|
48
|
+
export function isNewer(latest, current) {
|
|
49
|
+
const l = parseVersion(latest);
|
|
50
|
+
const cur = parseVersion(current);
|
|
51
|
+
if (!l || !cur) return false;
|
|
52
|
+
if (l.pre && !cur.pre) return false;
|
|
53
|
+
if (l.major !== cur.major) return l.major > cur.major;
|
|
54
|
+
if (l.minor !== cur.minor) return l.minor > cur.minor;
|
|
55
|
+
if (l.patch !== cur.patch) return l.patch > cur.patch;
|
|
56
|
+
// Same x.y.z: the stable release supersedes the prerelease of that same version, so a user sitting
|
|
57
|
+
// on 4.0.0-rc.1 is told when 4.0.0 final ships.
|
|
58
|
+
return !l.pre && !!cur.pre;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- registry -----------------------------------------------------------
|
|
62
|
+
export function registryBase({ env = process.env } = {}) {
|
|
63
|
+
const base = env.YAD_REGISTRY_URL || env.npm_config_registry || DEFAULT_REGISTRY;
|
|
64
|
+
return base.replace(/\/+$/, '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The `dist-tags` endpoint returns a few dozen bytes (`{"latest":"3.10.1"}`); the packument at
|
|
68
|
+
// /<pkg> or /<pkg>/latest is orders of magnitude larger for the same one field.
|
|
69
|
+
// `fetchImpl` must NOT default to a bare `fetch` in the parameter list: default parameters are
|
|
70
|
+
// evaluated before the function body's try/catch is entered, so on a runtime without a global fetch
|
|
71
|
+
// (Node 18 started with --no-experimental-fetch) that would throw a ReferenceError straight past
|
|
72
|
+
// every guard here and out through bin/yad.mjs's .finally. Resolve it inside the try instead.
|
|
73
|
+
export async function fetchLatest({ env = process.env, timeoutMs = FETCH_TIMEOUT_MS, fetchImpl } = {}) {
|
|
74
|
+
try {
|
|
75
|
+
const doFetch = fetchImpl ?? globalThis.fetch;
|
|
76
|
+
if (typeof doFetch !== 'function') return null; // no fetch on this runtime — stay quiet
|
|
77
|
+
const url = `${registryBase({ env })}/-/package/${encodeURIComponent(PKG_NAME)}/dist-tags`;
|
|
78
|
+
const res = await doFetch(url, {
|
|
79
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
80
|
+
headers: { accept: 'application/json' },
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) return null;
|
|
83
|
+
const tags = await res.json();
|
|
84
|
+
return typeof tags?.latest === 'string' ? tags.latest : null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null; // offline, DNS failure, timeout, non-JSON body — all mean "we don't know", not "fail"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- cache --------------------------------------------------------------
|
|
91
|
+
// The CLI's only per-user state. Everything else it writes is project-scoped under .sdlc/.
|
|
92
|
+
export function cacheFile({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
|
|
93
|
+
if (env.YAD_CACHE_DIR) return path.join(env.YAD_CACHE_DIR, 'update-check.json');
|
|
94
|
+
if (env.XDG_CACHE_HOME) return path.join(env.XDG_CACHE_HOME, 'yadflow', 'update-check.json');
|
|
95
|
+
if (platform === 'win32' && env.LOCALAPPDATA) return path.join(env.LOCALAPPDATA, 'yadflow', 'update-check.json');
|
|
96
|
+
return path.join(home, '.cache', 'yadflow', 'update-check.json');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const readCache = (file) => readJSON(file, null);
|
|
100
|
+
|
|
101
|
+
// A read-only home (CI images, locked-down laptops, a root-owned ~/.cache) must not break `yad`.
|
|
102
|
+
// Losing the cache only costs one registry round-trip per command.
|
|
103
|
+
export function writeCache(file, data) {
|
|
104
|
+
try {
|
|
105
|
+
writeJSON(file, data);
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- suppression --------------------------------------------------------
|
|
113
|
+
// `pkgRoot` carrying a .git means yad is running from a source checkout (`npm run yad`, the test
|
|
114
|
+
// suite's execFileSync calls), not from a global npm install. Nagging a maintainer about the version
|
|
115
|
+
// they are editing is noise.
|
|
116
|
+
export function shouldSuppress({ env = process.env, pkgRoot = PKG_ROOT } = {}) {
|
|
117
|
+
if (truthy(env.YAD_NO_UPDATE_NOTIFIER)) return true;
|
|
118
|
+
if (truthy(env.CI)) return true;
|
|
119
|
+
if (truthy(env.SDLC_NONINTERACTIVE)) return true;
|
|
120
|
+
if (exists(path.join(pkgRoot, '.git'))) return true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- banner -------------------------------------------------------------
|
|
125
|
+
// `yad update` is the necessary second half: upgrading the global CLI leaves this project's installed
|
|
126
|
+
// yad-* skills stamped at the old version in .sdlc/cli-version.json, which `yad doctor` then flags.
|
|
127
|
+
export function formatBanner(current, latest) {
|
|
128
|
+
// Normalize so a `v`-prefixed input can never produce `.../releases/tag/vv3.11.0`. Callers only
|
|
129
|
+
// reach here after isNewer(), so parseVersion has already accepted both — the ?? is belt and braces.
|
|
130
|
+
const v = normalizeVersion(latest) ?? latest;
|
|
131
|
+
const url = `https://github.com/${UPSTREAM_REPO}/releases/tag/v${v}`;
|
|
132
|
+
return [
|
|
133
|
+
'',
|
|
134
|
+
` ${c.yellow('!')} ${c.bold(`${PKG_NAME} update available`)} — ${c.dim(current)} → ${c.green(v)}`,
|
|
135
|
+
` ${c.dim('Changelog:')} ${url}`,
|
|
136
|
+
` ${c.dim('Update:')} ${c.cyan(`npm install ${PKG_NAME} -g`)}`,
|
|
137
|
+
` ${c.dim('Then:')} ${c.cyan('yad update')} ${c.dim("(re-sync this project's yad-* skills)")}`,
|
|
138
|
+
].join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- orchestrator -------------------------------------------------------
|
|
142
|
+
// Returns true when a banner was printed (tests assert on this; callers ignore it).
|
|
143
|
+
export async function maybeNotifyUpdate({
|
|
144
|
+
env = process.env,
|
|
145
|
+
now = Date.now(),
|
|
146
|
+
pkgRoot = PKG_ROOT,
|
|
147
|
+
ttlMs = DAY_MS,
|
|
148
|
+
current = VERSION,
|
|
149
|
+
out = (s) => console.error(s),
|
|
150
|
+
fetchImpl, // resolved to globalThis.fetch inside fetchLatest — see the note there
|
|
151
|
+
} = {}) {
|
|
152
|
+
try {
|
|
153
|
+
if (shouldSuppress({ env, pkgRoot })) return false;
|
|
154
|
+
|
|
155
|
+
const file = cacheFile({ env });
|
|
156
|
+
const cache = readCache(file);
|
|
157
|
+
// `age >= 0` matters: a lastCheck stamped in the future (a clock that jumped forward, an NTP
|
|
158
|
+
// correction, a cache synced from another machine) yields a negative age, which would read as
|
|
159
|
+
// "fresh" and pin a stale `latest` until real time caught up. Treat it as expired instead.
|
|
160
|
+
const age = now - cache?.lastCheck;
|
|
161
|
+
const fresh = Number.isFinite(cache?.lastCheck) && age >= 0 && age < ttlMs;
|
|
162
|
+
|
|
163
|
+
let latest = typeof cache?.latest === 'string' ? cache.latest : null;
|
|
164
|
+
if (!fresh) {
|
|
165
|
+
const fetched = await fetchLatest({ env, fetchImpl });
|
|
166
|
+
if (fetched) latest = fetched;
|
|
167
|
+
// Stamp lastCheck even when the fetch failed: an offline user would otherwise pay the full
|
|
168
|
+
// timeout on every single command. We keep any previously-known `latest` so the banner survives
|
|
169
|
+
// a temporary outage.
|
|
170
|
+
writeCache(file, { lastCheck: now, latest });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!isNewer(latest, current)) return false;
|
|
174
|
+
out(formatBanner(current, latest));
|
|
175
|
+
return true;
|
|
176
|
+
} catch {
|
|
177
|
+
return false; // never let the notifier turn a successful command into a failed one
|
|
178
|
+
}
|
|
179
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.0",
|
|
4
4
|
"description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
@@ -215,7 +215,11 @@ The gates run identically under either CI; the config just invokes the scripts w
|
|
|
215
215
|
|
|
216
216
|
- **GitHub Actions** — `templates/github/yad-checks.yml` → `.github/workflows/yad-checks.yml`. The
|
|
217
217
|
jobs run on `pull_request` with `fetch-depth: 0`, passing `origin/${{ github.base_ref }}` as base
|
|
218
|
-
(verified-commits also gets a read-only `GH_TOKEN` for the Verified-badge lookup). The
|
|
218
|
+
(verified-commits also gets a read-only `GH_TOKEN` for the Verified-badge lookup). The trigger sets
|
|
219
|
+
`types: [opened, synchronize, reopened, edited]` — the extra `edited` so a title/body correction
|
|
220
|
+
re-runs the pattern gates without a close/reopen (a plain re-run replays the frozen original payload).
|
|
221
|
+
The commit-range jobs carry `if: github.event.action != 'edited'` so a bare body/title edit only
|
|
222
|
+
re-runs `pr-title`/`pr-template`, not the whole suite. The pattern jobs
|
|
219
223
|
read the title/body from the event payload: `pr-title` takes `${{ github.event.pull_request.title }}`
|
|
220
224
|
and `pr-template` writes `${{ github.event.pull_request.body }}` to a temp file. All `--profile code`.
|
|
221
225
|
The Phase 6 thread gates (`lineage-check`, `epic-open`, `reconcile-debt`) run as their own jobs with
|
|
@@ -6,11 +6,16 @@
|
|
|
6
6
|
name: yad-checks
|
|
7
7
|
on:
|
|
8
8
|
pull_request:
|
|
9
|
+
# `edited` (beyond the opened/synchronize/reopened defaults) so a PR title/body correction
|
|
10
|
+
# re-runs the pattern gates without a close/reopen — e.g. fixing a body the pr-template gate held.
|
|
11
|
+
# The commit-range jobs below skip a bare `edited` (only pr-title/pr-template need to re-check).
|
|
12
|
+
types: [opened, synchronize, reopened, edited]
|
|
9
13
|
branches: ["**"]
|
|
10
14
|
|
|
11
15
|
jobs:
|
|
12
16
|
spec-link:
|
|
13
17
|
runs-on: ubuntu-latest
|
|
18
|
+
if: github.event.action != 'edited'
|
|
14
19
|
steps:
|
|
15
20
|
- uses: actions/checkout@v4
|
|
16
21
|
with: { fetch-depth: 0 }
|
|
@@ -18,6 +23,7 @@ jobs:
|
|
|
18
23
|
|
|
19
24
|
contract-check:
|
|
20
25
|
runs-on: ubuntu-latest
|
|
26
|
+
if: github.event.action != 'edited'
|
|
21
27
|
steps:
|
|
22
28
|
- uses: actions/checkout@v4
|
|
23
29
|
with: { fetch-depth: 0 }
|
|
@@ -25,6 +31,7 @@ jobs:
|
|
|
25
31
|
|
|
26
32
|
build-test-lint:
|
|
27
33
|
runs-on: ubuntu-latest
|
|
34
|
+
if: github.event.action != 'edited'
|
|
28
35
|
env:
|
|
29
36
|
YAD_TEST_MAX_WORKERS: "2" # cap jest/vitest test workers in CI; ignored by other runners
|
|
30
37
|
steps:
|
|
@@ -41,6 +48,7 @@ jobs:
|
|
|
41
48
|
# story->epic resolution and degrade to a note when the product repo is not reachable from CI.
|
|
42
49
|
lineage-check:
|
|
43
50
|
runs-on: ubuntu-latest
|
|
51
|
+
if: github.event.action != 'edited'
|
|
44
52
|
steps:
|
|
45
53
|
- uses: actions/checkout@v4
|
|
46
54
|
with: { fetch-depth: 0 }
|
|
@@ -48,6 +56,7 @@ jobs:
|
|
|
48
56
|
|
|
49
57
|
epic-open:
|
|
50
58
|
runs-on: ubuntu-latest
|
|
59
|
+
if: github.event.action != 'edited'
|
|
51
60
|
steps:
|
|
52
61
|
- uses: actions/checkout@v4
|
|
53
62
|
with: { fetch-depth: 0 }
|
|
@@ -55,14 +64,17 @@ jobs:
|
|
|
55
64
|
|
|
56
65
|
reconcile-debt:
|
|
57
66
|
runs-on: ubuntu-latest
|
|
67
|
+
if: github.event.action != 'edited'
|
|
58
68
|
steps:
|
|
59
69
|
- uses: actions/checkout@v4
|
|
60
70
|
with: { fetch-depth: 0 }
|
|
61
71
|
- run: bash checks/reconcile-debt-check.sh "origin/${{ github.base_ref }}"
|
|
62
72
|
|
|
63
73
|
# Pattern gates: commit subject + PR title + PR body all follow the convention (profile: code).
|
|
74
|
+
# commit-message reads the commit range, not the title/body — skip it on a bare `edited` event.
|
|
64
75
|
commit-message:
|
|
65
76
|
runs-on: ubuntu-latest
|
|
77
|
+
if: github.event.action != 'edited'
|
|
66
78
|
steps:
|
|
67
79
|
- uses: actions/checkout@v4
|
|
68
80
|
with: { fetch-depth: 0 }
|
|
@@ -88,8 +100,10 @@ jobs:
|
|
|
88
100
|
bash checks/pr-template.sh --profile code "$body"
|
|
89
101
|
|
|
90
102
|
# No unverified commits from unverified users: platform-Verified signature + allowlisted author.
|
|
103
|
+
# Reads the commit range, not the title/body — skip it on a bare `edited` event.
|
|
91
104
|
verified-commits:
|
|
92
105
|
runs-on: ubuntu-latest
|
|
106
|
+
if: github.event.action != 'edited'
|
|
93
107
|
permissions:
|
|
94
108
|
contents: read
|
|
95
109
|
env:
|
|
@@ -82,7 +82,13 @@ side-effect:** when a repo is stale (HEAD ≠ `syncedHead`), the phase **flags i
|
|
|
82
82
|
silently re-packing the whole repo. A phase never refreshes the registry on its own; the human runs
|
|
83
83
|
`yad repo refresh` (or `yad check --fix`). After the AI regenerates the code-map, `yad repo refresh
|
|
84
84
|
--push` publishes the refreshed code-maps + registry to the hub's default branch as a `chore(hub): sync
|
|
85
|
-
code-context … [skip ci]` audit commit (never
|
|
85
|
+
code-context … [skip ci]` audit commit (never the pack's content; `--allow-branch` overrides the branch
|
|
86
|
+
guard). The `pack.md` is gitignored — `yad repo refresh`/`yad setup` scaffold
|
|
87
|
+
`.sdlc/code-context/*/pack.md` into the hub `.gitignore` (so a regenerated pack never dirties the tree),
|
|
88
|
+
and a hub that tracked the pack *before* that ignore existed is self-healed: `--push` untracks it and
|
|
89
|
+
lands the removal + the managed `.gitignore` line in the same audit commit. The commit is a scoped
|
|
90
|
+
`git commit -- <paths>` — it never sweeps unrelated staged work, and an unrelated hand-edit to
|
|
91
|
+
`.gitignore` is left for the human to commit rather than riding the `[skip ci]` audit commit.
|
|
86
92
|
|
|
87
93
|
## Why this stays DRY with backfill
|
|
88
94
|
|