devflow-kit 2.4.0 → 3.0.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 +229 -0
- package/README.md +111 -18
- package/dist/agents/git.md +822 -0
- package/dist/cli/commands/agents.js +6 -1
- package/dist/cli/commands/ambient.js +160 -145
- package/dist/cli/commands/attribution-prompts.js +1 -1
- package/dist/cli/commands/capture.js +29 -55
- package/dist/cli/commands/compliance-prompts.js +1 -1
- package/dist/cli/commands/compliance.js +48 -55
- package/dist/cli/commands/context.js +17 -32
- package/dist/cli/commands/debug.js +65 -26
- package/dist/cli/commands/flags.js +3 -3
- package/dist/cli/commands/hud.js +34 -10
- package/dist/cli/commands/init-seed.js +61 -27
- package/dist/cli/commands/init.js +649 -240
- package/dist/cli/commands/install-report.js +200 -0
- package/dist/cli/commands/knowledge/index.js +2 -2
- package/dist/cli/commands/knowledge/toggle.js +35 -37
- package/dist/cli/commands/learning.js +79 -57
- package/dist/cli/commands/legacy-hooks.js +11 -14
- package/dist/cli/commands/memory.js +134 -135
- package/dist/cli/commands/prompt-io.js +4 -4
- package/dist/cli/commands/proxy.js +23 -41
- package/dist/cli/commands/security.js +81 -29
- package/dist/cli/commands/skills.js +71 -7
- package/dist/cli/commands/tracker-prompts.js +145 -0
- package/dist/cli/commands/tracker.js +277 -0
- package/dist/cli/commands/uninstall.js +520 -169
- package/dist/cli.js +2 -0
- package/dist/commands/bug-analysis.md +58 -14
- package/dist/commands/code-review.md +110 -32
- package/dist/commands/debug.md +55 -11
- package/dist/commands/dynamic-build.md +344 -73
- package/dist/commands/dynamic-plan.md +77 -27
- package/dist/commands/dynamic-profile.md +25 -11
- package/dist/commands/dynamic-tickets.md +76 -15
- package/dist/commands/explore.md +37 -7
- package/dist/commands/implement.md +314 -62
- package/dist/commands/plan.md +146 -32
- package/dist/commands/release.md +64 -17
- package/dist/commands/research.md +34 -8
- package/dist/commands/resolve.md +196 -68
- package/dist/commands/self-review.md +45 -9
- package/dist/core/agent-models.js +55 -12
- package/dist/core/assets.js +58 -2
- package/dist/core/compliance-compose.js +27 -27
- package/dist/core/evidence-policy.js +363 -0
- package/dist/core/feature-config.js +200 -65
- package/dist/core/feature-switch.js +112 -0
- package/dist/core/flags.js +34 -6
- package/dist/core/fs-atomic.js +27 -0
- package/dist/core/hook-log-dirs.js +104 -0
- package/dist/core/learning-tuning-config.js +5 -3
- package/dist/core/ledger-root.js +102 -0
- package/dist/core/manifest.js +38 -10
- package/dist/core/mds-variants.js +798 -0
- package/dist/core/migrations.js +49 -23
- package/dist/core/model-discovery.js +12 -1
- package/dist/core/plugins.js +361 -12
- package/dist/core/project-paths.js +1 -18
- package/dist/core/proxy-log.js +8 -6
- package/dist/core/proxy-state.js +11 -8
- package/dist/core/reference-sweep.js +136 -0
- package/dist/core/same-location.js +25 -0
- package/dist/core/tracker.js +494 -0
- package/dist/hud/components/config-counts.js +15 -4
- package/dist/hud/components/learning-counts.js +14 -0
- package/dist/hud/config.js +2 -1
- package/dist/hud/cost-history.js +2 -4
- package/dist/hud/git.js +52 -7
- package/dist/hud/index.js +7 -9
- package/dist/skills/git/references/decision-markers.md +19 -0
- package/dist/skills/git/references/learn-conventions.md +56 -0
- package/dist/skills/git/references/pr/check-ci-status.md +14 -0
- package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
- package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
- package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
- package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
- package/dist/skills/git/references/pr/post-review-summary.md +42 -0
- package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
- package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
- package/dist/skills/git/references/pr/validate-branch.md +18 -0
- package/dist/skills/git/references/publication-gate.md +13 -0
- package/dist/skills/git/references/tracker/_mcp.md +153 -0
- package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
- package/dist/skills/git/references/tracker/github/create-release.md +11 -0
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
- package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
- package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
- package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
- package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
- package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
- package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
- package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
- package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
- package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
- package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
- package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
- package/dist/skills/git/references/trust-rule.md +7 -0
- package/dist/targets/claude-code/claude-paths.js +59 -57
- package/dist/targets/claude-code/compliance-install.js +49 -65
- package/dist/targets/claude-code/hooks.js +108 -3
- package/dist/targets/claude-code/installer.js +1187 -32
- package/dist/targets/claude-code/legacy.js +5 -0
- package/dist/targets/claude-code/post-install.js +366 -151
- package/dist/targets/claude-code/tracker-install.js +134 -0
- package/package.json +8 -6
- package/src/assets/agents/code.md +45 -6
- package/src/assets/agents/design.md +2 -1
- package/src/assets/agents/git.mds +825 -0
- package/src/assets/agents/knowledge.md +3 -3
- package/src/assets/agents/learning.md +11 -0
- package/src/assets/agents/review.md +3 -1
- package/src/assets/agents/synthesize.md +1 -1
- package/src/assets/agents/test.md +16 -5
- package/src/assets/agents/tracker.md +474 -0
- package/src/assets/agents/validate.md +7 -5
- package/src/assets/commands/_partials/_compliance.mds +19 -1
- package/src/assets/commands/_partials/_decisions.mds +15 -3
- package/src/assets/commands/_partials/_docs_root.mds +35 -0
- package/src/assets/commands/_partials/_engine.mds +13 -11
- package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
- package/src/assets/commands/_partials/_factory.mds +1 -1
- package/src/assets/commands/_partials/_knowledge.mds +27 -9
- package/src/assets/commands/_partials/_plan_contract.mds +22 -7
- package/src/assets/commands/_partials/_preamble.mds +2 -2
- package/src/assets/commands/_partials/_publication.mds +8 -2
- package/src/assets/commands/_partials/_settings.mds +28 -0
- package/src/assets/commands/_partials/_ticket_template.mds +3 -2
- package/src/assets/commands/_partials/_tracker.mds +18 -0
- package/src/assets/commands/_partials/_wave.mds +16 -10
- package/src/assets/commands/bug-analysis.mds +31 -19
- package/src/assets/commands/code-review.mds +67 -41
- package/src/assets/commands/debug.mds +13 -7
- package/src/assets/commands/dynamic-build.mds +274 -66
- package/src/assets/commands/dynamic-plan.mds +50 -23
- package/src/assets/commands/dynamic-profile.mds +24 -11
- package/src/assets/commands/dynamic-tickets.mds +63 -16
- package/src/assets/commands/explore.mds +4 -5
- package/src/assets/commands/implement.mds +234 -67
- package/src/assets/commands/plan.mds +91 -33
- package/src/assets/commands/release.md +64 -17
- package/src/assets/commands/research.mds +11 -9
- package/src/assets/commands/resolve.mds +150 -78
- package/src/assets/commands/self-review.mds +24 -25
- package/src/assets/mds/git/_pr.mds +331 -0
- package/src/assets/mds/git/_references.mds +135 -0
- package/src/assets/mds/tracker/_common.mds +156 -0
- package/src/assets/mds/tracker/_github.mds +472 -0
- package/src/assets/mds/tracker/_jira.mds +407 -0
- package/src/assets/mds/tracker/_linear.mds +449 -0
- package/src/assets/mds/tracker/_mcp.mds +305 -0
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
- package/src/assets/scripts/hooks/background-memory-update +40 -19
- package/src/assets/scripts/hooks/capture-prompt +18 -8
- package/src/assets/scripts/hooks/capture-question +18 -8
- package/src/assets/scripts/hooks/capture-turn +27 -13
- package/src/assets/scripts/hooks/debug-trace +11 -6
- package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
- package/src/assets/scripts/hooks/ensure-proxy +9 -8
- package/src/assets/scripts/hooks/ensure-root-gitignore +236 -60
- package/src/assets/scripts/hooks/git-marker +48 -0
- package/src/assets/scripts/hooks/hook-log-init +3 -1
- package/src/assets/scripts/hooks/json-helper.cjs +228 -5
- package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -20
- package/src/assets/scripts/hooks/log-paths +80 -0
- package/src/assets/scripts/hooks/memory-worker +22 -13
- package/src/assets/scripts/hooks/pre-compact-memory +44 -15
- package/src/assets/scripts/hooks/preamble +1 -4
- package/src/assets/scripts/hooks/queue-append +146 -28
- package/src/assets/scripts/hooks/resolve-project-root +101 -7
- package/src/assets/scripts/hooks/session-start-context +534 -20
- package/src/assets/scripts/hooks/session-start-memory +38 -15
- package/src/assets/scripts/lib/project-config.cjs +633 -0
- package/src/assets/scripts/pr-evidence.cjs +1961 -0
- package/src/assets/scripts/redact-secrets.cjs +490 -62
- package/src/assets/scripts/release-trace.cjs +1143 -0
- package/src/assets/scripts/resolve-evidence-policy.cjs +1145 -0
- package/src/assets/scripts/resolve-settings.cjs +1054 -0
- package/src/assets/scripts/verify-evidence.cjs +1822 -0
- package/src/assets/skills/compliance/SKILL.md +4 -2
- package/src/assets/skills/docs-framework/SKILL.md +11 -10
- package/src/assets/skills/docs-framework/references/patterns.md +10 -17
- package/src/assets/skills/gap-analysis/SKILL.md +2 -2
- package/src/assets/skills/git/SKILL.md +8 -78
- package/src/assets/skills/git/references/github-api.md +179 -141
- package/src/assets/skills/git/references/patterns.md +11 -6
- package/src/assets/skills/review-methodology/SKILL.md +1 -1
- package/src/assets/skills/review-methodology/references/patterns.md +6 -61
- package/src/assets/skills/review-methodology/references/violations.md +14 -22
- package/src/assets/skills/worktree-support/SKILL.md +1 -1
- package/src/assets/skills/worktree-support/references/roots.md +29 -0
- package/src/targets/claude-code/templates/managed-settings.json +25 -9
- package/src/assets/agents/git.md +0 -938
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* @file reference-sweep.ts
|
|
5
|
+
*
|
|
6
|
+
* Path-keyed registry-diff sweep for the generated `devflow:git` reference tree.
|
|
7
|
+
*
|
|
8
|
+
* Sibling of {@link sweepOrphanedAssets} in orphan-sweep.ts and deliberately the same
|
|
9
|
+
* {@link SweepResult} shape — `scanned` is the non-vacuity counter, removals and
|
|
10
|
+
* per-item failures are reported rather than thrown (avoids PF-009).
|
|
11
|
+
*
|
|
12
|
+
* What is genuinely new is the KEY. `sweepOrphanedAssets` keys a flat directory by
|
|
13
|
+
* registry name through `mdEntryName`, which cannot express `tracker/{provider}/{op}.md`:
|
|
14
|
+
* two providers may legitimately both carry a `comment.md`, so the registry name has to
|
|
15
|
+
* be the relative PATH, and the walk has to descend.
|
|
16
|
+
*
|
|
17
|
+
* Never writes, only removes (avoids PF-011).
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Descent bound for every walk over the generated reference tree — this sweep and the
|
|
21
|
+
* build's own prune, which imports it (`pruneOrphans` in scripts/build-mds.ts). One
|
|
22
|
+
* tree, one bound: two walkers each carrying their own literal is how they come to
|
|
23
|
+
* disagree on both the number of levels and what happens at the last one.
|
|
24
|
+
*
|
|
25
|
+
* `depth` counts the walked root as 0 and the bound is the deepest directory a walk may
|
|
26
|
+
* descend INTO, so `depth > MAX_REFERENCE_SWEEP_DEPTH` is the breach — the comparison
|
|
27
|
+
* the build's walks already use. The installed tree is two levels deep
|
|
28
|
+
* (`tracker/{provider}/{op}.md`), so 8 is generous. The bound exists because an
|
|
29
|
+
* unbounded recursion over a directory neither walker owns would spin on a symlink loop
|
|
30
|
+
* rather than fail — every loop has an explicit upper bound.
|
|
31
|
+
*
|
|
32
|
+
* The two walkers answer a breach differently by design, and both answer out loud: the
|
|
33
|
+
* build throws (a generated tree that deep is a build bug, and dist/ is still the
|
|
34
|
+
* build's own to fail), while this sweep records the unvisited subtree in `failed`
|
|
35
|
+
* (avoids PF-009 — an install is not abandoned over one subtree). Neither returns
|
|
36
|
+
* quietly: a subtree the walk never entered must not be summarised as converged.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_REFERENCE_SWEEP_DEPTH = 8;
|
|
39
|
+
/**
|
|
40
|
+
* Remove everything under `root` that the manifest does not name.
|
|
41
|
+
*
|
|
42
|
+
* @param root - Directory to converge (the installed `references/tracker/` tree).
|
|
43
|
+
* @param knownRelPaths - POSIX paths relative to `root` that must survive. Derived from
|
|
44
|
+
* the build's own module registries by the caller — never hand-listed.
|
|
45
|
+
*
|
|
46
|
+
* @returns A {@link SweepResult} whose `removed` entries are POSIX relative paths. A
|
|
47
|
+
* directory into which no manifest path descends is removed WHOLE and reported by its
|
|
48
|
+
* own relative path — leaving it empty would be a convergence that stops one step
|
|
49
|
+
* short, and an empty provider directory is indistinguishable from a provider whose
|
|
50
|
+
* references failed to install. A subtree left unswept because it breached
|
|
51
|
+
* {@link MAX_REFERENCE_SWEEP_DEPTH} is reported in `failed` under its own relative
|
|
52
|
+
* path, for the same reason: everything this sweep did not converge is named.
|
|
53
|
+
*
|
|
54
|
+
* A missing or unreadable `root` is a no-op, not an error: the overlay creates the tree
|
|
55
|
+
* it converges, so an absent one simply means there is nothing to prune yet.
|
|
56
|
+
*/
|
|
57
|
+
export async function sweepOrphanedReferences(root, knownRelPaths) {
|
|
58
|
+
const acc = { scanned: 0, removed: [], failed: [] };
|
|
59
|
+
await sweepDirectory(root, '', 0, knownRelPaths, directoryPrefixes(knownRelPaths), acc);
|
|
60
|
+
return { scanned: acc.scanned, removed: acc.removed, failed: acc.failed };
|
|
61
|
+
}
|
|
62
|
+
async function sweepDirectory(dir, prefix, depth, known, knownDirPrefixes, acc) {
|
|
63
|
+
if (depth > MAX_REFERENCE_SWEEP_DEPTH) {
|
|
64
|
+
// A breached bound means this subtree is never visited, so any orphan inside it
|
|
65
|
+
// survives. Report it through the same channel as a failed removal: a silent return
|
|
66
|
+
// would let the install summary claim convergence over ground never covered.
|
|
67
|
+
acc.failed.push({
|
|
68
|
+
name: prefix || dir,
|
|
69
|
+
error: new Error(`${prefix || dir}: sweep descent exceeds the bound of ${MAX_REFERENCE_SWEEP_DEPTH} ` +
|
|
70
|
+
`levels — this subtree was not swept and any orphans under it survive.`),
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let entries;
|
|
75
|
+
try {
|
|
76
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return; /* absent or unreadable — not an error (avoids PF-009) */
|
|
80
|
+
}
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const relPath = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
83
|
+
const fullPath = path.join(dir, entry.name);
|
|
84
|
+
// A real subdirectory is either an ancestor of something the manifest names — in
|
|
85
|
+
// which case descend — or dead weight, in which case take the whole subtree.
|
|
86
|
+
// isDirectory() is false for a symlink-to-dir, so a planted link is treated as a
|
|
87
|
+
// leaf and removed rather than followed.
|
|
88
|
+
if (entry.isDirectory()) {
|
|
89
|
+
if (knownDirPrefixes.has(`${relPath}/`)) {
|
|
90
|
+
await sweepDirectory(fullPath, relPath, depth + 1, known, knownDirPrefixes, acc);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
acc.scanned++;
|
|
94
|
+
try {
|
|
95
|
+
await fs.rm(fullPath, { recursive: true, force: true });
|
|
96
|
+
acc.removed.push(relPath);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
acc.failed.push({ name: relPath, error: err }); /* per-item isolation (avoids PF-009) */
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
acc.scanned++;
|
|
104
|
+
if (known.has(relPath))
|
|
105
|
+
continue;
|
|
106
|
+
try {
|
|
107
|
+
await fs.rm(fullPath, { force: true });
|
|
108
|
+
acc.removed.push(relPath);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
acc.failed.push({ name: relPath, error: err }); /* per-item isolation (avoids PF-009) */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Every directory prefix some manifest path sits under, trailing slash included:
|
|
117
|
+
* `tracker/github/comment.md` contributes `tracker/` and `tracker/github/`.
|
|
118
|
+
*
|
|
119
|
+
* Built once per sweep so "does anything the manifest names live under this directory?"
|
|
120
|
+
* costs one `has` per entry instead of a scan of the whole manifest per entry. The
|
|
121
|
+
* answer is identical to that scan: `dir/` is a member exactly when some manifest path
|
|
122
|
+
* starts with `dir/`.
|
|
123
|
+
*
|
|
124
|
+
* Both loops are bounded by their own input — the inner one walks separators from a
|
|
125
|
+
* strictly increasing offset, so it terminates at the last one in the path.
|
|
126
|
+
*/
|
|
127
|
+
function directoryPrefixes(known) {
|
|
128
|
+
const prefixes = new Set();
|
|
129
|
+
for (const p of known) {
|
|
130
|
+
for (let cut = p.indexOf('/'); cut !== -1; cut = p.indexOf('/', cut + 1)) {
|
|
131
|
+
prefixes.add(p.slice(0, cut + 1));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return prefixes;
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=reference-sweep.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* Whether two paths name one location — realpaths where they exist, so a symlinked
|
|
5
|
+
* HOME, or macOS's /var → /private/var temp tree, still matches. The shell hooks make
|
|
6
|
+
* the same physical comparison in git-marker's df_is_project_root (D-HOOKS-GIT-ONLY).
|
|
7
|
+
*/
|
|
8
|
+
export async function isSameLocation(a, b) {
|
|
9
|
+
const canonical = (target) => fs.realpath(target).catch(() => path.resolve(target));
|
|
10
|
+
const [left, right] = await Promise.all([canonical(a), canonical(b)]);
|
|
11
|
+
return left === right;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* D-INIT-NOT-HOME: the CLI half of D-HOOKS-GIT-ONLY. A git repository rooted at
|
|
15
|
+
* HOME (a dotfiles repo) is not a project: its `<root>/.devflow` is the machine
|
|
16
|
+
* root ~/.devflow, and its `.gitignore` and `.claudeignore` are the user's own
|
|
17
|
+
* home-directory files. So no command writes per-repository files there — the
|
|
18
|
+
* same rule the hooks' df_is_project_root applies — and `roots` is returned
|
|
19
|
+
* without any entry that is HOME.
|
|
20
|
+
*/
|
|
21
|
+
export async function withoutHomeRoots(roots, homeDir) {
|
|
22
|
+
const verdicts = await Promise.all(roots.map(root => isSameLocation(root, homeDir)));
|
|
23
|
+
return roots.filter((_, i) => !verdicts[i]);
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=same-location.js.map
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core issue-tracker provider registry, boundary parser, and file lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Two halves, deliberately in one module:
|
|
5
|
+
* - The DOMAIN half (registry, TrackerProvider, parseTrackerId,
|
|
6
|
+
* normalizeTrackerFeature, path derivation, the frontmatter parser) is
|
|
7
|
+
* pure — zero I/O.
|
|
8
|
+
* - The LIFECYCLE half (rearmTrackerInference, applyTrackerSentinel,
|
|
9
|
+
* migrateLegacyTrackerConventions) owns the ~/.devflow tracker files.
|
|
10
|
+
* It sits here rather than in a target adapter because ~/.devflow is
|
|
11
|
+
* devflow-global, not Claude-Code-specific — the same reason manifest.ts's
|
|
12
|
+
* read/write live in src/core/ (applies ADR-013).
|
|
13
|
+
*
|
|
14
|
+
* avoids PF-014: nothing here calls process.exit() and nothing throws; every
|
|
15
|
+
* fallible path returns a Result, so callers own their own error rendering
|
|
16
|
+
* and a try/finally in a caller is never skipped.
|
|
17
|
+
*
|
|
18
|
+
* D-TRACKER-OWNER [DR-22][DR-10]: the attempt counters and the machine provider
|
|
19
|
+
* sentinel have exactly ONE owner each — `rearmTrackerInference` and
|
|
20
|
+
* `applyTrackerSentinel` — and every command that touches one goes through it.
|
|
21
|
+
* The sentinel is converged by `devflow init` and `devflow tracker --set`; the
|
|
22
|
+
* counters are re-armed by those two and by `devflow tracker --status`, which
|
|
23
|
+
* is the command a capped user reaches for (D-F). The conventions files are the
|
|
24
|
+
* Tracker agent's to write and the user's to edit: no command moves, renames or
|
|
25
|
+
* deletes one, and the single legacy file is moved once, by the migration that
|
|
26
|
+
* calls `migrateLegacyTrackerConventions`. Never inline an `fs.rm` at a call
|
|
27
|
+
* site.
|
|
28
|
+
*/
|
|
29
|
+
import { promises as fs } from 'fs';
|
|
30
|
+
import * as path from 'path';
|
|
31
|
+
/**
|
|
32
|
+
* Canonical issue-tracker provider registry.
|
|
33
|
+
*
|
|
34
|
+
* D-TRACKER-ONE-DOMAIN [PF-049]: this table is the SINGLE authority on the closed
|
|
35
|
+
* provider set, and `TrackerProvider` below is a projection of it. A provider
|
|
36
|
+
* therefore exists for the type system exactly when it has a row here: there is no
|
|
37
|
+
* hand-listed union that can admit an id `parseTrackerId` rejects and the wizard
|
|
38
|
+
* never offers. `as const satisfies` is what buys both halves — `satisfies` checks
|
|
39
|
+
* every row against `TrackerProviderDefinition` while `as const` keeps the ids as
|
|
40
|
+
* literals rather than widening them to `string` (the same pattern
|
|
41
|
+
* `VARIANT_MODULES` uses in src/core/mds-variants.ts).
|
|
42
|
+
*
|
|
43
|
+
* Labels and hints are stamped verbatim into rendered prompts — no user input is
|
|
44
|
+
* ever written. The validated ID selects a hardcoded path prefix from a static
|
|
45
|
+
* map at every consumer; the input string is never concatenated into a path.
|
|
46
|
+
*
|
|
47
|
+
* Hints deliberately avoid the token "MCP": transport must not leak into
|
|
48
|
+
* user-facing text (standing prohibition).
|
|
49
|
+
*/
|
|
50
|
+
export const TRACKER_PROVIDERS = [
|
|
51
|
+
{ id: 'github', label: 'GitHub', hint: 'GitHub Issues through the gh CLI' },
|
|
52
|
+
{ id: 'jira', label: 'Jira', hint: 'Atlassian Jira issues and projects' },
|
|
53
|
+
{ id: 'linear', label: 'Linear', hint: 'Linear issues and projects' },
|
|
54
|
+
];
|
|
55
|
+
/** Registry IDs in registry order. */
|
|
56
|
+
export const TRACKER_PROVIDER_IDS = TRACKER_PROVIDERS.map(p => p.id);
|
|
57
|
+
/**
|
|
58
|
+
* The off position and the value every malformed input self-heals to.
|
|
59
|
+
* Existing installs and every GitHub user land here, silently.
|
|
60
|
+
*/
|
|
61
|
+
export const DEFAULT_TRACKER_PROVIDER = 'github';
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Artifact basenames — one spelling for every TypeScript reader
|
|
64
|
+
//
|
|
65
|
+
// NOT the only spelling in the repository, and a rename that assumes it is will
|
|
66
|
+
// miss the places these names are hardcoded (PF-013): the SessionStart hook's
|
|
67
|
+
// Section 3 (shell) and the Tracker agent's prompt (prose), neither of which can
|
|
68
|
+
// import from here. Each is cross-pinned against these constants by tests —
|
|
69
|
+
// shell-hooks-tracker, tracker-agent, uninstall-logic and core/tracker — so the
|
|
70
|
+
// spellings cannot drift silently, but they do have to move together.
|
|
71
|
+
//
|
|
72
|
+
// Two families are one-per-provider and are DERIVED from the registry rather
|
|
73
|
+
// than spelled out — the conventions files under TRACKER_CONVENTIONS_DIR and the
|
|
74
|
+
// attempt counters (TRACKER_ATTEMPTS_NAMES) — so a fourth provider is covered the
|
|
75
|
+
// day it joins the registry. The staging files are one-per-invocation under a
|
|
76
|
+
// mktemp name, so uninstall imports TRACKER_STAGED_PREFIX and resolves it against
|
|
77
|
+
// disk.
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
/**
|
|
80
|
+
* `~/.devflow/tracker/` — one inferred conventions file per provider
|
|
81
|
+
* (USER CONTENT on uninstall).
|
|
82
|
+
*
|
|
83
|
+
* D-TRACKER-PER-PROVIDER-CONVENTIONS: conventions are learned per PROVIDER, not
|
|
84
|
+
* per machine. A repository may select its own tracker in its committed
|
|
85
|
+
* `.devflow/project.json`, so one machine can meet more than one provider, and a
|
|
86
|
+
* single machine-wide file would be silently authoritative for whichever provider
|
|
87
|
+
* did not write it. One file per provider means a provider change moves nothing
|
|
88
|
+
* aside: the other provider's conventions stay where they are, correct for the
|
|
89
|
+
* repositories that use it.
|
|
90
|
+
*/
|
|
91
|
+
export const TRACKER_CONVENTIONS_DIR = 'tracker';
|
|
92
|
+
/**
|
|
93
|
+
* `~/.devflow/tracker.md` — the single machine-wide conventions file releases
|
|
94
|
+
* before per-provider conventions wrote. The `tracker-conventions-per-provider-v1`
|
|
95
|
+
* migration moves it to its provider's file; one it cannot place (no provider in
|
|
96
|
+
* its frontmatter, or that provider's file already exists) stays, as USER CONTENT.
|
|
97
|
+
*/
|
|
98
|
+
export const TRACKER_LEGACY_CONVENTIONS_FILE = 'tracker.md';
|
|
99
|
+
/** `~/.devflow/.tracker.attempts` — the single counter those releases kept; the migration removes it. */
|
|
100
|
+
export const TRACKER_LEGACY_ATTEMPTS_FILE = '.tracker.attempts';
|
|
101
|
+
/**
|
|
102
|
+
* `~/.devflow/.tracker.enabled` — the machine provider sentinel (install artifact).
|
|
103
|
+
* Holds the provider NAME on one line; absent for github (see applyTrackerSentinel).
|
|
104
|
+
*/
|
|
105
|
+
export const TRACKER_ENABLED_FILE = '.tracker.enabled';
|
|
106
|
+
/** `~/.devflow/.tracker.processing` — the Tracker agent's atomic claim (install artifact). */
|
|
107
|
+
export const TRACKER_CLAIM_FILE = '.tracker.processing';
|
|
108
|
+
/**
|
|
109
|
+
* `~/.devflow/.tracker-staged.XXXXXX` — the Tracker agent's scrubbed staging
|
|
110
|
+
* file (install artifact). A basename PREFIX, not a basename.
|
|
111
|
+
*
|
|
112
|
+
* The agent takes its stage with `mktemp` inside `~/.devflow`, one per
|
|
113
|
+
* invocation so two concurrent runs never share a path, and removes it from a
|
|
114
|
+
* `trap` on EXIT INT TERM. A SIGKILL outruns the trap, so a stage can outlive
|
|
115
|
+
* the run it belongs to — and an artifacts-only uninstall that removes exact
|
|
116
|
+
* paths walks straight past it while reporting the directory swept. It carries
|
|
117
|
+
* no user-authored content (it is a scrubbed, unplaced copy of what the agent
|
|
118
|
+
* was about to write), so it is an install artifact, never user content.
|
|
119
|
+
*
|
|
120
|
+
* Spelled twice for the reason the basenames above are (PF-013): the agent's
|
|
121
|
+
* prompt cannot import from here, so the mktemp template is also a literal in
|
|
122
|
+
* src/assets/agents/tracker.md, and tests/core/tracker.test.ts pins the two
|
|
123
|
+
* spellings together.
|
|
124
|
+
*/
|
|
125
|
+
export const TRACKER_STAGED_PREFIX = '.tracker-staged.';
|
|
126
|
+
/**
|
|
127
|
+
* `.tracker.{provider}.attempts` — one provider's inference attempt counter
|
|
128
|
+
* (install artifact).
|
|
129
|
+
*
|
|
130
|
+
* Per provider, so one provider whose tracker connection is broken spends only
|
|
131
|
+
* its own attempts: a machine that meets jira in one repository and linear in
|
|
132
|
+
* another must not have the broken one cap the working one. The claim file stays
|
|
133
|
+
* global — one Tracker agent runs at a time on a machine, whichever provider it
|
|
134
|
+
* is learning.
|
|
135
|
+
*/
|
|
136
|
+
export function trackerAttemptsName(provider) {
|
|
137
|
+
return `.tracker.${provider}.attempts`;
|
|
138
|
+
}
|
|
139
|
+
/** Every provider's attempt-counter basename, in registry order — what a re-arm clears and uninstall removes. */
|
|
140
|
+
export const TRACKER_ATTEMPTS_NAMES = TRACKER_PROVIDER_IDS.map(id => trackerAttemptsName(id));
|
|
141
|
+
/**
|
|
142
|
+
* How many background inference attempts a machine gets before the SessionStart
|
|
143
|
+
* hook stops emitting the setup directive.
|
|
144
|
+
*
|
|
145
|
+
* Spelled twice for the reason the basenames above are (PF-013): the hook is the
|
|
146
|
+
* enforcer and cannot import from here, so `TRACKER_ATTEMPTS_MAX=5` is also a
|
|
147
|
+
* literal in src/assets/scripts/hooks/session-start-context. This constant is the
|
|
148
|
+
* number `devflow tracker --status` quotes back when it re-arms the counter, and
|
|
149
|
+
* tests/core/tracker.test.ts pins the two spellings together so the report cannot
|
|
150
|
+
* promise more tries than the hook grants.
|
|
151
|
+
*/
|
|
152
|
+
export const TRACKER_ATTEMPTS_MAX = 5;
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Internal helpers
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
const REGISTRY_SET = new Set(TRACKER_PROVIDER_IDS);
|
|
157
|
+
const VALID_IDS_LIST = TRACKER_PROVIDER_IDS.join(', ');
|
|
158
|
+
/** Longest rejected value echoed back to the terminal, in CODE POINTS. */
|
|
159
|
+
const MAX_ECHOED_VALUE = 40;
|
|
160
|
+
function errorMessage(err) {
|
|
161
|
+
return err instanceof Error ? err.message : String(err);
|
|
162
|
+
}
|
|
163
|
+
/** The errno of a rejected fs call, or undefined when the failure carries none. */
|
|
164
|
+
function errnoCode(err) {
|
|
165
|
+
return typeof err === 'object' && err !== null ? err.code : undefined;
|
|
166
|
+
}
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Exported functions — domain
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
/**
|
|
171
|
+
* The one runtime membership test for the provider domain.
|
|
172
|
+
*
|
|
173
|
+
* Byte-exact against the registry: no trim, no case folding, no alias. Both the
|
|
174
|
+
* strict parser and the tolerant normaliser narrow through this, so the domain
|
|
175
|
+
* the compiler enforces and the domain the boundary enforces are the same set by
|
|
176
|
+
* construction rather than by two casts that happen to agree.
|
|
177
|
+
*/
|
|
178
|
+
export function isTrackerProvider(value) {
|
|
179
|
+
return typeof value === 'string' && REGISTRY_SET.has(value);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Render an untrusted provider token for a terminal message.
|
|
183
|
+
*
|
|
184
|
+
* Rejected values are echoed so the user can see what they typed, so they are
|
|
185
|
+
* third-party input at a display sink: control characters (terminal escapes,
|
|
186
|
+
* BEL, newlines) are replaced and the value is truncated. Used by
|
|
187
|
+
* `parseTrackerId`'s error text and by `devflow tracker --status`.
|
|
188
|
+
*
|
|
189
|
+
* Takes `unknown`, and a non-string renders as its TYPE: the module's
|
|
190
|
+
* never-throws contract (PF-014) has to hold for what reaches this sink, not
|
|
191
|
+
* only for what the signature says does — `devflow tracker --status` reads
|
|
192
|
+
* a conventions file's hand-editable frontmatter, and a caller-side guard is one edit
|
|
193
|
+
* from being gone. Naming the type also keeps the render total, where `String()`
|
|
194
|
+
* would hand control to a caller-supplied `toString` — itself both a throw path
|
|
195
|
+
* and an echo path this function exists to close.
|
|
196
|
+
*/
|
|
197
|
+
export function describeTrackerValue(raw) {
|
|
198
|
+
if (typeof raw !== 'string')
|
|
199
|
+
return `<${raw === null ? 'null' : typeof raw}>`;
|
|
200
|
+
// The class is written with ESCAPES, never literal control bytes. A raw NUL
|
|
201
|
+
// makes grep classify this whole file as binary — it prints "Binary file
|
|
202
|
+
// matches" and skips the lines — so every grep-based sweep over src/core/
|
|
203
|
+
// silently stops covering tracker.ts while still exiting 0. Pinned by
|
|
204
|
+
// tests/guards/no-control-bytes.test.ts.
|
|
205
|
+
// eslint-disable-next-line no-control-regex
|
|
206
|
+
const stripped = raw.replace(/[\x00-\x1f\x7f]/g, '?');
|
|
207
|
+
// Cut by CODE POINT. `slice` cuts UTF-16 code units, so a cut landing inside an
|
|
208
|
+
// astral character (emoji, CJK ext-B) emits the lone surrogate half of it.
|
|
209
|
+
const points = [...stripped];
|
|
210
|
+
return points.length > MAX_ECHOED_VALUE
|
|
211
|
+
? `${points.slice(0, MAX_ECHOED_VALUE).join('')}…`
|
|
212
|
+
: stripped;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Strict boundary parser for a provider token (`--tracker <id>`,
|
|
216
|
+
* `devflow tracker --set <id>`).
|
|
217
|
+
*
|
|
218
|
+
* D-TRACKER-STRICT: REJECT, NEVER REPAIR. Membership is byte-exact against the
|
|
219
|
+
* registry — no trim, no case folding, no alias, no space-to-dash. `jira-cloud`
|
|
220
|
+
* errors rather than becoming `jira`; `JIRA` and `jira ` error rather than being
|
|
221
|
+
* repaired into `jira`. This is the one place compliance's shape is copied but
|
|
222
|
+
* its `normalizeId` is NOT: repairing a provider silently installs mechanics for
|
|
223
|
+
* a tracker the user did not name, and a repaired token is the echo the
|
|
224
|
+
* static-path-prefix rule exists to prevent. All-invalid input never yields a
|
|
225
|
+
* silent default.
|
|
226
|
+
*
|
|
227
|
+
* The error names every valid ID and the offending value.
|
|
228
|
+
*/
|
|
229
|
+
export function parseTrackerId(input) {
|
|
230
|
+
if (input === '') {
|
|
231
|
+
return { ok: false, error: `Missing tracker provider ID. Valid IDs: ${VALID_IDS_LIST}` };
|
|
232
|
+
}
|
|
233
|
+
if (!isTrackerProvider(input)) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
error: `Unknown tracker provider ID: "${describeTrackerValue(input)}". Valid IDs: ${VALID_IDS_LIST}`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, value: input };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Tolerant sink normaliser for a raw `manifest.features.tracker` value.
|
|
243
|
+
*
|
|
244
|
+
* Absent, null, malformed, a bare string, or an unknown provider → the default
|
|
245
|
+
* `{provider:'github'}` (applies ADR-014 self-heal). Drop-not-error: a manifest
|
|
246
|
+
* written by a newer devflow, or hand-edited, degrades to what this build
|
|
247
|
+
* understands instead of failing the read.
|
|
248
|
+
*
|
|
249
|
+
* D-TRACKER-SELF-HEAL [DR-26]: self-healing here is SILENT and emits no
|
|
250
|
+
* DEGRADED — that is the correct ADR-014 behaviour, and it is a different
|
|
251
|
+
* condition from a per-repo config value outside the domain (which does emit
|
|
252
|
+
* `unknown tracker provider`). The two must not be conflated.
|
|
253
|
+
*/
|
|
254
|
+
export function normalizeTrackerFeature(raw) {
|
|
255
|
+
const DEFAULT = { provider: DEFAULT_TRACKER_PROVIDER };
|
|
256
|
+
if (raw === null || raw === undefined || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
257
|
+
return DEFAULT;
|
|
258
|
+
}
|
|
259
|
+
const obj = raw;
|
|
260
|
+
if (!isTrackerProvider(obj.provider))
|
|
261
|
+
return DEFAULT;
|
|
262
|
+
return { provider: obj.provider };
|
|
263
|
+
}
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Exported functions — path derivation (pure)
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
/** `{devflowDir}/tracker` — the directory holding one conventions file per provider. */
|
|
268
|
+
export function trackerConventionsDir(devflowDir) {
|
|
269
|
+
return path.join(devflowDir, TRACKER_CONVENTIONS_DIR);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* `{devflowDir}/tracker/{provider}.md` — one provider's inferred conventions.
|
|
273
|
+
*
|
|
274
|
+
* The provider is a registry id (the type admits nothing else), so the segment it
|
|
275
|
+
* contributes is one of three fixed words, never user input joined into a path.
|
|
276
|
+
*/
|
|
277
|
+
export function trackerConventionsPath(devflowDir, provider) {
|
|
278
|
+
return path.join(trackerConventionsDir(devflowDir), `${provider}.md`);
|
|
279
|
+
}
|
|
280
|
+
/** `{devflowDir}/.tracker.{provider}.attempts` — one provider's inference attempt counter. */
|
|
281
|
+
export function trackerAttemptsPath(devflowDir, provider) {
|
|
282
|
+
return path.join(devflowDir, trackerAttemptsName(provider));
|
|
283
|
+
}
|
|
284
|
+
/** `{devflowDir}/.tracker.enabled` — the machine provider sentinel. */
|
|
285
|
+
export function trackerEnabledSentinelPath(devflowDir) {
|
|
286
|
+
return path.join(devflowDir, TRACKER_ENABLED_FILE);
|
|
287
|
+
}
|
|
288
|
+
/** How many leading lines of a conventions file are scanned for frontmatter. */
|
|
289
|
+
const FRONTMATTER_SCAN_LINES = 40;
|
|
290
|
+
/**
|
|
291
|
+
* Parse the leading frontmatter of a conventions file's head.
|
|
292
|
+
*
|
|
293
|
+
* The one parser, shared by `devflow tracker --status` and the
|
|
294
|
+
* per-provider migration, so the two can never disagree about which provider a
|
|
295
|
+
* file names. Scans at most {@link FRONTMATTER_SCAN_LINES} lines; the first
|
|
296
|
+
* occurrence of each key wins.
|
|
297
|
+
*/
|
|
298
|
+
export function parseTrackerFrontmatter(head) {
|
|
299
|
+
const lines = head.split('\n', FRONTMATTER_SCAN_LINES);
|
|
300
|
+
if (lines[0]?.trim() !== '---')
|
|
301
|
+
return { hasFrontmatter: false };
|
|
302
|
+
let provider;
|
|
303
|
+
let inferredFrom;
|
|
304
|
+
for (const line of lines.slice(1)) {
|
|
305
|
+
if (line.trim() === '---')
|
|
306
|
+
break;
|
|
307
|
+
const match = /^([A-Za-z-]+):\s*(.*)$/.exec(line);
|
|
308
|
+
if (match === null)
|
|
309
|
+
continue;
|
|
310
|
+
if (match[1] === 'provider' && provider === undefined)
|
|
311
|
+
provider = match[2].trim();
|
|
312
|
+
if (match[1] === 'inferred-from' && inferredFrom === undefined)
|
|
313
|
+
inferredFrom = match[2].trim();
|
|
314
|
+
}
|
|
315
|
+
return { hasFrontmatter: true, provider, inferredFrom };
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* How many leading BYTES of a conventions file any reader takes — the bound the
|
|
319
|
+
* Tracker agent writes to and the Git agent loads. A line cap alone bounds the
|
|
320
|
+
* SCAN, not the read: the file's size is not devflow's to assume (avoids PF-023:
|
|
321
|
+
* a bound is only real at the sink).
|
|
322
|
+
*/
|
|
323
|
+
export const TRACKER_CONVENTIONS_READ_BYTES = 8000;
|
|
324
|
+
/**
|
|
325
|
+
* Read at most `limit` bytes from the head of a REGULAR file.
|
|
326
|
+
*
|
|
327
|
+
* `undefined` for an absent, unreadable or non-regular path — a FIFO or a device
|
|
328
|
+
* is refused before it is opened, so a hostile entry can never block a read.
|
|
329
|
+
* Follows a symlink to read what it names (a reader cares what the conventions
|
|
330
|
+
* SAY); it never moves or writes through one. Never throws (PF-014).
|
|
331
|
+
*/
|
|
332
|
+
export async function readBoundedHead(filePath, limit) {
|
|
333
|
+
let handle;
|
|
334
|
+
try {
|
|
335
|
+
if (!(await fs.stat(filePath)).isFile())
|
|
336
|
+
return undefined;
|
|
337
|
+
handle = await fs.open(filePath, 'r');
|
|
338
|
+
const buffer = Buffer.alloc(limit);
|
|
339
|
+
const { bytesRead } = await handle.read(buffer, 0, limit, 0);
|
|
340
|
+
return buffer.subarray(0, bytesRead).toString('utf-8');
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
await handle?.close().catch(() => undefined);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Exported functions — file lifecycle
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
/**
|
|
353
|
+
* Re-arm background convention inference by removing every provider's attempt
|
|
354
|
+
* counter.
|
|
355
|
+
*
|
|
356
|
+
* The documented re-arm path (OD-14 / D-F): `devflow init` AND
|
|
357
|
+
* `devflow tracker --set/--status` all re-arm, so a user whose tracker server
|
|
358
|
+
* was broken for five sessions is not stuck at the cap forever. Every provider's
|
|
359
|
+
* counter, not only the machine's: a repository's committed project.json can
|
|
360
|
+
* select a provider the machine never did, and its counter is the one a user in
|
|
361
|
+
* that repository is capped on.
|
|
362
|
+
*
|
|
363
|
+
* Idempotent when a counter is absent; never throws (PF-014). `fs.rm` with
|
|
364
|
+
* `force` treats an absent file — and an absent parent directory — as success.
|
|
365
|
+
*/
|
|
366
|
+
export async function rearmTrackerInference(devflowDir) {
|
|
367
|
+
try {
|
|
368
|
+
for (const name of TRACKER_ATTEMPTS_NAMES) {
|
|
369
|
+
await fs.rm(path.join(devflowDir, name), { force: true });
|
|
370
|
+
}
|
|
371
|
+
return { ok: true, value: undefined };
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
return { ok: false, error: `Could not reset the tracker attempt counter: ${errorMessage(err)}` };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Converge the `.tracker.enabled` sentinel to the machine provider.
|
|
379
|
+
*
|
|
380
|
+
* [DR-10] Holds the provider NAME, one line, whenever the machine provider is NOT
|
|
381
|
+
* github, and is removed when it is. The SessionStart hook reads it with the
|
|
382
|
+
* `read` builtin, so the machine provider costs no fork: a GitHub user pays one
|
|
383
|
+
* `stat`, and a jira machine whose conventions are already learned pays a stat,
|
|
384
|
+
* a builtin read and a second stat — zero forks either way. A name rather than a
|
|
385
|
+
* bare presence marker, so the hook never has to open the manifest to learn which
|
|
386
|
+
* provider it is gating.
|
|
387
|
+
*
|
|
388
|
+
* Converges unconditionally in both directions (avoids PF-015): a provider
|
|
389
|
+
* flipped back to github removes the sentinel in the same call shape that wrote
|
|
390
|
+
* it, so there is no "enable wrote it, disable forgot it" asymmetry.
|
|
391
|
+
*/
|
|
392
|
+
export async function applyTrackerSentinel(devflowDir, provider) {
|
|
393
|
+
const sentinel = trackerEnabledSentinelPath(devflowDir);
|
|
394
|
+
try {
|
|
395
|
+
if (provider === DEFAULT_TRACKER_PROVIDER) {
|
|
396
|
+
await fs.rm(sentinel, { force: true });
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
await fs.mkdir(devflowDir, { recursive: true });
|
|
400
|
+
await fs.writeFile(sentinel, `${provider}\n`, 'utf-8');
|
|
401
|
+
}
|
|
402
|
+
return { ok: true, value: undefined };
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
return { ok: false, error: `Could not update the tracker sentinel: ${errorMessage(err)}` };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Move the pre-per-provider `~/.devflow/tracker.md` to the provider file its
|
|
410
|
+
* frontmatter names (D-TRACKER-PER-PROVIDER-CONVENTIONS), once.
|
|
411
|
+
*
|
|
412
|
+
* - no legacy file → `none`
|
|
413
|
+
* - frontmatter names a registry provider,
|
|
414
|
+
* and that provider's file does not exist → `moved`, by `rename(2)`
|
|
415
|
+
* - no provider it can name, or the target
|
|
416
|
+
* already exists → `kept`: the file stays where it is,
|
|
417
|
+
* user content, with one reason
|
|
418
|
+
* - any I/O failure → `failed`
|
|
419
|
+
*
|
|
420
|
+
* `rename(2)` moves a SYMLINK itself, never the file it points at, so a
|
|
421
|
+
* conventions file kept in a dotfiles repository stays there and only the link
|
|
422
|
+
* moves. A link's target is resolved relative to the link's directory, so a
|
|
423
|
+
* RELATIVE link would resolve from one level deeper and name a different path —
|
|
424
|
+
* the conventions would silently vanish behind a dangling link that also blocks
|
|
425
|
+
* the Tracker agent's create-exclusive write. A relative link is therefore `kept`,
|
|
426
|
+
* with its reason; an absolute one moves unaffected. rename also replaces an
|
|
427
|
+
* existing destination without a word, which is why the target is probed first
|
|
428
|
+
* and a present one is never overwritten: that file holds conventions a user may
|
|
429
|
+
* have corrected by hand, and it is the provider's own.
|
|
430
|
+
*
|
|
431
|
+
* D-TRACKER-MIGRATE-WINDOW: the probe and the rename are two calls, so a Tracker
|
|
432
|
+
* agent that writes the same provider's file between them is replaced by the
|
|
433
|
+
* legacy one. Accepted: the window is one syscall wide, only `devflow init` opens
|
|
434
|
+
* it, and both files hold that provider's conventions. link-then-unlink would
|
|
435
|
+
* close it but follows a symlink on macOS, fails where hard links are
|
|
436
|
+
* unsupported, and can leave both names behind.
|
|
437
|
+
*
|
|
438
|
+
* The legacy single attempt counter is removed on every run that reaches the end:
|
|
439
|
+
* it carries no user content, the per-provider counters replace it, and a file
|
|
440
|
+
* nothing reads is one no uninstall list would account for.
|
|
441
|
+
*/
|
|
442
|
+
export async function migrateLegacyTrackerConventions(devflowDir) {
|
|
443
|
+
const from = path.join(devflowDir, TRACKER_LEGACY_CONVENTIONS_FILE);
|
|
444
|
+
try {
|
|
445
|
+
await fs.rm(path.join(devflowDir, TRACKER_LEGACY_ATTEMPTS_FILE), { force: true });
|
|
446
|
+
let isLink;
|
|
447
|
+
try {
|
|
448
|
+
isLink = (await fs.lstat(from)).isSymbolicLink();
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
if (errnoCode(err) === 'ENOENT')
|
|
452
|
+
return { kind: 'none' };
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
if (isLink && !path.isAbsolute(await fs.readlink(from))) {
|
|
456
|
+
return {
|
|
457
|
+
kind: 'kept',
|
|
458
|
+
reason: `${from} is a symbolic link with a relative target, which would no longer resolve from ` +
|
|
459
|
+
`${trackerConventionsDir(devflowDir)}, so it was left in place — re-create it there by hand.`,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
const head = await readBoundedHead(from, TRACKER_CONVENTIONS_READ_BYTES);
|
|
463
|
+
if (head === undefined) {
|
|
464
|
+
return { kind: 'kept', reason: `${from} is not a readable regular file, so it was left in place.` };
|
|
465
|
+
}
|
|
466
|
+
const named = parseTrackerFrontmatter(head).provider;
|
|
467
|
+
if (!isTrackerProvider(named)) {
|
|
468
|
+
return {
|
|
469
|
+
kind: 'kept',
|
|
470
|
+
reason: `${from} names no tracker provider in its frontmatter, so it was left in place — ` +
|
|
471
|
+
`move it to ${trackerConventionsDir(devflowDir)}/{provider}.md by hand if it is still wanted.`,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
const to = trackerConventionsPath(devflowDir, named);
|
|
475
|
+
try {
|
|
476
|
+
await fs.lstat(to);
|
|
477
|
+
return {
|
|
478
|
+
kind: 'kept',
|
|
479
|
+
reason: `${from} was left in place — ${to} already exists and is never overwritten.`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
if (errnoCode(err) !== 'ENOENT')
|
|
484
|
+
throw err;
|
|
485
|
+
}
|
|
486
|
+
await fs.mkdir(trackerConventionsDir(devflowDir), { recursive: true });
|
|
487
|
+
await fs.rename(from, to);
|
|
488
|
+
return { kind: 'moved', from, to, provider: named };
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
return { kind: 'failed', error: `Could not move ${from} to its provider's file: ${errorMessage(err)}` };
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
//# sourceMappingURL=tracker.js.map
|