devflow-kit 3.9.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.
Files changed (168) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +1 -1
  3. package/dist/agents/code.md +6 -6
  4. package/dist/agents/fix.md +6 -6
  5. package/dist/agents/harden.md +7 -6
  6. package/dist/agents/refactor.md +6 -6
  7. package/dist/cli/commands/ambient.js +10 -2
  8. package/dist/cli/commands/backup-session.js +131 -0
  9. package/dist/cli/commands/compliance-prompts.js +9 -4
  10. package/dist/cli/commands/compliance.js +184 -24
  11. package/dist/cli/commands/curation.js +23 -10
  12. package/dist/cli/commands/gate-level-prompts.js +39 -25
  13. package/dist/cli/commands/gates.js +5 -7
  14. package/dist/cli/commands/init.js +169 -184
  15. package/dist/cli/commands/install-report.js +129 -0
  16. package/dist/cli/commands/legacy-sweep.js +159 -0
  17. package/dist/cli/commands/proxy.js +6 -1
  18. package/dist/cli/commands/rules.js +75 -21
  19. package/dist/cli/commands/skills.js +42 -9
  20. package/dist/cli/commands/tracker.js +9 -3
  21. package/dist/cli/commands/uninstall.js +339 -75
  22. package/dist/commands/bug-analysis.md +69 -120
  23. package/dist/commands/dynamic-build.md +23 -23
  24. package/dist/commands/implement.md +46 -32
  25. package/dist/commands/release.md +1 -1
  26. package/dist/commands/resolve.md +35 -31
  27. package/dist/commands/self-review.md +8 -7
  28. package/dist/compliance-off/agents/git.md +120 -0
  29. package/dist/compliance-off/agents/triage.md +180 -0
  30. package/dist/compliance-off/commands/bug-analysis.md +398 -0
  31. package/dist/compliance-off/commands/code-review.md +520 -0
  32. package/dist/compliance-off/commands/dynamic-build.md +1251 -0
  33. package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
  34. package/dist/compliance-off/commands/implement.md +736 -0
  35. package/dist/compliance-off/commands/plan.md +671 -0
  36. package/dist/compliance-off/commands/release.md +309 -0
  37. package/dist/compliance-off/commands/resolve.md +742 -0
  38. package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
  39. package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
  40. package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
  41. package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
  42. package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
  43. package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
  44. package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
  45. package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
  46. package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
  47. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
  48. package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
  49. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
  50. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
  51. package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
  52. package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
  53. package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
  54. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
  55. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
  56. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
  57. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
  58. package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
  59. package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
  60. package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
  61. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
  62. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
  63. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
  64. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
  65. package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
  66. package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
  67. package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
  68. package/dist/core/agent-models.js +19 -4
  69. package/dist/core/assets.js +126 -17
  70. package/dist/core/compliance-off-files.js +27 -0
  71. package/dist/core/compliance.js +14 -0
  72. package/dist/core/curation-variants.js +460 -112
  73. package/dist/core/flags.js +2 -2
  74. package/dist/core/fs-atomic.js +5 -1
  75. package/dist/core/installed-variants.js +63 -0
  76. package/dist/core/manifest.js +48 -5
  77. package/dist/core/mds-variants.js +700 -3
  78. package/dist/core/orphan-sweep.js +29 -2
  79. package/dist/core/path-set.js +15 -0
  80. package/dist/core/plugins.js +68 -10
  81. package/dist/core/reference-sweep.js +42 -25
  82. package/dist/core/retired-shipped-names.js +278 -0
  83. package/dist/curation-compliance-off/agents/triage.md +176 -0
  84. package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
  85. package/dist/curation-compliance-off/commands/code-review.md +481 -0
  86. package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
  87. package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
  88. package/dist/curation-compliance-off/commands/implement.md +689 -0
  89. package/dist/curation-compliance-off/commands/plan.md +631 -0
  90. package/dist/curation-compliance-off/commands/release.md +279 -0
  91. package/dist/curation-compliance-off/commands/resolve.md +692 -0
  92. package/dist/curation-off/agents/code.md +6 -6
  93. package/dist/curation-off/agents/fix.md +6 -6
  94. package/dist/curation-off/agents/harden.md +7 -6
  95. package/dist/curation-off/agents/refactor.md +6 -6
  96. package/dist/curation-off/commands/bug-analysis.md +69 -119
  97. package/dist/curation-off/commands/dynamic-build.md +23 -23
  98. package/dist/curation-off/commands/implement.md +46 -32
  99. package/dist/curation-off/commands/release.md +1 -1
  100. package/dist/curation-off/commands/resolve.md +35 -31
  101. package/dist/curation-off/commands/self-review.md +8 -7
  102. package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
  103. package/dist/targets/claude-code/behavior-install.js +23 -9
  104. package/dist/targets/claude-code/charter-render.js +374 -0
  105. package/dist/targets/claude-code/compliance-install.js +62 -17
  106. package/dist/targets/claude-code/compliance-off-files.js +249 -0
  107. package/dist/targets/claude-code/curation-install.js +632 -117
  108. package/dist/targets/claude-code/folder-swap.js +142 -0
  109. package/dist/targets/claude-code/install-backup.js +780 -0
  110. package/dist/targets/claude-code/installer.js +795 -199
  111. package/dist/targets/claude-code/language-stamp.js +6 -0
  112. package/dist/targets/claude-code/legacy-skill-hashes.js +170 -0
  113. package/dist/targets/claude-code/linked-folder.js +176 -0
  114. package/dist/targets/claude-code/reference-converge.js +131 -0
  115. package/dist/targets/claude-code/rules-folder.js +34 -0
  116. package/dist/targets/claude-code/shipped-set.js +160 -0
  117. package/dist/targets/claude-code/tracker-install.js +21 -8
  118. package/dist/targets/claude-code/tracker-servers.js +20 -6
  119. package/dist/targets/claude-code/workflow-folder-install.js +251 -0
  120. package/dist/variant-axes.json +109 -0
  121. package/package.json +1 -1
  122. package/src/assets/agents/_partials/_code.mds +6 -6
  123. package/src/assets/agents/evaluate.md +1 -1
  124. package/src/assets/agents/git.mds +32 -0
  125. package/src/assets/agents/harden.mds +7 -6
  126. package/src/assets/agents/test.md +21 -12
  127. package/src/assets/agents/triage.mds +2 -0
  128. package/src/assets/agents/validate.md +10 -10
  129. package/src/assets/commands/_partials/_compliance.mds +5 -3
  130. package/src/assets/commands/_partials/_engine.mds +9 -8
  131. package/src/assets/commands/_partials/_publication.mds +10 -1
  132. package/src/assets/commands/bug-analysis.mds +80 -126
  133. package/src/assets/commands/code-review.mds +43 -0
  134. package/src/assets/commands/dynamic-build.mds +109 -15
  135. package/src/assets/commands/dynamic-tickets.mds +13 -0
  136. package/src/assets/commands/implement.mds +145 -33
  137. package/src/assets/commands/plan.mds +19 -0
  138. package/src/assets/commands/release.mds +50 -1
  139. package/src/assets/commands/resolve.mds +217 -32
  140. package/src/assets/commands/self-review.mds +8 -7
  141. package/src/assets/mds/git/_ops.mds +23 -0
  142. package/src/assets/mds/git/_ops_steps.mds +17 -0
  143. package/src/assets/mds/git/_pr.mds +1 -1
  144. package/src/assets/mds/git/_references.mds +6 -0
  145. package/src/assets/mds/tracker/_common.mds +4 -0
  146. package/src/assets/mds/tracker/_github.mds +42 -0
  147. package/src/assets/mds/tracker/_jira.mds +43 -0
  148. package/src/assets/mds/tracker/_linear.mds +43 -0
  149. package/src/assets/rules/quality.md +2 -0
  150. package/src/assets/scripts/ci-wait.cjs +204 -126
  151. package/src/assets/scripts/hooks/assets/implement-installed +1 -0
  152. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
  153. package/src/assets/scripts/hooks/preamble +18 -8
  154. package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
  155. package/src/assets/scripts/pr-evidence.cjs +41 -2
  156. package/src/assets/scripts/verify-evidence.cjs +408 -56
  157. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
  158. package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
  159. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
  160. package/src/assets/skills/agent-references/references/validate/passed-alone.md +7 -7
  161. package/src/assets/skills/docs-framework/SKILL.md +5 -2
  162. package/src/assets/skills/git/SKILL.md +3 -3
  163. package/src/assets/skills/git/references/github-api.md +2 -139
  164. package/src/assets/skills/git/references/github-posting.md +158 -0
  165. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  166. package/src/assets/skills/review-methodology/references/patterns.md +1 -1
  167. package/src/assets/skills/review-methodology/references/report-template.md +1 -2
  168. package/src/assets/skills/testing/SKILL.md +2 -4
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The shipped set: every file path, relative to the Claude dir, that some install of this build
3
+ * can write into `commands/devflow`, `agents/devflow`, `rules/devflow` and `skills/devflow:*`.
4
+ *
5
+ * The backup session moves a file out of those folders unless the set names it, so the set errs
6
+ * toward too large. It comes from the full registries and all four variants (compliance on and
7
+ * off, curation on and off), never from the selection of one run and never from what an earlier
8
+ * install left on disk:
9
+ *
10
+ * - commands: every plugin command, also a workflow that ships as a skill folder, since the
11
+ * old command file of such a workflow is removed as a shipped file and never moved;
12
+ * - agents and rules: every plugin entry, the feature-owned rules and the tracker agent;
13
+ * - skills: the files of each registry skill in the package source, the generated references
14
+ * of the compliance-on manifest, the compliance skill the converge writes, and the files only
15
+ * a compliance-on install writes;
16
+ * - workflow folders: the files of each registered folder under all four variant keys;
17
+ * - shadows: the listing of a valid skill shadow counts beside the listing it replaces.
18
+ *
19
+ * {@link buildShippedSet} is pure. {@link loadShippedSet} reads the package and the shadows from
20
+ * disk, within fixed bounds, and answers with a reason instead of a smaller set when it cannot.
21
+ */
22
+ import { promises as fs } from 'fs';
23
+ import * as path from 'path';
24
+ import { skillsDir } from '../../core/assets.js';
25
+ import { complianceSkillFiles } from '../../core/compliance.js';
26
+ import { COMPLIANCE_OFF_REMOVED_SKILL_FILES } from '../../core/compliance-off-files.js';
27
+ import { VARIANT_KEYS } from '../../core/curation-variants.js';
28
+ import { SKILL_REFS_SKILL_NAME, installedReferenceManifest, workflowFolderFiles, } from '../../core/mds-variants.js';
29
+ import { createPathSet } from '../../core/path-set.js';
30
+ import { FEATURE_OWNED_RULES, getAllAgentNames, getAllCommandNames, getAllRuleNames, getAllSkillNames, prefixSkillName, } from '../../core/plugins.js';
31
+ import { validateSkillShadow } from './installer.js';
32
+ import { TRACKER_AGENT_NAME } from './tracker-install.js';
33
+ /** The most files one listed skill folder may hold. A folder past it is a failed listing. */
34
+ export const MAX_LISTED_FILES = 2_000;
35
+ /** The deepest level a listed folder may reach, counting the listed root as 0. */
36
+ export const MAX_LISTED_DEPTH = 6;
37
+ /** The most folders one listing may visit. */
38
+ export const MAX_LISTED_FOLDERS = 500;
39
+ const IGNORED_FILE_NAME = '.DS_Store';
40
+ const commandPath = (name) => `commands/devflow/${name}.md`;
41
+ const agentPath = (name) => `agents/devflow/${name}.md`;
42
+ const rulePath = (name) => `rules/devflow/${name}.md`;
43
+ const skillPath = (skill, file) => `skills/${prefixSkillName(skill)}/${file}`;
44
+ /**
45
+ * The file paths of the shipped set. Pure: the registries are constants, and the package and
46
+ * shadow listings come in as inputs.
47
+ */
48
+ export function shippedPaths(inputs) {
49
+ const paths = new Set();
50
+ for (const name of getAllCommandNames(new Set()))
51
+ paths.add(commandPath(name));
52
+ for (const name of [...getAllAgentNames(), TRACKER_AGENT_NAME])
53
+ paths.add(agentPath(name));
54
+ for (const name of [...getAllRuleNames(), ...FEATURE_OWNED_RULES])
55
+ paths.add(rulePath(name));
56
+ for (const [skill, files] of inputs.packageSkillFiles) {
57
+ for (const file of files)
58
+ paths.add(skillPath(skill, file));
59
+ }
60
+ for (const [skill, files] of inputs.shadowListings ?? []) {
61
+ for (const file of files)
62
+ paths.add(skillPath(skill, file));
63
+ }
64
+ for (const rel of installedReferenceManifest({ compliance: true })) {
65
+ paths.add(skillPath(SKILL_REFS_SKILL_NAME, `references/${rel}`));
66
+ }
67
+ for (const entry of COMPLIANCE_OFF_REMOVED_SKILL_FILES)
68
+ paths.add(`skills/${entry}`);
69
+ for (const file of complianceSkillFiles())
70
+ paths.add(skillPath('compliance', file));
71
+ for (const folder of inputs.workflowFolders) {
72
+ for (const key of VARIANT_KEYS) {
73
+ for (const file of workflowFolderFiles(folder, key))
74
+ paths.add(skillPath(folder.name, file));
75
+ }
76
+ }
77
+ return paths;
78
+ }
79
+ /** The shipped set of {@link shippedPaths}, ready for the backup session. */
80
+ export function buildShippedSet(inputs) {
81
+ return createPathSet(shippedPaths(inputs));
82
+ }
83
+ /**
84
+ * The files below `root` as POSIX paths, sorted per folder. Folders are entered only as real
85
+ * folders (a link is listed as a file and never entered). The listing is bounded by
86
+ * {@link MAX_LISTED_FILES}, {@link MAX_LISTED_DEPTH} and {@link MAX_LISTED_FOLDERS}; a breach or a
87
+ * read error is a failed listing, since a smaller listing would be a smaller set.
88
+ */
89
+ async function listFiles(root) {
90
+ const files = [];
91
+ const pending = [
92
+ { dir: root, rel: '', depth: 0 },
93
+ ];
94
+ for (let visited = 0; visited < MAX_LISTED_FOLDERS; visited++) {
95
+ const next = pending.pop();
96
+ if (next === undefined)
97
+ return { ok: true, files };
98
+ let entries;
99
+ try {
100
+ entries = await fs.readdir(next.dir, { withFileTypes: true });
101
+ }
102
+ catch (err) {
103
+ const code = err.code;
104
+ return { ok: false, reason: `could not read ${next.dir} (${typeof code === 'string' ? code : 'error'})` };
105
+ }
106
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
107
+ for (const entry of entries) {
108
+ const rel = next.rel === '' ? entry.name : `${next.rel}/${entry.name}`;
109
+ if (entry.isDirectory()) {
110
+ if (next.depth + 1 > MAX_LISTED_DEPTH)
111
+ return { ok: false, reason: `${root} is deeper than ${MAX_LISTED_DEPTH} levels` };
112
+ pending.push({ dir: path.join(next.dir, entry.name), rel, depth: next.depth + 1 });
113
+ }
114
+ else if ((entry.isFile() || entry.isSymbolicLink()) && entry.name !== IGNORED_FILE_NAME) {
115
+ if (files.length >= MAX_LISTED_FILES)
116
+ return { ok: false, reason: `${root} holds more than ${MAX_LISTED_FILES} files` };
117
+ files.push(rel);
118
+ }
119
+ }
120
+ }
121
+ return pending.length === 0
122
+ ? { ok: true, files }
123
+ : { ok: false, reason: `${root} holds more than ${MAX_LISTED_FOLDERS} folders` };
124
+ }
125
+ /**
126
+ * Build the shipped set from the registries, the package skill source and the skill shadows.
127
+ *
128
+ * Every registry skill is listed from `skillsDir(packageRoot)`. A skill whose source is missing is
129
+ * a failed load, not a smaller set: the caller then runs no scan, because a set that lacks a
130
+ * shipped file would send that file to the backup folder.
131
+ */
132
+ export async function loadShippedSet(options) {
133
+ const skills = getAllSkillNames(new Set());
134
+ const packageSkillFiles = new Map();
135
+ for (const skill of skills) {
136
+ const listed = await listFiles(path.join(skillsDir(options.packageRoot), skill));
137
+ if (!listed.ok)
138
+ return { ok: false, reason: `skill "${skill}": ${listed.reason}` };
139
+ packageSkillFiles.set(skill, listed.files);
140
+ }
141
+ let shadowListings = options.shadowListings;
142
+ if (shadowListings === undefined) {
143
+ const found = new Map();
144
+ for (const skill of skills) {
145
+ const shadowDir = path.join(options.devflowDir, 'skills', skill);
146
+ if ((await validateSkillShadow(shadowDir)) !== 'valid')
147
+ continue;
148
+ const listed = await listFiles(shadowDir);
149
+ if (!listed.ok)
150
+ return { ok: false, reason: `shadow of skill "${skill}": ${listed.reason}` };
151
+ found.set(skill, listed.files);
152
+ }
153
+ shadowListings = found;
154
+ }
155
+ return {
156
+ ok: true,
157
+ shipped: buildShippedSet({ packageSkillFiles, shadowListings, workflowFolders: options.workflowFolders }),
158
+ };
159
+ }
160
+ //# sourceMappingURL=shipped-set.js.map
@@ -15,6 +15,7 @@ import { stampAgentTools } from '../../core/agent-frontmatter.js';
15
15
  import { agentSourceDirs } from '../../core/assets.js';
