@warnyin/sdlc 0.9.0 → 0.10.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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 (2026-09-14)
4
+
5
+ - **Feature (update notice)**: a project is now told when a newer `@warnyin/sdlc` exists. Once a
6
+ day the SessionStart hook hands a background process one request to the npm registry for the
7
+ `latest` version; the session never waits on it. When the installed version (recorded in the
8
+ new `sdlc/.hooks/version.json`) is older, the next session's context opens with one line
9
+ naming both versions and `npx @warnyin/sdlc@latest update`, and telling the agent to mention
10
+ it and not run it. It repeats at every session start (including resume and `/clear`) until
11
+ the project is updated or the check is off. Nothing updates by itself. Only a plain `X.Y.Z` from the registry is ever cached
12
+ or shown; redirects, bodies over 64 KiB and anything slow or broken leave silence. **Existing
13
+ installs are on by default** after `update`, because `update` never rewrites your
14
+ `sdlc/config.yaml`: add `updateCheck: false` there to switch it off, or set `CI` or
15
+ `NO_UPDATE_NOTIFIER`. Behind a proxy the check stays silent (Node's fetch ignores
16
+ `HTTPS_PROXY`). Claude Code only; other tools install no hooks. `update` now always rewrites
17
+ `sdlc/.hooks/version.json`, even where your other hook files are kept.
18
+ - **Release**: versions are now published by GitHub Actions from a pushed `vX.Y.Z` tag through
19
+ npm trusted publishing, with a provenance attestation that links each tarball to its commit
20
+ and workflow run. No npm token is involved.
21
+
3
22
  ## 0.9.0 (2026-09-14)
4
23
 
5
24
  - **Feature (new)**: `/sdlc:new` now asks its clarifying questions in rounds that follow
package/README.md CHANGED
@@ -89,4 +89,30 @@ This repo self-hosts: its own development flows through `sdlc/changes/`. After c
89
89
  `npm run setup:dogfood` to regenerate the installer-owned mirrors (`sdlc/.playbook/`,
90
90
  `sdlc/.hooks/`, `.claude/`).
91
91
 
92
+ ## Releasing
93
+
94
+ Pushing a plain `vX.Y.Z` tag publishes that version. `.github/workflows/release.yml` runs the CI
95
+ jobs as a gate, checks the tag names `package.json`'s version, then runs `npm publish` with
96
+ provenance through npm trusted publishing. No npm token lives in the repo or its secrets.
97
+
98
+ One-time setup, by a package owner, before the first release tag is pushed (until then the
99
+ publish step fails and nothing is released):
100
+ 1. On npmjs.com, open `@warnyin/sdlc` → Settings → Trusted Publisher → GitHub Actions and enter
101
+ organization/user `warnyin`, repository `warnyin-sdlc`, workflow filename `release.yml`,
102
+ no environment. If asked which actions to allow, allow `npm publish`, not stage-only.
103
+ 2. Optional, after the first release by tag succeeds: under Publishing access, choose
104
+ "Require two-factor authentication and disallow tokens".
105
+ 3. On GitHub, add a tag ruleset for `v*` so only maintainers can create or move release tags —
106
+ whoever can push the tag can publish.
107
+
108
+ Each release:
109
+ 1. Bump `version` in `package.json`, add the `CHANGELOG.md` entry, commit `chore(release): X.Y.Z`.
110
+ 2. `git tag vX.Y.Z && git push origin main vX.Y.Z`
111
+ 3. Watch the `release` run in GitHub Actions, then confirm with `npm view @warnyin/sdlc version`.
112
+
113
+ A tag that disagrees with `package.json`, a pre-release tag, a tag not on `main`, or a red gate
114
+ publishes nothing. Every release becomes `latest`, so cut releases from `main` only. A published
115
+ version can never be reused: to back one out, `npm deprecate @warnyin/sdlc@X.Y.Z "<reason>"` and
116
+ release a fixed X.Y.Z+1.
117
+
92
118
  MIT
package/bin/cli.mjs CHANGED
@@ -230,6 +230,18 @@ function scaffoldSdlc(projectRoot, tools, ctx) {
230
230
  copyTree(path.join(PAYLOAD, 'templates'), path.join('sdlc', '.playbook', 'templates'), projectRoot, ctx);
231
231
  copyTree(path.join(PAYLOAD, 'hooks'), path.join('sdlc', '.hooks'), projectRoot, ctx);
232
232
  copyTree(path.join(PKG_ROOT, 'lib'), path.join('sdlc', '.hooks', 'lib'), projectRoot, ctx);
233
+ recordPayloadVersion(projectRoot, ctx);
234
+ }
235
+
236
+ // The hooks carry no package.json; the update notice compares against this. It is a record
237
+ // the CLI writes, not a user file, so it is rewritten every run instead of going through
238
+ // installFile's keep-if-different rule — otherwise a hand edit, or a clone whose gitignored
239
+ // manifest is missing, would freeze it and the notice would repeat after every update.
240
+ function recordPayloadVersion(projectRoot, ctx) {
241
+ const rel = path.join('sdlc', '.hooks', 'version.json');
242
+ const content = `${JSON.stringify({ version: pkgVersion() })}\n`;
243
+ writeFileNormalized(path.join(projectRoot, rel), content);
244
+ ctx.manifest.set(toPosix(rel), sha256(content));
233
245
  }
