wize-dev-kit 0.26.0 → 0.27.2

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.
Files changed (71) hide show
  1. package/ARCH.md +2 -1
  2. package/CHANGELOG.md +70 -0
  3. package/README.es.md +28 -6
  4. package/README.md +28 -6
  5. package/README.pt-BR.md +28 -6
  6. package/adapters/continue/render.js +2 -3
  7. package/adapters/copilot/render.js +3 -3
  8. package/adapters/cursor/render.js +2 -3
  9. package/adapters/generic/render.js +2 -3
  10. package/adapters/opencode/render.js +3 -4
  11. package/adapters/windsurf/render.js +2 -3
  12. package/docs/WORKTREES.md +150 -0
  13. package/docs/WORKTREES.pt-BR.md +150 -0
  14. package/docs/harnesses/antigravity.md +2 -0
  15. package/docs/harnesses/antigravity.pt-BR.md +2 -0
  16. package/docs/harnesses/claude-code.md +2 -0
  17. package/docs/harnesses/claude-code.pt-BR.md +2 -0
  18. package/docs/harnesses/codex.md +3 -1
  19. package/docs/harnesses/codex.pt-BR.md +3 -1
  20. package/docs/harnesses/continue.md +2 -0
  21. package/docs/harnesses/continue.pt-BR.md +2 -0
  22. package/docs/harnesses/copilot.md +2 -0
  23. package/docs/harnesses/copilot.pt-BR.md +2 -0
  24. package/docs/harnesses/cursor.md +2 -0
  25. package/docs/harnesses/cursor.pt-BR.md +2 -0
  26. package/docs/harnesses/generic.md +2 -0
  27. package/docs/harnesses/generic.pt-BR.md +2 -0
  28. package/docs/harnesses/hermes.md +2 -0
  29. package/docs/harnesses/hermes.pt-BR.md +2 -0
  30. package/docs/harnesses/kimi-code.md +2 -0
  31. package/docs/harnesses/kimi-code.pt-BR.md +2 -0
  32. package/docs/harnesses/kiro.md +2 -0
  33. package/docs/harnesses/kiro.pt-BR.md +2 -0
  34. package/docs/harnesses/opencode.md +2 -0
  35. package/docs/harnesses/opencode.pt-BR.md +2 -0
  36. package/docs/harnesses/windsurf.md +2 -0
  37. package/docs/harnesses/windsurf.pt-BR.md +2 -0
  38. package/package.json +1 -1
  39. package/src/orchestrator-skills/module.yaml +1 -1
  40. package/src/orchestrator-skills/wize/skill.md +17 -7
  41. package/src/orchestrator-skills/wize-help/skill.md +20 -7
  42. package/src/orchestrator-skills/wize-update/skill.md +33 -27
  43. package/src/security-overlay/skills/wize-sec-recon/scripts/run-recon.js +4 -4
  44. package/src/security-overlay/skills/wize-sec-recon/skill.md +1 -1
  45. package/tools/installer/commands/doctor.js +186 -40
  46. package/tools/installer/commands/document-project.js +13 -3
  47. package/tools/installer/commands/hooks.js +215 -0
  48. package/tools/installer/commands/hydrate.js +298 -0
  49. package/tools/installer/commands/sync.js +50 -23
  50. package/tools/installer/commands/update.js +596 -120
  51. package/tools/installer/commands/version-check.js +69 -10
  52. package/tools/installer/document-project/modes/full-rescan.js +1 -1
  53. package/tools/installer/document-project/modes/initial-scan.js +5 -4
  54. package/tools/installer/document-project/modes/quick.js +11 -4
  55. package/tools/installer/git-context.js +219 -0
  56. package/tools/installer/git-ops.js +211 -0
  57. package/tools/installer/install-plan.js +136 -0
  58. package/tools/installer/marker.js +67 -0
  59. package/tools/installer/project-root.js +68 -0
  60. package/tools/installer/project-status.js +171 -0
  61. package/tools/installer/render-shared.js +170 -4
  62. package/tools/installer/render-targets.js +112 -0
  63. package/tools/installer/semver.js +59 -0
  64. package/tools/installer/setup-helpers.js +89 -31
  65. package/tools/installer/templates/hydrate.sh +163 -0
  66. package/tools/installer/templates/wize-kit-update.yml +38 -0
  67. package/tools/installer/toml.js +133 -0
  68. package/tools/installer/util.js +16 -0
  69. package/tools/installer/version-check.js +167 -37
  70. package/tools/installer/wize-cli.js +216 -78
  71. package/tools/installer/sync.js +0 -29