16
16
  import { mdFileName } from '../../core/orphan-sweep.js';
17
17
  import { TRACKER_MCP_SERVERS_DEFAULT, trackerServerTools } from '../../core/tracker.js';
18
+ import { newLinkReport, refuseUnrealFolder, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
18
19
  /**
19
20
  * The agent this module owns.
20
21
  *
@@ -97,55 +98,67 @@ async function writeWouldChangeNothing(wanted, target) {
97
98
  * nothing reports `unchanged` and the summary stays quiet
98
99
  * (see {@link writeWouldChangeNothing}).
99
100
  *
101
+ * An `agents/devflow` that is a link, or not a folder, is refused: nothing is written there, and the
102
+ * folder is named in `refusedFolders` and in a warning. A `tracker.md` that is a link is replaced by a
103
+ * real file, and named in `replacedLinks` and in a warning.
104
+ *
100
105
  * Never throws. A caller reads `converged`; it does not catch. The one refusal
101
106
  * that is not an I/O degradation — a claudeDir that is not absolute — is reported
102
107
  * the same way rather than thrown, because an install must not die on it.
103
108
  */
104
109
  export async function convergeTrackerArtifacts(opts) {
105
110
  const { claudeDir, warn } = opts;
111
+ const links = newLinkReport();
112
+ const finish = (converged, agent) => {
113
+ warnLinkReport(links, warn);
114
+ return { converged, agent, replacedLinks: links.replacedLinks, refusedFolders: links.refusedFolders };
115
+ };
106
116
  // Precondition, asserted in production code rather than only in tests: an
107
117
  // empty or relative claudeDir would make the target resolve somewhere
108
118
  // unexpected.
109
119
  if (!path.isAbsolute(claudeDir)) {
110
120
  warn(`tracker: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
111
- return { converged: false, agent: 'unchanged' };
121
+ return finish(false, 'unchanged');
112
122
  }
113
123
  const target = agentTarget(claudeDir);
124
+ // A link at the folder sends the write into another place, so the folder is proven first.
125
+ if (await refuseUnrealFolder(links, path.dirname(target)))
126
+ return finish(false, 'unchanged');
114
127
  const dirs = opts.agentSourceDirs ?? agentSourceDirs();
115
128
  const candidates = dirs.map(dir => path.join(dir, mdFileName(TRACKER_AGENT_NAME)));
116
129
  const source = await firstExisting(candidates);
117
130
  if (source === undefined) {
118
131
  warn(`tracker: agent source not found for "${TRACKER_AGENT_NAME}" (searched: ${candidates.join(', ')}) — ` +
119
132
  `run \`npm run build:mds\` if it is compiled from an .mds generator host`);
120
- return { converged: false, agent: 'unchanged' };
133
+ return finish(false, 'unchanged');
121
134
  }
122
135
  let wanted;
123
136
  try {
124
137
  const stamped = stampAgentTools(await fs.readFile(source, 'utf-8'), trackerServerTools(opts.servers ?? TRACKER_MCP_SERVERS_DEFAULT));
125
138
  if (!stamped.ok) {
126
139
  warn(`tracker: the Tracker agent source could not take the tracker servers (${stamped.error}) — not installed`);
127
- return { converged: false, agent: 'unchanged' };
140
+ return finish(false, 'unchanged');
128
141
  }
129
142
  wanted = stamped.value.content;
130
143
  }
131
144
  catch (err) {
132
145
  warn(`tracker: cannot read the Tracker agent source (${source}) — ${String(err)}`);
133
- return { converged: false, agent: 'unchanged' };
146
+ return finish(false, 'unchanged');
134
147
  }
135
148
  // Already converged — nothing to write, and nothing for the summary to announce.
136
149
  // The directory is not created either: an identical file at the target means it is
137
150
  // already there (see {@link writeWouldChangeNothing}).
138
151
  if (await writeWouldChangeNothing(wanted, target)) {
139
- return { converged: true, agent: 'unchanged' };
152
+ return finish(true, 'unchanged');
140
153
  }
141
154
  try {
142
155
  await fs.mkdir(path.dirname(target), { recursive: true });
143
- await fs.writeFile(target, wanted);
156
+ await writeFileReplacingLink(target, wanted, links);
144
157
  }
145
158
  catch (err) {
146
159
  warn(`tracker: failed to install the Tracker agent (${target}) — ${String(err)}`);
147
- return { converged: false, agent: 'unchanged' };
160
+ return finish(false, 'unchanged');
148
161
  }
149
- return { converged: true, agent: 'installed' };
162
+ return finish(true, 'installed');
150
163
  }
151
164
  //# sourceMappingURL=tracker-install.js.map
@@ -17,7 +17,7 @@
17
17
  * a `--plugin` run that does not install Git leaves the installed, stamped copy alone.
18
18
  * 2. `convergeTrackerArtifacts` stamps Tracker as it writes it, and compares the STAMPED text
19
19
  * with the installed file, so a steady-state run reports `unchanged`.
20
- * 3. `convergeCurationVariants` (init, `devflow curation --enable/--disable`) carries the
20
+ * 3. `convergeVariantAxes` (init, `devflow curation` and `devflow compliance`) carries the
21
21
  * installed stamp onto the variant it writes (`carryStampedTools`), the way it carries
22
22
  * `model:` and `effort:`, so a variant switch keeps the list and a repeat run is byte-equal.
23
23
  * 4. `devflow tracker --servers` re-stamps the installed copies at once ({@link restampTrackerAgents}).
@@ -34,9 +34,9 @@
34
34
  import { promises as fs } from 'fs';
35
35
  import * as path from 'path';
36
36
  import { stampAgentTools } from '../../core/agent-frontmatter.js';
37
- import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
38
37
  import { mdFileName } from '../../core/orphan-sweep.js';
39
38
  import { trackerServerTools } from '../../core/tracker.js';
39
+ import { newLinkReport, refuseUnrealFolder, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
40
40
  import { TRACKER_AGENT_NAME } from './tracker-install.js';
41
41
  /** The registry name of the Git agent. */
42
42
  export const GIT_AGENT_NAME = 'git';
@@ -51,7 +51,10 @@ export function isTrackerStampedAgent(agentName) {
51
51
  *
52
52
  * Rewrites only an installed copy (an absent one stays absent), compares against the bytes on
53
53
  * disk at the moment of writing, and writes atomically only when they differ, so a run that
54
- * changes nothing writes nothing. Never throws.
54
+ * changes nothing writes nothing. An `agents/devflow` that is a link, or not a folder, is refused:
55
+ * nothing is written there, both agents are `failed`, and the folder is named in `refusedFolders` and
56
+ * in a warning. An agent file that is a link is replaced by a real file, and named in `replacedLinks`
57
+ * and in a warning. Never throws.
55
58
  */
56
59
  export async function restampTrackerAgents(opts) {
57
60
  const { claudeDir, servers, warn } = opts;
@@ -59,11 +62,22 @@ export async function restampTrackerAgents(opts) {
59
62
  const unchanged = [];
60
63
  const absent = [];
61
64
  const failed = [];
65
+ const links = newLinkReport();
66
+ const finish = () => {
67
+ warnLinkReport(links, warn);
68
+ return { rewritten, unchanged, absent, failed, replacedLinks: links.replacedLinks, refusedFolders: links.refusedFolders };
69
+ };
62
70
  // Precondition, asserted in production code: a relative claudeDir would resolve the targets
63
71
  // against the working directory.
64
72
  if (!path.isAbsolute(claudeDir)) {
65
73
  warn(`tracker servers: claudeDir is not an absolute path ("${claudeDir}") — the installed agents were not stamped`);
66
- return { rewritten, unchanged, absent, failed: [...TRACKER_STAMPED_AGENTS] };
74
+ failed.push(...TRACKER_STAMPED_AGENTS);
75
+ return finish();
76
+ }
77
+ // A link at the folder sends every write below it into another place, so the folder is proven first.
78
+ if (await refuseUnrealFolder(links, path.join(claudeDir, 'agents', 'devflow'))) {
79
+ failed.push(...TRACKER_STAMPED_AGENTS);
80
+ return finish();
67
81
  }
68
82
  const tools = trackerServerTools(servers);
69
83
  for (const agent of TRACKER_STAMPED_AGENTS) {
@@ -94,7 +108,7 @@ export async function restampTrackerAgents(opts) {
94
108
  continue;
95
109
  }
96
110
  try {
97
- await writeFileAtomicExclusive(target, stamped.value.content);
111
+ await writeFileReplacingLink(target, stamped.value.content, links);
98
112
  rewritten.push(agent);
99
113
  }
100
114
  catch (err) {
@@ -102,6 +116,6 @@ export async function restampTrackerAgents(opts) {
102
116
  failed.push(agent);
103
117
  }
104
118
  }
105
- return { rewritten, unchanged, absent, failed };
119
+ return finish();
106
120
  }
107
121
  //# sourceMappingURL=tracker-servers.js.map
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Install of a workflow skill folder: `skills/devflow:<name>/`, from the build tree of the
3
+ * machine's exact variant key.
4
+ *
5
+ * D-WORKFLOW-FOLDER-INSTALL: the install (installViaFileCopy) and the converge
6
+ * (convergeVariantAxes, for init, `devflow curation` and `devflow compliance`) both call
7
+ * {@link installWorkflowFolder} after {@link readWorkflowFolder}, so a folder the install writes
8
+ * and a folder the converge writes after a switch are the same bytes. Three rules hold:
9
+ *
10
+ * - ONE source root: the tree built for exactly the machine's two switches
11
+ * (workflowFoldersSourceDir). A key whose tree lacks the folder, or lacks a file of its own
12
+ * file list, is a failed read. Nothing falls through to another key, so a compliance-off
13
+ * machine never receives the text of the compliance-on tree.
14
+ * - EXACTLY the key's file list (workflowFolderFiles): the installed folder holds those files
15
+ * and no others. A file the list omits for the key, and any other file below the folder, is
16
+ * pruned by the swap, so a switch to compliance off leaves no file the compliance-off list omits.
17
+ * - ONE swap: the folder is staged beside the skills folder and moved into place, and the old
18
+ * folder is restored if the move fails. If the restore fails too, the old folder is kept where
19
+ * it was moved to and the result names that path (`recoveryPath`); nothing deletes a backup
20
+ * that the result names as the way back. A folder that already holds the exact files is not
21
+ * touched, so an install that changes nothing writes nothing.
22
+ * - NO LOSS of the user's files: before any rename, the backup session moves every file of the
23
+ * old folder that devflow did not ship (the files of all four variant keys count as shipped)
24
+ * to the backup folder. If one cannot move, the result is `failed` and the folder stays as it
25
+ * is. After the swap the displaced folder is scanned again, its shipped files are removed by the
26
+ * session's list, and it goes with a plain `rmdir`; no folder is removed with a recursive `rm`
27
+ * except the staging folder, which holds only the copies this call wrote.
28
+ *
29
+ * A link at the skills folder or at the folder, or anything there that is not a folder, is refused
30
+ * and recorded in `links.refusedFolders`; a link is never followed and never written through.
31
+ * The folder name is checked at both entry points (the read and the install) with the output-name
32
+ * rule of the registry, because it is joined onto paths, one of them removed recursively.
33
+ * Nothing here throws: a failure is a result with a reason.
34
+ */
35
+ import { promises as fs } from 'fs';
36
+ import * as path from 'path';
37
+ import { workflowFoldersSourceDir } from '../../core/assets.js';
38
+ import { describeBuildIdCheckError, describeWorkflowFolderError, validateOutputName, verifyFolderBuildId, workflowFolderFiles, workflowFolderKey, } from '../../core/mds-variants.js';
39
+ import { isContainedIn } from '../../core/paths.js';
40
+ import { prefixSkillName } from '../../core/plugins.js';
41
+ import { discardDisplacedFolder } from './folder-swap.js';
42
+ import { formatCappedList } from './install-backup.js';
43
+ import { directoryState, refuseUnrealFolder } from './linked-folder.js';
44
+ /** The deepest level of a folder that is read or compared: `references/<file>` is level 1. */
45
+ const MAX_FOLDER_DEPTH = 4;
46
+ /** The folder inside the staging folder that is moved into place. */
47
+ const STAGED_FOLDER = 'folder';
48
+ /** Where the old folder waits while the new one is moved in: beside its staging folder. */
49
+ export const backupOf = (staging) => `${staging}.old`;
50
+ /** The prefix of the staging folder of one folder install; `mkdtemp` adds six random characters. */
51
+ export const stagingPrefixOf = (claudeDir, name) => path.join(claudeDir, `.devflow-workflow-${name}-${process.pid}-`);
52
+ const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
53
+ /**
54
+ * Read the files one folder holds under the exact variant key of `switches`.
55
+ *
56
+ * Fails, with a reason that names the folder, when its name is not a plain folder name, when the
57
+ * key's tree lacks the folder, when the folder is a link or not a folder, when a file of the key's
58
+ * list is missing or is not a plain file, when the stamped files do not carry one build id (a build
59
+ * that stopped half-way), or when that id is not the id of the files as read (a file from another
60
+ * build, or one changed after the build). The id is recomputed over every file of the key's list.
61
+ *
62
+ * @param root - Package root the build tree is read from.
63
+ */
64
+ export async function readWorkflowFolder(folder, switches, root) {
65
+ const named = validateOutputName(folder.name);
66
+ if (!named.ok) {
67
+ return { ok: false, reason: describeWorkflowFolderError({ kind: 'bad-folder-name', folder: folder.name, error: named.error }) };
68
+ }
69
+ const key = workflowFolderKey(switches);
70
+ const dir = path.join(workflowFoldersSourceDir(switches, root), folder.name);
71
+ if ((await directoryState(dir)) !== 'real') {
72
+ return {
73
+ ok: false,
74
+ reason: `workflow folder '${folder.name}' is not built for the variant key '${key}' (${dir}). Ensure build:mds ran successfully before install.`,
75
+ };
76
+ }
77
+ const files = [];
78
+ for (const rel of workflowFolderFiles(folder, key)) {
79
+ if (!isContainedIn(dir, rel)) {
80
+ return { ok: false, reason: `workflow folder '${folder.name}' names the file '${rel}', which leaves the folder` };
81
+ }
82
+ const source = path.join(dir, ...rel.split('/'));
83
+ try {
84
+ if (!(await fs.lstat(source)).isFile()) {
85
+ return { ok: false, reason: `${source} is not a plain file` };
86
+ }
87
+ files.push({ file: rel, bytes: await fs.readFile(source) });
88
+ }
89
+ catch (err) {
90
+ return { ok: false, reason: `${source} could not be read (${errorMessage(err)})` };
91
+ }
92
+ }
93
+ const ids = verifyFolderBuildId(files.map(({ file, bytes }) => ({ file, body: bytes.toString('utf-8') })));
94
+ if (!ids.ok) {
95
+ return { ok: false, reason: `workflow folder '${folder.name}' (${key}): ${describeBuildIdCheckError(ids.error)}` };
96
+ }
97
+ return { ok: true, files };
98
+ }
99
+ /**
100
+ * Every entry below `dir` as sorted POSIX paths: a file as `a/b.md`, a folder as `a/`. Null when
101
+ * anything is neither a plain file nor a folder (a link, a socket), or the walk is deeper than the bound.
102
+ */
103
+ async function listEntries(dir, prefix = '', depth = 0) {
104
+ if (depth > MAX_FOLDER_DEPTH)
105
+ return null;
106
+ let entries;
107
+ try {
108
+ entries = await fs.readdir(dir, { withFileTypes: true });
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ const out = [];
114
+ for (const entry of entries) {
115
+ const rel = `${prefix}${entry.name}`;
116
+ if (entry.isDirectory()) {
117
+ const nested = await listEntries(path.join(dir, entry.name), `${rel}/`, depth + 1);
118
+ if (nested === null)
119
+ return null;
120
+ out.push(`${rel}/`, ...nested);
121
+ }
122
+ else if (entry.isFile()) {
123
+ out.push(rel);
124
+ }
125
+ else {
126
+ return null;
127
+ }
128
+ }
129
+ return out.sort();
130
+ }
131
+ /** The entries a folder of exactly these files holds: the files and the folders that hold them. */
132
+ function expectedEntries(files) {
133
+ const entries = new Set();
134
+ for (const { file } of files) {
135
+ entries.add(file);
136
+ const parts = file.split('/');
137
+ for (let i = 1; i < parts.length; i++)
138
+ entries.add(`${parts.slice(0, i).join('/')}/`);
139
+ }
140
+ return [...entries].sort();
141
+ }
142
+ /** True only when `target` holds exactly these files with exactly these bytes. Any error answers false. */
143
+ async function folderHolds(target, files) {
144
+ const listed = await listEntries(target);
145
+ const wanted = expectedEntries(files);
146
+ if (listed === null || listed.length !== wanted.length || listed.some((entry, i) => entry !== wanted[i]))
147
+ return false;
148
+ for (const { file, bytes } of files) {
149
+ try {
150
+ if (!bytes.equals(await fs.readFile(path.join(target, ...file.split('/')))))
151
+ return false;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ return true;
158
+ }
159
+ /**
160
+ * Write `skills/devflow:<name>/` as exactly `files`: move the user's files aside, stage, then swap
161
+ * in. See the module comment.
162
+ *
163
+ * The staging folder sits under `claudeDir`, not under `skills/`, so a folder a crash leaves
164
+ * behind never looks like a second skill to Claude Code. It is made by `mkdtemp`, an exclusive
165
+ * create, so nothing that stands at a guessed name is written through; its name carries the
166
+ * unit kind, the folder name and the process id, and `mkdtemp` adds the random token. The folder
167
+ * is built in a folder inside it, which keeps the default mode of a folder this process makes.
168
+ * Never throws.
169
+ */
170
+ export async function installWorkflowFolder(opts) {
171
+ const { claudeDir, name, files, links, backups } = opts;
172
+ if (!path.isAbsolute(claudeDir)) {
173
+ return { name, outcome: 'failed', reason: `claudeDir is not an absolute path ("${claudeDir}")` };
174
+ }
175
+ const named = validateOutputName(name);
176
+ if (!named.ok) {
177
+ return { name, outcome: 'failed', reason: describeWorkflowFolderError({ kind: 'bad-folder-name', folder: name, error: named.error }) };
178
+ }
179
+ const skillsDir = path.join(claudeDir, 'skills');
180
+ const target = path.join(skillsDir, prefixSkillName(name));
181
+ const escaping = files.find(({ file }) => !isContainedIn(target, file));
182
+ if (escaping !== undefined) {
183
+ return { name, outcome: 'failed', reason: `workflow folder '${name}' names the file '${escaping.file}', which leaves the folder` };
184
+ }
185
+ if (await refuseUnrealFolder(links, skillsDir))
186
+ return { name, outcome: 'refused' };
187
+ if (await refuseUnrealFolder(links, target))
188
+ return { name, outcome: 'refused' };
189
+ let staging = null;
190
+ try {
191
+ if ((await directoryState(target)) === 'real') {
192
+ // Before any rename: what devflow did not ship moves out of the folder, whichever key's files it holds.
193
+ await backups.prepass([target]);
194
+ const kept = backups.keptUnder(target);
195
+ if (kept.length > 0) {
196
+ return {
197
+ name,
198
+ outcome: 'failed',
199
+ reason: `${kept.length} file(s) in ${target} could not be moved aside, so the folder was left as it is: ${formatCappedList(kept)}`,
200
+ };
201
+ }
202
+ if (await folderHolds(target, files))
203
+ return { name, outcome: 'unchanged' };
204
+ }
205
+ await fs.mkdir(claudeDir, { recursive: true });
206
+ staging = await fs.mkdtemp(stagingPrefixOf(claudeDir, name));
207
+ const staged = path.join(staging, STAGED_FOLDER);
208
+ await fs.mkdir(staged);
209
+ for (const { file, bytes } of files) {
210
+ const stagedFile = path.join(staged, ...file.split('/'));
211
+ await fs.mkdir(path.dirname(stagedFile), { recursive: true });
212
+ await fs.writeFile(stagedFile, bytes);
213
+ }
214
+ await fs.mkdir(skillsDir, { recursive: true });
215
+ const hadTarget = (await directoryState(target)) === 'real';
216
+ const backup = backupOf(staging);
217
+ if (hadTarget)
218
+ await fs.rename(target, backup);
219
+ try {
220
+ await fs.rename(staged, target);
221
+ }
222
+ catch (err) {
223
+ if (!hadTarget)
224
+ throw err;
225
+ try {
226
+ await fs.rename(backup, target);
227
+ }
228
+ catch (restoreErr) {
229
+ return {
230
+ name,
231
+ outcome: 'failed',
232
+ reason: `${errorMessage(err)}; the previous folder could not be put back (${errorMessage(restoreErr)}) and is kept at ${backup}`,
233
+ recoveryPath: backup,
234
+ };
235
+ }
236
+ throw err;
237
+ }
238
+ // The displaced folder is scanned again, because a file can arrive after the first scan.
239
+ if (hadTarget)
240
+ await discardDisplacedFolder(backups, backup, target);
241
+ return { name, outcome: 'installed' };
242
+ }
243
+ catch (err) {
244
+ return { name, outcome: 'failed', reason: errorMessage(err) };
245
+ }
246
+ finally {
247
+ if (staging !== null)
248
+ await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined);
249
+ }
250
+ }
251
+ //# sourceMappingURL=workflow-folder-install.js.map
@@ -0,0 +1,109 @@
1
+ {
2
+ "version": 1,
3
+ "hosts": {
4
+ "agents/code": {
5
+ "curation": true,
6
+ "compliance": false
7
+ },
8
+ "agents/debug": {
9
+ "curation": true,
10
+ "compliance": false
11
+ },
12
+ "agents/design": {
13
+ "curation": true,
14
+ "compliance": false
15
+ },
16
+ "agents/fix": {
17
+ "curation": true,
18
+ "compliance": false
19
+ },
20
+ "agents/git": {
21
+ "curation": false,
22
+ "compliance": true
23
+ },
24
+ "agents/harden": {
25
+ "curation": true,
26
+ "compliance": false
27
+ },
28
+ "agents/knowledge": {
29
+ "curation": true,
30
+ "compliance": false
31
+ },
32
+ "agents/refactor": {
33
+ "curation": true,
34
+ "compliance": false
35
+ },
36
+ "agents/research": {
37
+ "curation": true,
38
+ "compliance": false
39
+ },
40
+ "agents/review": {
41
+ "curation": true,
42
+ "compliance": false
43
+ },
44
+ "agents/skim": {
45
+ "curation": true,
46
+ "compliance": false
47
+ },
48
+ "agents/triage": {
49
+ "curation": true,
50
+ "compliance": true
51
+ },
52
+ "commands/bug-analysis": {
53
+ "curation": true,
54
+ "compliance": true
55
+ },
56
+ "commands/code-review": {
57
+ "curation": true,
58
+ "compliance": true
59
+ },
60
+ "commands/debug": {
61
+ "curation": true,
62
+ "compliance": false
63
+ },
64
+ "commands/dynamic-build": {
65
+ "curation": true,
66
+ "compliance": true
67
+ },
68
+ "commands/dynamic-plan": {
69
+ "curation": true,
70
+ "compliance": false
71
+ },
72
+ "commands/dynamic-profile": {
73
+ "curation": true,
74
+ "compliance": false
75
+ },
76
+ "commands/dynamic-tickets": {
77
+ "curation": true,
78
+ "compliance": true
79
+ },
80
+ "commands/explore": {
81
+ "curation": true,
82
+ "compliance": false
83
+ },
84
+ "commands/implement": {
85
+ "curation": true,
86
+ "compliance": true
87
+ },
88
+ "commands/plan": {
89
+ "curation": true,
90
+ "compliance": true
91
+ },
92
+ "commands/release": {
93
+ "curation": true,
94
+ "compliance": true
95
+ },
96
+ "commands/research": {
97
+ "curation": true,
98
+ "compliance": false
99
+ },
100
+ "commands/resolve": {
101
+ "curation": true,
102
+ "compliance": true
103
+ },
104
+ "commands/self-review": {
105
+ "curation": true,
106
+ "compliance": false
107
+ }
108
+ }
109
+ }