234
246
 
235
247
  export function writeManifestFile(projectRoot, manifest) {
@@ -0,0 +1,42 @@
1
+ // Pure decisions for the update notice: whether to check, whether a check is due, and the
2
+ // line to show. No fs, no network — the SessionStart hook and the detached checker own I/O.
3
+
4
+ import { parseVersion, compareVersions } from './version.mjs';
5
+
6
+ export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
7
+ export const PACKAGE_NAME = '@warnyin/sdlc';
8
+ export const CHECK_INTERVAL_MS = 24 * 3600_000;
9
+ export const FETCH_TIMEOUT_MS = 3000;
10
+ export const MAX_BODY_BYTES = 64 * 1024;
11
+
12
+ const OFF_VALUES = new Set(['false', 'no', 'off', '0']);
13
+ const set = (v) => typeof v === 'string' && v !== '';
14
+
15
+ const unquote = (v) => String(v ?? '').trim().replace(/^(['"])(.*)\1$/, '$2').toLowerCase();
16
+
17
+ // `CI=false` / `CI=0` are how some runners say "not CI"; any other non-empty CI means CI.
18
+ export function isCheckDisabled(env = {}, config = {}) {
19
+ if (OFF_VALUES.has(unquote(config.updateCheck))) return true;
20
+ if (set(env.NO_UPDATE_NOTIFIER)) return true;
21
+ return set(env.CI) && !OFF_VALUES.has(env.CI.toLowerCase());
22
+ }
23
+
24
+ // Missing, unparsable or future timestamps are stale: a clock that jumped back must not
25
+ // silence the check until real time catches up.
26
+ export function isCheckDue(cache, now) {
27
+ const at = Date.parse(cache?.checkedAt);
28
+ if (Number.isNaN(at) || at > now) return true;
29
+ return now - at >= CHECK_INTERVAL_MS;
30
+ }
31
+
32
+ export function latestUrl(base) {
33
+ return `${String(base).replace(/\/+$/, '')}/${PACKAGE_NAME.replace('/', '%2F')}/latest`;
34
+ }
35
+
36
+ export function noticeLine(installed, latest) {
37
+ if (!parseVersion(installed) || !parseVersion(latest)) return null;
38
+ if (!(compareVersions(latest, installed) > 0)) return null;
39
+ // `@latest`: a bare `npx <pkg>` may resolve a local or cached copy and update to nothing.
40
+ return `[sdlc] ${PACKAGE_NAME} ${latest} is available (this project has ${installed}). Tell the user once; `
41
+ + `they can run \`npx ${PACKAGE_NAME}@latest update\` — do not run it yourself.`;
42
+ }
@@ -0,0 +1,23 @@
1
+ // Strict plain `X.Y.Z` versions. The update notice prints only what passes parseVersion, so
2
+ // this regex is the whole boundary between registry-supplied text and the agent's context:
3
+ // no pre-release, no build metadata, no leading zeros, at most 9 digits per part.
4
+
5
+ const PLAIN_VERSION = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/;
6
+
7
+ export function parseVersion(value) {
8
+ if (typeof value !== 'string') return null;
9
+ const m = value.match(PLAIN_VERSION);
10
+ return m ? m.slice(1).map(Number) : null;
11
+ }
12
+
13
+ // Negative, zero or positive like a sort comparator; NaN when either side is not a plain
14
+ // version, so `compareVersions(a, b) > 0` is false for anything unparsable.
15
+ export function compareVersions(a, b) {
16
+ const pa = parseVersion(a);
17
+ const pb = parseVersion(b);
18
+ if (!pa || !pb) return NaN;
19
+ for (let i = 0; i < 3; i++) {
20
+ if (pa[i] !== pb[i]) return pa[i] - pb[i];
21
+ }
22
+ return 0;
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warnyin/sdlc",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Spec-driven, AI-driven SDLC framework — token-lean specs, contract-first changes, autonomous pipeline with managed hooks. Operationalizes the Day-1 'New SDLC with Vibe Coding' work process.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,81 @@
1
+ // SessionStart side of the update notice: reads the cached check, and when it is due records
2
+ // the attempt and hands the network request to a detached `check-update.mjs`. Never waits on
3
+ // the network and never throws — any failure means no notice.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import process from 'node:process';
8
+ import { spawn } from 'node:child_process';
9
+ import { parseConfig } from './lib/config.mjs';
10
+ import { parseVersion } from './lib/version.mjs';
11
+ import { isCheckDisabled, isCheckDue, noticeLine } from './lib/update-notice.mjs';
12
+
13
+ export const cachePath = (sdlcRoot) => path.join(sdlcRoot, '.state', 'update-check.json');
14
+
15
+ export function readJsonFile(p) {
16
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
17
+ }
18
+
19
+ // `.state/` is gitignored, not unwritable: a repo can ship a link there. Write only into a
20
+ // `.state/` that already exists and really is `<sdlcRoot>/.state` (same rule as the change
21
+ // pointers in lib/active.mjs); never create it, so a deleted project is not resurrected.
22
+ function isRealStateDir(sdlcRoot) {
23
+ try {
24
+ const stateDir = path.join(sdlcRoot, '.state');
25
+ return fs.lstatSync(stateDir).isDirectory()
26
+ && fs.realpathSync.native(stateDir) === path.join(fs.realpathSync.native(sdlcRoot), '.state');
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ // Temp file + rename: a symlink at the destination is replaced rather than written through,
33
+ // and a concurrent reader sees the old file or the new one, never half of either.
34
+ export function writeCache(sdlcRoot, value) {
35
+ const p = cachePath(sdlcRoot);
36
+ if (!isRealStateDir(sdlcRoot)) return false;
37
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
38
+ try {
39
+ fs.writeFileSync(tmp, JSON.stringify(value), { flag: 'wx' });
40
+ fs.renameSync(tmp, p);
41
+ return true;
42
+ } catch {
43
+ fs.rmSync(tmp, { force: true });
44
+ return false;
45
+ }
46
+ }
47
+
48
+ function readConfig(sdlcRoot) {
49
+ try { return parseConfig(fs.readFileSync(path.join(sdlcRoot, 'config.yaml'), 'utf8')); } catch { return {}; }
50
+ }
51
+
52
+ // The checker talks to a third-party host; it gets only what a request needs, not the
53
+ // session's tokens. SYSTEMROOT keeps Windows sockets working; the CA var keeps corporate TLS.
54
+ const CHECKER_ENV = ['PATH', 'SYSTEMROOT', 'NODE_EXTRA_CA_CERTS', 'WARNYIN_SDLC_REGISTRY_URL'];
55
+ const checkerEnv = (env) => Object.fromEntries(CHECKER_ENV.filter((k) => env[k] !== undefined).map((k) => [k, env[k]]));
56
+
57
+ function startCheck(hooksDir) {
58
+ const child = spawn(process.execPath, [path.join(hooksDir, 'check-update.mjs')], {
59
+ detached: true, stdio: 'ignore', windowsHide: true, env: checkerEnv(process.env),
60
+ });
61
+ child.on('error', () => {});
62
+ child.unref();
63
+ }
64
+
65
+ export function updateNotice({ sdlcRoot, hooksDir, now = Date.now() }) {
66
+ try {
67
+ if (isCheckDisabled(process.env, readConfig(sdlcRoot))) return null;
68
+ const installed = readJsonFile(path.join(hooksDir, 'version.json'))?.version;
69
+ if (!parseVersion(installed)) return null;
70
+ const cache = readJsonFile(cachePath(sdlcRoot));
71
+ const latest = parseVersion(cache?.latest) ? cache.latest : undefined;
72
+ // No recorded attempt, no request: a cache that cannot be written would otherwise mean a
73
+ // request on every session.
74
+ if (isCheckDue(cache, now) && writeCache(sdlcRoot, { checkedAt: new Date(now).toISOString(), latest })) {
75
+ startCheck(hooksDir);
76
+ }
77
+ return noticeLine(installed, latest);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // Detached from SessionStart by _update-notice.mjs — not a registered hook. Asks the registry
3
+ // for the latest published version and caches it only when it is a plain X.Y.Z. Prints
4
+ // nothing, follows no redirect, reads at most MAX_BODY_BYTES, and gives up after
5
+ // FETCH_TIMEOUT_MS. Every failure leaves the cache as the hook left it.
6
+
7
+ import process from 'node:process';
8
+ import { resolveRoots } from './_shared.mjs';
9
+ import { cachePath, readJsonFile, writeCache } from './_update-notice.mjs';
10
+ import { parseVersion } from './lib/version.mjs';
11
+ import {
12
+ DEFAULT_REGISTRY, FETCH_TIMEOUT_MS, MAX_BODY_BYTES, latestUrl,
13
+ } from './lib/update-notice.mjs';
14
+
15
+ const { sdlcRoot } = resolveRoots(import.meta.url);
16
+
17
+ async function readCapped(body, limit) {
18
+ const chunks = [];
19
+ let size = 0;
20
+ for await (const chunk of body) {
21
+ size += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.byteLength;
22
+ if (size > limit) return null;
23
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
24
+ }
25
+ return Buffer.concat(chunks).toString('utf8');
26
+ }
27
+
28
+ async function main() {
29
+ const url = new URL(latestUrl(process.env.WARNYIN_SDLC_REGISTRY_URL || DEFAULT_REGISTRY));
30
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return; // fetch also reads data:
31
+ const res = await fetch(url, {
32
+ redirect: 'error',
33
+ headers: { accept: 'application/json' },
34
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
35
+ });
36
+ if (!res.ok || !res.body) return;
37
+ const text = await readCapped(res.body, MAX_BODY_BYTES);
38
+ if (text === null) return;
39
+ const version = JSON.parse(text)?.version;
40
+ if (!parseVersion(version)) return;
41
+ const cache = readJsonFile(cachePath(sdlcRoot));
42
+ writeCache(sdlcRoot, { checkedAt: cache?.checkedAt ?? new Date().toISOString(), latest: version });
43
+ }
44
+
45
+ main().catch(() => {}).finally(() => process.exit(0)); // fail open, silently
@@ -2,7 +2,8 @@
2
2
  // SessionStart hook — THE static-context loader. Emits (hard cap 60 lines):
3
3
  // constitution + every `inclusion: always` steering file + a one-line
4
4
  // pointer to the active change. Everything else stays dynamic.
5
- // Journals what was injected so /sdlc:observe can price residency honestly.
5
+ // Journals what was injected so /sdlc:observe can price residency honestly. When a newer
6
+ // framework version is known, one notice line leads the output, outside the budget.
6
7
 
7
8
  import fs from 'node:fs';
8
9
  import path from 'node:path';
@@ -11,8 +12,9 @@ import { resolveRoots, readStdinJson, activeChange, appendJournal } from './_sha
11
12
  import { parseFrontmatter } from './lib/frontmatter.mjs';
12
13
  import { CAPS } from './lib/caps.mjs';
13
14
  import { pickSessionId } from './lib/active.mjs';
15
+ import { updateNotice } from './_update-notice.mjs';
14
16
 
15
- const { sdlcRoot } = resolveRoots(import.meta.url);
17
+ const { sdlcRoot, hooksDir } = resolveRoots(import.meta.url);
16
18
 
17
19
  async function main() {
18
20
  const input = await readStdinJson();
@@ -42,16 +44,18 @@ async function main() {
42
44
  const active = activeChange(sdlcRoot, sessionId);
43
45
  if (active) out.push(`Active change: sdlc/changes/${active}/change.md — run /sdlc:next for status.`);
44
46
 
45
- if (!out.length) return;
47
+ const notice = updateNotice({ sdlcRoot, hooksDir });
48
+ if (!out.length && !notice) return;
46
49
 
47
- let lines = out.join('\n\n').split('\n');
50
+ let lines = out.length ? out.join('\n\n').split('\n') : [];
48
51
  if (lines.length > CAPS.alwaysBudget) {
49
52
  lines = lines.slice(0, CAPS.alwaysBudget);
50
53
  lines.push(`[sdlc] static context truncated at ${CAPS.alwaysBudget} lines — run /sdlc:steer to distill (validate also flags this).`);
51
54
  }
52
- console.log(lines.join('\n'));
55
+ console.log((notice ? [notice, ...lines] : lines).join('\n'));
53
56
 
54
- appendJournal(sdlcRoot, active, { event: 'inject', files: injected, lines: lines.length });
57
+ // `lines` stays the budgeted count; the notice is recorded on its own, outside the budget.
58
+ appendJournal(sdlcRoot, active, { event: 'inject', files: injected, lines: lines.length, notice: Boolean(notice) });
55
59
  }
56
60
 
57
61
  main().catch(() => process.exit(0)); // fail open
@@ -4,5 +4,9 @@ tools: [] # filled by `warnyin-sdlc init`
4
4
  # Optional price table for cost reporting (USD per 1M tokens).
5
5
  # prices:
6
6
  # claude-sonnet-5: { input: 3, output: 15, cacheRead: 0.3 }
7
+ # Once a day a SessionStart hook asks the npm registry whether a newer @warnyin/sdlc exists
8
+ # and, if so, tells the agent to mention `npx @warnyin/sdlc update`. It never updates by
9
+ # itself. Uncomment to switch it off (CI or NO_UPDATE_NOTIFIER in the env also do).
10
+ # updateCheck: false
7
11
  # Optional per-artifact cap overrides (see lib/caps.mjs for defaults).
8
12
  # caps: {}