@@ -0,0 +1,136 @@
1
+ // Pure decisions behind `wize-dev-kit install` / `uninstall`: where the kit
2
+ // may be installed, what an existing project.toml means for a re-run, how the
3
+ // project and user names are derived and when the baseline runs. wize-cli.js
4
+ // runs main() at load time, so everything testable lives here.
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const { gitContext, configUserName } = require('./git-context.js');
11
+ const { findProjectRoot, findKitElsewhere } = require('./project-root.js');
12
+ const { loadProjectConfig } = require('./toml.js');
13
+ const { real, sameSet } = require('./util.js');
14
+
15
+ const hasProjectToml = (dir) => fs.existsSync(path.join(dir, '.wize', 'config', 'project.toml'));
16
+
17
+ // -> { status, ctx, installed, root?, toplevel?, message? }
18
+ // installed cwd already holds project.toml
19
+ // nested an ancestor (inside the git checkout) holds it
20
+ // subdir cwd is inside a git checkout but is not its top level
21
+ // primary linked worktree whose branch lacks project.toml while the
22
+ // default-branch (or any other non-bare) worktree has it (merge the kit, or pass --here)
23
+ // ok fresh install at cwd
24
+ // `--here` skips the nested, subdir and primary checks.
25
+ function checkInstallRoot({ cwd, flags = {} }) {
26
+ const here = real(cwd);
27
+ const ctx = gitContext(here);
28
+ if (hasProjectToml(here)) return { status: 'installed', installed: true, ctx, root: here };
29
+ if (flags.here) return { status: 'ok', installed: false, ctx };
30
+
31
+ const found = findProjectRoot(here);
32
+ if (found && found !== here) {
33
+ return { status: 'nested', installed: false, ctx, root: found, message: `kit already installed at ${found}; run from there` };
34
+ }
35
+ const primary = findKitElsewhere(here, ctx);
36
+ if (primary) {
37
+ return {
38
+ status: 'primary', installed: false, ctx, root: primary,
39
+ message: `kit is installed on ${primary}'s branch; merge it into this branch, or pass --here to install here`
40
+ };
41
+ }
42
+ if (ctx.isRepo && ctx.toplevel && real(ctx.toplevel) !== here) {
43
+ return {
44
+ status: 'subdir', installed: false, ctx, toplevel: real(ctx.toplevel),
45
+ message: `run install from the repo root ${real(ctx.toplevel)} (or pass --here)`
46
+ };
47
+ }
48
+ return { status: 'ok', installed: false, ctx };
49
+ }
50
+
51
+ const csv = (v) => (typeof v === 'string' ? v.split(',').map(s => s.trim()).filter(Boolean) : []);
52
+
53
+ // Flags that would change what an installed project is configured for.
54
+ // Returns the list of conflicting flag names (empty = nothing to reject).
55
+ function conflictingFlags({ flags = {}, root }) {
56
+ let cfg;
57
+ try { cfg = loadProjectConfig(root); } catch { cfg = {}; }
58
+ const install = (cfg && cfg.install) || {};
59
+ const out = [];
60
+ if (typeof flags.profiles === 'string') {
61
+ const want = [...csv(flags.profiles), 'core'];
62
+ if (!sameSet(want, [...(install.profiles || ['core']), 'core'])) out.push('--profiles');
63
+ }
64
+ if (typeof flags.targets === 'string') {
65
+ if (!sameSet(csv(flags.targets), install.ide_targets || [])) out.push('--targets');
66
+ }
67
+ if (typeof flags.lang === 'string') {
68
+ const lang = cfg && cfg.language && cfg.language.communication;
69
+ if (flags.lang.trim() !== lang) out.push('--lang');
70
+ }
71
+ return out;
72
+ }
73
+
74
+ function installedPin(root) {
75
+ try {
76
+ const cfg = loadProjectConfig(root);
77
+ return (cfg.project && cfg.project.kit_version) || 'unknown';
78
+ } catch { return 'unknown'; }
79
+ }
80
+
81
+ // Default project name: the primary checkout's name in a linked worktree (the
82
+ // worktree directory is a throwaway), else the directory being installed.
83
+ function defaultProjectName({ cwd, ctx }) {
84
+ const c = ctx || gitContext(cwd);
85
+ if (c.isRepo && c.linked && c.primary) return path.basename(c.primary);
86
+ return path.basename(real(cwd));
87
+ }
88
+
89
+ const clean = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
90
+
91
+ // specific flag > legacy --name > derived
92
+ function resolveProjectName({ flags = {}, cwd, ctx }) {
93
+ return clean(flags['project-name']) || clean(flags.name) || defaultProjectName({ cwd, ctx });
94
+ }
95
+
96
+ // specific flag > legacy --name > git user.name > $USER > Developer. Without
97
+ // --yes and without a flag the caller prompts, so this returns null.
98
+ function resolveUserName({ flags = {}, cwd, env = process.env, nonInteractive = false }) {
99
+ const given = clean(flags['user-name']) || clean(flags.name);
100
+ if (given) return given;
101
+ if (!nonInteractive) return null;
102
+ return configUserName(cwd) || env.USER || env.USERNAME || 'Developer';
103
+ }
104
+
105
+ function baselineDocsExist(root) {
106
+ try {
107
+ return fs.readdirSync(path.join(root, '.wize', 'knowledge', 'document-project')).some(f => f.endsWith('.md'));
108
+ } catch { return false; }
109
+ }
110
+
111
+ // -> { run, reason }. WIZE_SKIP_BASELINE=1 always wins; an existing baseline
112
+ // blocks the run unless --baseline forces it; otherwise brownfield only.
113
+ function baselineDecision({ root, flags = {}, env = process.env, brownfield }) {
114
+ if (env.WIZE_SKIP_BASELINE === '1') return { run: false, reason: 'env' };
115
+ if (flags.baseline) return { run: true, reason: 'forced' };
116
+ if (!brownfield) return { run: false, reason: 'greenfield' };
117
+ if (baselineDocsExist(root)) return { run: false, reason: 'exists' };
118
+ return { run: true, reason: 'brownfield' };
119
+ }
120
+
121
+ // Uninstall target: an explicit --root / WIZE_PROJECT_ROOT, else the project
122
+ // root found from cwd (cwd itself when none). `refused` when cwd is not the
123
+ // root and nothing explicit was given.
124
+ function resolveUninstallRoot({ cwd, flags = {}, env = process.env }) {
125
+ const here = real(cwd);
126
+ const explicit = clean(flags.root) || clean(env.WIZE_PROJECT_ROOT);
127
+ if (explicit) return { root: real(path.resolve(here, explicit)), explicit: true, refused: false };
128
+ const found = findProjectRoot(here);
129
+ const root = found || here;
130
+ return { root, explicit: false, refused: root !== here };
131
+ }
132
+
133
+ module.exports = {
134
+ checkInstallRoot, conflictingFlags, installedPin, defaultProjectName,
135
+ resolveProjectName, resolveUserName, baselineDecision, resolveUninstallRoot
136
+ };
@@ -0,0 +1,67 @@
1
+ // Hydration marker: .wize/.local/hydrated.json records which kit version
2
+ // last rendered this checkout. .wize/.local/ is git-ignored, so each worktree
3
+ // owns its own marker. Reads never throw.
4
+
5
+ 'use strict';
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+
10
+ const SCHEMA = 1;
11
+
12
+ function markerPath(root) {
13
+ return path.join(root, '.wize', '.local', 'hydrated.json');
14
+ }
15
+
16
+ function readMarker(root) {
17
+ try {
18
+ const data = JSON.parse(fs.readFileSync(markerPath(root), 'utf-8'));
19
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ // The pin a render targeted. Fallback renders record it next to the binary that
26
+ // did the work, so a pin the binary cannot match does not read as stale. Older
27
+ // markers carry only kit_version.
28
+ const markerPin = (marker) => (typeof marker.pin === 'string' ? marker.pin : marker.kit_version);
29
+
30
+ // A fallback render stands in for the pinned kit while it cannot be run (offline,
31
+ // unpublished). It counts as current for FALLBACK_TTL_MS, then the next
32
+ // hydrate tries the exact pin again.
33
+ const FALLBACK_TTL_MS = 60 * 60 * 1000;
34
+
35
+ function fallbackExpired(marker, now = Date.now()) {
36
+ if (!marker || marker.fallback !== true) return false;
37
+ const at = Date.parse(marker.rendered_at);
38
+ return !Number.isFinite(at) || now - at > FALLBACK_TTL_MS;
39
+ }
40
+
41
+ function writeMarker(root, { kit_version, pin, targets, profiles, counts, delegated, rendered_with, fallback } = {}) {
42
+ const file = markerPath(root);
43
+ const payload = {
44
+ schema: SCHEMA,
45
+ kit_version: kit_version ?? null,
46
+ pin: pin ?? null,
47
+ targets: targets || [],
48
+ profiles: profiles || [],
49
+ counts: counts || {},
50
+ delegated: !!delegated,
51
+ rendered_with: rendered_with ?? null,
52
+ ...(fallback ? { fallback: true } : {}),
53
+ rendered_at: new Date().toISOString()
54
+ };
55
+ const tmp = path.join(path.dirname(file), `.hydrated.json.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
56
+ try {
57
+ fs.mkdirSync(path.dirname(file), { recursive: true });
58
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
59
+ fs.renameSync(tmp, file);
60
+ return payload;
61
+ } catch {
62
+ try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
63
+ return null;
64
+ }
65
+ }
66
+
67
+ module.exports = { markerPath, readMarker, writeMarker, markerPin, fallbackExpired, FALLBACK_TTL_MS };
@@ -0,0 +1,68 @@
1
+ // Project-root discovery. A command run from a subdirectory or from a linked
2
+ // worktree must land on the directory that owns `.wize/config/project.toml`,
3
+ // without ever climbing out of the enclosing git checkout.
4
+
5
+ 'use strict';
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const { real } = require('./util.js');
10
+ const { worktreeList, defaultBranch } = require('./git-context.js');
11
+
12
+ const PROJECT_TOML = path.join('.wize', 'config', 'project.toml');
13
+
14
+ function exists(p) {
15
+ try { fs.statSync(p); return true; } catch { return false; }
16
+ }
17
+
18
+ // Walks up from `start` and returns the first directory holding
19
+ // .wize/config/project.toml. The walk ends at the first directory that
20
+ // contains `.git` (file or dir); with no `.git` anywhere above, only `start`
21
+ // is checked, so the walk never reaches $HOME.
22
+ function findProjectRoot(start) {
23
+ const first = real(start);
24
+ const chain = [];
25
+ let boundary = null;
26
+ for (let dir = first; ; dir = path.dirname(dir)) {
27
+ chain.push(dir);
28
+ if (exists(path.join(dir, '.git'))) { boundary = dir; break; }
29
+ if (path.dirname(dir) === dir) break;
30
+ }
31
+ const candidates = boundary ? chain : [first];
32
+ for (const dir of candidates) {
33
+ if (exists(path.join(dir, PROJECT_TOML))) return dir;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ // In a linked worktree whose branch lacks the kit, the other checkout that has
39
+ // it: the default-branch worktree first, else any non-bare worktree (a bare
40
+ // repository + worktrees layout has no primary checkout). `ctx` is the
41
+ // gitContext of `here`. Null when this is not a linked worktree or none has it.
42
+ function findKitElsewhere(here, ctx) {
43
+ if (!ctx || !ctx.isRepo || !ctx.linked) return null;
44
+ const self = real(ctx.toplevel || here);
45
+ const list = worktreeList(here) || [];
46
+ const def = defaultBranch(here);
47
+ const others = list.filter(w => !w.bare && real(w.path) !== self && real(w.path) !== real(here));
48
+ others.sort((a, b) => Number(b.branch === def) - Number(a.branch === def));
49
+ for (const w of others) {
50
+ if (exists(path.join(real(w.path), PROJECT_TOML))) return real(w.path);
51
+ }
52
+ if (!list.length && ctx.primary && real(ctx.primary) !== self && exists(path.join(real(ctx.primary), PROJECT_TOML))) return real(ctx.primary);
53
+ return null;
54
+ }
55
+
56
+ // Precedence: --root > WIZE_PROJECT_ROOT > findProjectRoot(cwd) > (hydrate
57
+ // only) ORCA_WORKTREE_PATH > cwd. A project found from cwd beats an inherited
58
+ // ORCA_WORKTREE_PATH, so a shell in worktree B never renders worktree A.
59
+ function resolveRoot({ flags = {}, env = process.env, cwd = process.cwd(), forCommand = null } = {}) {
60
+ const pick = (v) => (typeof v === 'string' && v.trim() ? real(path.resolve(cwd, v.trim())) : null);
61
+ return pick(flags.root)
62
+ || pick(env.WIZE_PROJECT_ROOT)
63
+ || findProjectRoot(cwd)
64
+ || (forCommand === 'hydrate' ? pick(env.ORCA_WORKTREE_PATH) : null)
65
+ || real(cwd);
66
+ }
67
+
68
+ module.exports = { findProjectRoot, findKitElsewhere, resolveRoot };
@@ -0,0 +1,171 @@
1
+ // Local project status for `version-check` and the CLI hints: what this
2
+ // checkout is pinned to, whether it is hydrated, how that compares with the
3
+ // running kit and the registry, and the one action to take next. Reads files
4
+ // and git only; the registry answer comes in as `latest`.
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const { loadProjectConfig } = require('./toml.js');
11
+ const { compareVersions, parse, isSafePin } = require('./semver.js');
12
+ const { findProjectRoot, findKitElsewhere } = require('./project-root.js');
13
+ const { real } = require('./util.js');
14
+ const { gitContext, defaultBranch } = require('./git-context.js');
15
+ const { missingTargets } = require('./render-targets.js');
16
+ const { readMarker, markerPin, fallbackExpired } = require('./marker.js');
17
+
18
+ // First kit version that ships `hydrate`; older pins only know `sync`.
19
+ const HYDRATE_MIN = '0.27.0';
20
+
21
+ const ACTION_EXIT_CODES = { none: 0, hydrate: 10, update: 11, 'use-pin': 12, install: 13 };
22
+
23
+ function kitVersionOf(kitRoot) {
24
+ try { return require(path.join(kitRoot, 'package.json')).version; }
25
+ catch { return null; }
26
+ }
27
+
28
+ function supportsHydrate(pin) {
29
+ return isSafePin(pin) && !!parse(pin) && compareVersions(pin, HYDRATE_MIN) >= 0;
30
+ }
31
+
32
+ function hydrateCommand(pin) {
33
+ if (!isSafePin(pin)) return 'npx --yes wize-dev-kit hydrate';
34
+ return supportsHydrate(pin)
35
+ ? `npx --yes wize-dev-kit@${pin} hydrate`
36
+ : `npx --yes wize-dev-kit@${pin} sync`;
37
+ }
38
+
39
+ function gitFacts(root) {
40
+ const ctx = gitContext(root);
41
+ return {
42
+ repo: ctx.isRepo,
43
+ linked: ctx.linked,
44
+ primary: ctx.primary,
45
+ branch: ctx.branch,
46
+ detached: ctx.isRepo ? ctx.detached : false,
47
+ default_branch: ctx.isRepo ? defaultBranch(root) : null
48
+ };
49
+ }
50
+
51
+ // `latestSource` says where `latest` came from. Only a registry answer fetched
52
+ // just now (`'network'`) can prove a pin is unpublished: a cached answer may be
53
+ // an hour old, or older offline, and a newer release would read as a downgrade.
54
+ // `published` is the packument's list of every published version; a pin above
55
+ // `latest` is unpublished only when that list is known and lacks it (a
56
+ // prerelease published under another dist-tag is ahead of `latest` too).
57
+ function getProjectStatus({ kitRoot, cwd = process.cwd(), latest = null, latestSource = null, published = null, env = process.env } = {}) {
58
+ const installed = kitVersionOf(kitRoot);
59
+ const root = findProjectRoot(cwd);
60
+ const commands = {
61
+ hydrate: hydrateCommand(null),
62
+ update: 'npx --yes wize-dev-kit@latest update',
63
+ pr: 'npx --yes wize-dev-kit@latest update --pr',
64
+ install: 'npx --yes wize-dev-kit@latest install'
65
+ };
66
+ const base = {
67
+ root,
68
+ pin: null,
69
+ installed,
70
+ latest: latest || null,
71
+ pin_status: 'missing',
72
+ hydrated: 'ok',
73
+ missing_targets: [],
74
+ marker_version: null,
75
+ user_toml: 'missing',
76
+ git: gitFacts(root || cwd),
77
+ skew: 'none',
78
+ action: 'none',
79
+ reason: '',
80
+ commands
81
+ };
82
+
83
+ if (!root) {
84
+ // A linked worktree whose branch predates the kit install: installing would
85
+ // fork the pin, so the answer is to merge, not to install.
86
+ const kitAt = findKitElsewhere(real(cwd), gitContext(real(cwd)));
87
+ if (kitAt) return { ...base, action: 'none', reason: 'kit-not-on-branch', kit_at: kitAt, commands: { ...commands, install: `${commands.install} --here` } };
88
+ return { ...base, action: 'install', reason: 'no-project', pin_status: 'missing', user_toml: 'missing' };
89
+ }
90
+
91
+ const cfg = loadProjectConfig(root);
92
+ const pin = (cfg.project && typeof cfg.project.kit_version === 'string' && cfg.project.kit_version) || null;
93
+ const targets = (cfg.install && cfg.install.ide_targets) || ['claude-code', 'generic'];
94
+ const marker = readMarker(root);
95
+ const missing = missingTargets(root, targets);
96
+ const userToml = fs.existsSync(path.join(root, '.wize', 'config', 'user.toml')) ? 'present' : 'missing';
97
+ const known = !!latest;
98
+
99
+ let pinStatus = 'ok';
100
+ if (!pin) pinStatus = 'missing';
101
+ else if (!isSafePin(pin)) pinStatus = 'invalid';
102
+ else if (installed && compareVersions(pin, installed) > 0) {
103
+ const proven = known && latestSource === 'network' && compareVersions(pin, latest) > 0
104
+ && Array.isArray(published) && !published.includes(pin);
105
+ pinStatus = proven ? 'unpublished' : 'ahead-of-binary';
106
+ }
107
+
108
+ let hydrated = 'ok';
109
+ if (missing.length) hydrated = 'missing';
110
+ else if (supportsHydrate(pin) && marker && (markerPin(marker) !== pin || fallbackExpired(marker))) hydrated = 'stale';
111
+
112
+ let skew = 'none';
113
+ if (pin && installed) {
114
+ const cmp = compareVersions(pin, installed);
115
+ if (cmp < 0) skew = 'project-behind';
116
+ else if (cmp > 0) skew = 'project-ahead';
117
+ else if (known && compareVersions(latest, pin) > 0) skew = 'registry-ahead';
118
+ }
119
+
120
+ commands.hydrate = hydrateCommand(pin);
121
+
122
+ let action = 'none';
123
+ let reason = 'up-to-date';
124
+ if (hydrated !== 'ok') {
125
+ action = 'hydrate';
126
+ reason = hydrated === 'missing' ? 'unhydrated' : 'stale-marker';
127
+ } else if (userToml === 'missing' && supportsHydrate(pin)) {
128
+ action = 'hydrate';
129
+ reason = 'user-toml-missing';
130
+ } else if (pinStatus === 'unpublished') {
131
+ action = 'update';
132
+ reason = 'pin-unpublished';
133
+ commands.update = 'npx --yes wize-dev-kit@latest update --allow-downgrade';
134
+ } else if (pinStatus === 'ahead-of-binary') {
135
+ action = 'use-pin';
136
+ reason = 'pin-ahead-of-binary';
137
+ } else if (pinStatus === 'missing' || pinStatus === 'invalid') {
138
+ action = 'update';
139
+ reason = pinStatus === 'missing' ? 'pin-missing' : 'pin-invalid';
140
+ } else if (skew === 'project-behind') {
141
+ action = 'update';
142
+ reason = 'project-behind';
143
+ } else if (skew === 'registry-ahead') {
144
+ action = 'update';
145
+ reason = 'registry-ahead';
146
+ }
147
+
148
+ // WIZE_DISABLE_UPDATE_CHECK=1 is the opt-out of auto-update: the skew stays
149
+ // visible, the update action does not.
150
+ if (env.WIZE_DISABLE_UPDATE_CHECK === '1' && action === 'update') {
151
+ action = 'none';
152
+ reason = 'update-disabled';
153
+ }
154
+
155
+ return {
156
+ ...base,
157
+ root,
158
+ pin,
159
+ pin_status: pinStatus,
160
+ hydrated,
161
+ missing_targets: missing,
162
+ marker_version: marker ? marker.kit_version ?? null : null,
163
+ user_toml: userToml,
164
+ skew,
165
+ action,
166
+ reason,
167
+ commands
168
+ };
169
+ }
170
+
171
+ module.exports = { getProjectStatus, kitVersionOf, hydrateCommand, supportsHydrate, ACTION_EXIT_CODES, HYDRATE_MIN };
@@ -105,10 +105,100 @@ function clipOneLine(str, max = 240) {
105
105
  return flat.slice(0, max - 1).trimEnd() + '…';
106
106
  }
107
107
 
108
+ // Where `dir` would be created: the nearest existing ancestor resolved through
109
+ // symlinks, plus the part that does not exist yet.
110
+ function landingDir(dir) {
111
+ const rest = [];
112
+ let cur = path.resolve(dir);
113
+ for (;;) {
114
+ try { return path.join(fs.realpathSync(cur), ...rest.reverse()); } catch { /* not there yet */ }
115
+ const parent = path.dirname(cur);
116
+ if (parent === cur) return path.resolve(dir);
117
+ rest.push(path.basename(cur));
118
+ cur = parent;
119
+ }
120
+ }
121
+
122
+ // With a write guard set, a directory the guard vetoes is not created, so a
123
+ // symlinked directory cannot be filled with empty folders behind the guard.
108
124
  function ensureDir(dir) {
125
+ if (probeOnly) return;
126
+ if (writeGuard && (writeGuard(path.resolve(dir)) || writeGuard(landingDir(dir)))) return;
109
127
  fs.mkdirSync(dir, { recursive: true });
110
128
  }
111
129
 
130
+ // ---- Write safety -------------------------------------------------------
131
+ // Every file a renderer emits goes through writeFileIfChanged /
132
+ // copyFileIfChanged: identical bytes are left alone (mtime untouched), writes
133
+ // are atomic (tmp + rename, safe for concurrent hydrates of one tree), and an
134
+ // optional guard can veto a path (hydrate uses it to never touch tracked files).
135
+ let writeGuard = null;
136
+ // Probe mode: every write still goes through the guard (so vetoes are
137
+ // recorded) but nothing touches the disk.
138
+ let probeOnly = false;
139
+ const writeStats = { written: 0, unchanged: 0, skippedTracked: [] };
140
+
141
+ function setWriteGuard(fn) { writeGuard = typeof fn === 'function' ? fn : null; }
142
+ function clearWriteGuard() { writeGuard = null; }
143
+ function setProbeMode(on) { probeOnly = !!on; }
144
+ function getWriteStats() {
145
+ return { written: writeStats.written, unchanged: writeStats.unchanged, skippedTracked: writeStats.skippedTracked.slice() };
146
+ }
147
+ function resetWriteStats() {
148
+ writeStats.written = 0;
149
+ writeStats.unchanged = 0;
150
+ writeStats.skippedTracked = [];
151
+ }
152
+
153
+ function atomicWrite(abs, buf, mode) {
154
+ const tmp = path.join(path.dirname(abs), `.${path.basename(abs)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
155
+ try {
156
+ fs.writeFileSync(tmp, buf);
157
+ if (mode !== undefined) fs.chmodSync(tmp, mode);
158
+ fs.renameSync(tmp, abs);
159
+ } catch (err) {
160
+ try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
161
+ throw err;
162
+ }
163
+ }
164
+
165
+ // The path a write really lands on: the parent directory is resolved through
166
+ // symlinks, so a tracked `.claude/skills -> ../skills` link cannot hide the
167
+ // tracked file behind it from the guard.
168
+ function landingPath(abs) {
169
+ return path.join(landingDir(path.dirname(abs)), path.basename(abs));
170
+ }
171
+
172
+ function emit(file, buf, mode) {
173
+ const abs = path.resolve(file);
174
+ if (writeGuard && (writeGuard(abs) || writeGuard(landingPath(abs)))) {
175
+ writeStats.skippedTracked.push(abs);
176
+ return 'skipped-tracked';
177
+ }
178
+ if (probeOnly) {
179
+ writeStats.unchanged++;
180
+ return 'unchanged';
181
+ }
182
+ let existing = null;
183
+ try { existing = fs.readFileSync(abs); } catch { /* absent */ }
184
+ if (existing && existing.equals(buf)) {
185
+ writeStats.unchanged++;
186
+ return 'unchanged';
187
+ }
188
+ atomicWrite(abs, buf, mode);
189
+ writeStats.written++;
190
+ return 'written';
191
+ }
192
+
193
+ // -> 'written' | 'unchanged' | 'skipped-tracked'
194
+ function writeFileIfChanged(file, content) {
195
+ return emit(file, Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf-8'));
196
+ }
197
+
198
+ function copyFileIfChanged(src, dst) {
199
+ return emit(dst, fs.readFileSync(src), fs.statSync(src).mode & 0o777);
200
+ }
201
+
112
202
  // Companion files (steps/, templates/, data/, *.csv, *-template.md,
113
203
  // customize.toml, research.template.md, etc.) that micro-file workflows
114
204
  // reference from their body. We must copy these alongside the SKILL.md so
@@ -139,7 +229,7 @@ function copyCompanionFiles(srcDir, destDir, written, relPrefix = '') {
139
229
  copyCompanionFiles(s, d, written, rel);
140
230
  } else if (entry.isFile()) {
141
231
  ensureDir(path.dirname(d));
142
- fs.copyFileSync(s, d);
232
+ copyFileIfChanged(s, d);
143
233
  written.push(rel);
144
234
  }
145
235
  }
@@ -244,7 +334,7 @@ function renderAnthropicSkills(kitRoot, targetDir, opts = {}) {
244
334
  } else {
245
335
  const skillDir = path.join(targetDir, a.code);
246
336
  ensureDir(skillDir);
247
- fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
337
+ writeFileIfChanged(path.join(skillDir, 'SKILL.md'), content);
248
338
  written.push(`${a.code}/SKILL.md`);
249
339
  // Copy companion files (steps/, templates/, data/, *.csv, customize.toml,
250
340
  // *-template.md, research.template.md, etc.) so micro-file workflows can
@@ -272,7 +362,69 @@ const AGENT_ORDER = [
272
362
  'wize-sec-red-teamer'
273
363
  ];
274
364
 
365
+ // ---- managed bootstrap block ---------------------------------------------------
366
+ //
367
+ // AGENTS.md is the one tracked file that Codex, Cursor, Windsurf, Antigravity
368
+ // and others read at session start. The block below tells the agent to run
369
+ // `version-check --json` and follow its `action`. The text names no version,
370
+ // so `update` only rewrites it when the wording itself changes.
371
+
372
+ const AGENTS_TITLE = '# Agents — Wize Development Kit';
373
+ const BOOTSTRAP_BEGIN = '<!-- wize:bootstrap:begin (managed by wize-dev-kit; edits inside are replaced by update) -->';
374
+ const BOOTSTRAP_END = '<!-- wize:bootstrap:end -->';
375
+ const BOOTSTRAP_BEGIN_RE = /<!-- wize:bootstrap:begin[^\n]*?-->/;
376
+
377
+ const BOOTSTRAP_LINES = [
378
+ BOOTSTRAP_BEGIN,
379
+ '## Kit bootstrap',
380
+ '',
381
+ 'Once per session, if you can run shell commands, run `npx --yes --fetch-retries=0 wize-dev-kit version-check --json`',
382
+ '(prefix it with `timeout 20` where that exists) and follow its `action` field (if npx fails or times out,',
383
+ 'for example offline, skip this):',
384
+ '',
385
+ '- `hydrate`: run `commands.hydrate` now. It writes only git-ignored files. If the `/wize-*`',
386
+ ' commands are still missing afterwards, tell the user to restart the harness.',
387
+ '- `update`: run `commands.update`. It prepares a `chore/wize-kit-<version>` branch in a temporary',
388
+ ' worktree and never touches this checkout. Then ask once before running `commands.pr`, which',
389
+ ' pushes the branch and opens a PR. Exception: when `reason` is `pin-unpublished`, `commands.update`',
390
+ ' lowers the project pin; ask the user first and run it only on a yes.',
391
+ '- `use-pin`: tell the user to run kit commands as `npx wize-dev-kit@<pin>`, with `<pin>` taken from',
392
+ ' `project.pin` in the JSON.',
393
+ '- `none` with `reason` `kit-not-on-branch`: tell the user to merge the kit into this branch, or install here with `install --here`.',
394
+ '- `install`, other `none`: do nothing.',
395
+ '',
396
+ 'If the `/wize-*` commands are missing, run `npx --yes wize-dev-kit hydrate`.',
397
+ 'Never commit a kit update on the default branch.',
398
+ BOOTSTRAP_END
399
+ ];
400
+ const BOOTSTRAP_BLOCK = BOOTSTRAP_LINES.join('\n');
401
+
402
+ // True only for an AGENTS.md this kit generated. A foreign file is never edited.
403
+ function isKitAgentsMd(content) {
404
+ if (typeof content !== 'string') return false;
405
+ return content.replace(/^/, '').split(/\r?\n/, 1)[0] === AGENTS_TITLE;
406
+ }
407
+
408
+ // Pure: returns `content` with exactly one managed bootstrap block. An existing
409
+ // block is replaced in place; otherwise the block goes before the first `## `
410
+ // heading, or at the end when there is none. A half-written block (one marker
411
+ // without the other) is left alone rather than guessed at.
412
+ function upsertBootstrapBlock(content) {
413
+ const begin = BOOTSTRAP_BEGIN_RE.exec(content);
414
+ const endIdx = content.indexOf(BOOTSTRAP_END);
415
+ if (begin || endIdx !== -1) {
416
+ if (!begin || endIdx === -1 || endIdx < begin.index) return content;
417
+ return content.slice(0, begin.index) + BOOTSTRAP_BLOCK + content.slice(endIdx + BOOTSTRAP_END.length);
418
+ }
419
+ const heading = /^## /m.exec(content);
420
+ if (heading) {
421
+ return content.slice(0, heading.index) + BOOTSTRAP_BLOCK + '\n\n' + content.slice(heading.index);
422
+ }
423
+ return content.replace(/\s*$/, '') + '\n\n' + BOOTSTRAP_BLOCK + '\n';
424
+ }
425
+
275
426
  function renderAgentsMd(kitRoot, projectRoot, opts = {}) {
427
+ if (opts.skipAgentsMd) return { written: [], skipped: ['AGENTS.md (skipAgentsMd)'] };
276
428
  const assets = collectAssets(kitRoot, opts);
277
429
  const rank = (code) => {
278
430
  const i = AGENT_ORDER.indexOf(code);
@@ -286,12 +438,14 @@ function renderAgentsMd(kitRoot, projectRoot, opts = {}) {
286
438
  return { written: [], skipped: ['AGENTS.md (already present; not overwritten)'] };
287
439
  }
288
440
  const lines = [
289
- '# Agents — Wize Development Kit',
441
+ AGENTS_TITLE,
290
442
  '',
291
443
  'This repo is wired with the [`wize-dev-kit`](https://www.npmjs.com/package/wize-dev-kit).',
292
444
  'Detailed artifacts live under `.wize/`. The agents below are activated through your AI IDE',
293
445
  'using slash commands (Claude Code, Codex, Cursor, Windsurf, Antigravity, Hermes all read this file).',
294
446
  '',
447
+ ...BOOTSTRAP_LINES,
448
+ '',
295
449
  '## Operating context',
296
450
  '',
297
451
  'Treat `.wize/`, this `AGENTS.md`, and the installed `wize-*` skills as your operating',
@@ -351,7 +505,7 @@ function renderAgentsMd(kitRoot, projectRoot, opts = {}) {
351
505
  lines.push('', '## Where to start', '', 'Activate the orchestrator: `/wize` (or `/wize-help`). Wizer will guide you.', '');
352
506
 
353
507
  if (opts.dryRun) return { written: ['[dry-run] AGENTS.md'], skipped: [] };
354
- fs.writeFileSync(file, lines.join('\n'), 'utf-8');
508
+ writeFileIfChanged(file, lines.join('\n'));
355
509
  return { written: ['AGENTS.md'], skipped: [] };
356
510
  }
357
511
 
@@ -359,7 +513,19 @@ module.exports = {
359
513
  collectAssets,
360
514
  renderAnthropicSkills,
361
515
  renderAgentsMd,
516
+ upsertBootstrapBlock,
517
+ isKitAgentsMd,
518
+ BOOTSTRAP_BEGIN,
519
+ BOOTSTRAP_END,
520
+ BOOTSTRAP_BLOCK,
362
521
  copyCompanionFiles,
522
+ writeFileIfChanged,
523
+ copyFileIfChanged,
524
+ setWriteGuard,
525
+ clearWriteGuard,
526
+ setProbeMode,
527
+ getWriteStats,
528
+ resetWriteStats,
363
529
  // Re-exports for adapter authors who need to roll their own format:
364
530
  readFrontmatter,
365
531
  bodyAfterFrontmatter,