@davidbalzan/groundwork 0.4.7 → 0.4.9
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/README.md +29 -0
- package/package.json +2 -2
- package/src/commands/doctor.mjs +105 -15
- package/src/commands/update.mjs +22 -2
- package/src/lib/baseline.mjs +48 -2
package/README.md
CHANGED
|
@@ -158,6 +158,35 @@ Shipped to `docs/.groundwork/scripts/` and preferred by the matching skills:
|
|
|
158
158
|
|
|
159
159
|
---
|
|
160
160
|
|
|
161
|
+
## Upgrading: nothing migrates your content
|
|
162
|
+
|
|
163
|
+
**`groundwork update` never rewrites your work.** It refreshes the files Groundwork
|
|
164
|
+
installed and leaves everything else alone. There is no auto-migration, and that is a
|
|
165
|
+
decision rather than a gap — an auto-migrating skill was considered and rejected, because
|
|
166
|
+
the failure mode is silent data loss in the one place you would not look for it.
|
|
167
|
+
|
|
168
|
+
What that means in practice:
|
|
169
|
+
|
|
170
|
+
- **A doc you edited is KEPT and named in the summary**, not overwritten. Taking an
|
|
171
|
+
upstream version is a deliberate act: `--force-skills` or `--force-docs`.
|
|
172
|
+
- **An older board grammar keeps parsing as itself.** A 5-column Active Streams table is
|
|
173
|
+
read as `workstreams.lanes-v0` — it is not upgraded, not rewritten, and not treated as
|
|
174
|
+
broken. You move to the 6-column `workstreams.v1` grammar when you choose to, by
|
|
175
|
+
editing the table.
|
|
176
|
+
- **Nothing reformats a file to normalise it.** If a table renders, it stays as written.
|
|
177
|
+
|
|
178
|
+
One honest exception, and it is narrow. A file installed **before Groundwork recorded
|
|
179
|
+
install baselines** has no recorded hash to compare against, so `update` cannot tell an
|
|
180
|
+
edited file from an untouched one. Those used to be kept forever — which quietly meant a
|
|
181
|
+
card could stay two weeks stale while `update` reported success. Now, **if git says the
|
|
182
|
+
file is tracked and has no uncommitted changes, it is refreshed and listed under
|
|
183
|
+
"Refreshed N pre-baseline file(s)"** — because git can give it back (`git checkout --
|
|
184
|
+
<path>`), so the refresh costs a command rather than a fact. If the file is untracked,
|
|
185
|
+
uncommitted, or the project is not a git repo, it is still kept.
|
|
186
|
+
|
|
187
|
+
If you want to see what would change before it does, `groundwork update --dry-run` reports
|
|
188
|
+
the same decisions without writing.
|
|
189
|
+
|
|
161
190
|
## Versions stay honest
|
|
162
191
|
|
|
163
192
|
Two mechanisms keep dependency versions from rotting:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidbalzan/groundwork",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "Groundwork — an installable AI development workflow (skills + doc methodology) you bolt onto any repo.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"author": "David Balzan",
|
|
30
30
|
"license": "UNLICENSED",
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@davidbalzan/groundwork-seam": "0.1.
|
|
32
|
+
"@davidbalzan/groundwork-seam": "^0.1.0"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"groundwork": "node src/cli.mjs",
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { countCheckboxes, progressBar } from "../lib/progress.mjs";
|
|
|
7
7
|
import { ARTIFACTS, workDocPaths } from "../lib/artifacts.mjs";
|
|
8
8
|
import { log, bold, green, yellow, dim, cyan } from "../lib/log.mjs";
|
|
9
9
|
import { adrTripwire } from "../lib/adr-tripwire.mjs";
|
|
10
|
-
import { phaseCitationsIn, parseFactsDoc, parseWorkDoc, workDocIssues, workDocLegacyWriteIssues, workDocIssuesDetailed, queueItemsOf, doneEntriesOf, workstreamsV1RowsOf, sweepTagOf, sweepTagCensus, refsIn, refIn } from "@davidbalzan/groundwork-seam";
|
|
10
|
+
import { phaseCitationsIn, parseFactsDoc, parseWorkDoc, workDocIssues, workDocLegacyWriteIssues, workDocIssuesDetailed, queueItemsOf, doneEntriesOf, workstreamsV1RowsOf, sweepTagOf, sweepTagCensus, refsIn, refIn, commitRefsIn, commitRefMatches, withoutCodeContext } from "@davidbalzan/groundwork-seam";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* `groundwork doctor` — flag drift between the docs and reality. Offline + deterministic.
|
|
@@ -299,21 +299,50 @@ guard("queue-done-loop", () => {
|
|
|
299
299
|
// Fourth matcher in one day to re-derive ref/tag matching and get it wrong. The
|
|
300
300
|
// primitive lives in the seam now so the fifth one written cannot.
|
|
301
301
|
const doneRefs = doneEntries.flatMap((e) => refsIn(String(e.ref ?? "")));
|
|
302
|
+
// A CLOSURE CAN BE TIED BY COMMIT, NOT ONLY BY PR — `land` requires a PR by
|
|
303
|
+
// rule, but a `.md`-direct workflow with no PR (this repo's own aide pushes
|
|
304
|
+
// docs straight to `main` all day) still closes an item, and its only
|
|
305
|
+
// citable evidence is `owner/repo@sha`. Before this, a genuinely-closed
|
|
306
|
+
// item with no PR read as "cites no PR, so nothing can tie it to DONE.md" —
|
|
307
|
+
// its ONLY remedies were to fabricate a PR reference or to prune delivered
|
|
308
|
+
// work, and both falsify the record. Surfaced 2026-08-31.
|
|
309
|
+
const doneCommits = doneEntries.flatMap((e) => commitRefsIn(String(e.ref ?? "")));
|
|
310
|
+
// THE REPO BEING CHECKED, so a BARE `#170` resolves instead of being unknown.
|
|
311
|
+
//
|
|
312
|
+
// The seam's primitive refuses a bare-vs-qualified tie by design, and doctor
|
|
313
|
+
// deliberately passed nothing on the premise that "the corpus writes
|
|
314
|
+
// `kit#94`/`console#38`, so the tie is recoverable". MEASURED ON THIS REPO:
|
|
315
|
+
// it does not. Queue items cite bare `#170, #173` while DONE.md qualifies
|
|
316
|
+
// them, and six correctly-logged closures were reported as untieable — a
|
|
317
|
+
// warning ON CORRECT CONTENT, which is the whole subject of this task and
|
|
318
|
+
// reached David twice as a suspected data problem.
|
|
319
|
+
//
|
|
320
|
+
// This NARROWS the check, so it ships with the case where it still fires:
|
|
321
|
+
// `console#39` still does not match `groundwork-kit#39`, because the primitive
|
|
322
|
+
// compares repository leaves rather than numbers. Unknown becomes known only
|
|
323
|
+
// where the repo actually answers it; a doc outside a git repo passes null and
|
|
324
|
+
// gets exactly today's behaviour.
|
|
325
|
+
const contextRepo = originRepoOf(docs);
|
|
302
326
|
const problems = [];
|
|
303
327
|
for (const item of closed) {
|
|
304
328
|
const cited = refsIn(String(item.text));
|
|
329
|
+
const citedCommits = commitRefsIn(String(item.text));
|
|
305
330
|
const short = String(item.text).replace(/\s+/g, " ").slice(0, 70);
|
|
306
|
-
if (cited.length === 0) {
|
|
307
|
-
problems.push(`${item.where}: [x] item cites
|
|
308
|
-
} else if (
|
|
331
|
+
if (cited.length === 0 && citedCommits.length === 0) {
|
|
332
|
+
problems.push(`${item.where}: [x] item cites neither a PR nor a commit, so nothing can tie it to DONE.md — "${short}…"`);
|
|
333
|
+
} else if (
|
|
334
|
+
!cited.some((r) => refIn(r, doneRefs, { contextRepo })) &&
|
|
335
|
+
!citedCommits.some((c) => doneCommits.some((d) => commitRefMatches(c, d)))
|
|
336
|
+
) {
|
|
309
337
|
// NOT "this closure is unlogged" — the check cannot know that. The citation
|
|
310
|
-
// is the only tie it has, and an item whose body never names the PR
|
|
311
|
-
// closed it is UNTIEABLE, even when DONE.md records the
|
|
312
|
-
// Measured live: an item citing `kit#84` and
|
|
313
|
-
// #119, and #119 IS in DONE.md — the item
|
|
314
|
-
// Reporting that as "no entry" would send
|
|
338
|
+
// is the only tie it has, and an item whose body never names the PR or
|
|
339
|
+
// commit that closed it is UNTIEABLE, even when DONE.md records the
|
|
340
|
+
// closure perfectly. Measured live: an item citing `kit#84` and
|
|
341
|
+
// `console#39` was closed by #119, and #119 IS in DONE.md — the item
|
|
342
|
+
// simply never mentions it. Reporting that as "no entry" would send
|
|
343
|
+
// someone to add a duplicate.
|
|
315
344
|
problems.push(
|
|
316
|
-
`${item.where}: [x] item cites ${cited.map((r) => r.raw).join(", ")}, none of which DONE.md records — so this closure CANNOT BE TIED by citation. ` +
|
|
345
|
+
`${item.where}: [x] item cites ${[...cited.map((r) => r.raw), ...citedCommits.map((c) => `@${c}`)].join(", ")}, none of which DONE.md records — so this closure CANNOT BE TIED by citation. ` +
|
|
317
346
|
`It may still be logged under a ref the item does not name; the tie, not the log, is what is missing — "${short}…"`,
|
|
318
347
|
);
|
|
319
348
|
}
|
|
@@ -526,7 +555,12 @@ guard("undeclared-work-docs", () => {
|
|
|
526
555
|
const rogue = [];
|
|
527
556
|
for (const rel of mdFiles) {
|
|
528
557
|
if (declared.has(rel) || isTemplate(rel)) continue;
|
|
529
|
-
|
|
558
|
+
// Same root cause as the wikilink resolver, and the same fix: an example
|
|
559
|
+
// item line inside a fence is a SPECIMEN, not work. A doc explaining
|
|
560
|
+
// `- [ ] (P1) …` must not be reported as an undeclared queue — declaring it
|
|
561
|
+
// to silence the warning would make every queue check read specimens as
|
|
562
|
+
// real items, which is the defect this check exists to find.
|
|
563
|
+
const text = withoutCodeContext(readText(path.join(docs, rel)));
|
|
530
564
|
const count = (text.match(/^- \[[ x]\] \((?:P1|P2|P3)\) /gm) ?? []).length;
|
|
531
565
|
if (count > 0 && ITEM.test(text)) rogue.push({ rel, count });
|
|
532
566
|
}
|
|
@@ -536,12 +570,38 @@ guard("undeclared-work-docs", () => {
|
|
|
536
570
|
// much it looked at.
|
|
537
571
|
const scanned = `${mdFiles.filter((r) => !isTemplate(r)).length} doc(s) scanned (templates excluded), ${declared.size} declared queue.v1`;
|
|
538
572
|
if (rogue.length) {
|
|
573
|
+
// AN ARCHIVE MUST NOT BE TOLD TO DECLARE `queue.v1`. Declaring it is what
|
|
574
|
+
// the remedy says, and it would make every archived row parse as OPEN work —
|
|
575
|
+
// the check would resurrect the rows someone archived on purpose. A consumer
|
|
576
|
+
// fleet already ships `QUEUE_PARKED_ARCHIVE` (58 rows) and
|
|
577
|
+
// `QUEUE_ITEMS_ARCHIVE` (48); following this advice there would revive 106
|
|
578
|
+
// closed items. The archive GRAMMAR itself is a separate item; what belongs
|
|
579
|
+
// here is not handing out a remedy that breaks the thing it is aimed at.
|
|
580
|
+
const isArchive = (rel) => /archive/i.test(rel);
|
|
581
|
+
const archives = rogue.filter((r) => isArchive(r.rel));
|
|
582
|
+
const live = rogue.filter((r) => !isArchive(r.rel));
|
|
583
|
+
// A REMEDY MUST NAME A PATH THAT EXISTS IN THE REPO BEING CHECKED.
|
|
584
|
+
// `src/lib/artifacts.mjs` is correct only inside the kit, and it survived
|
|
585
|
+
// review because its authors live in the one repo where it works. A consumer
|
|
586
|
+
// reading it goes looking for a file it does not have, which is worse than
|
|
587
|
+
// no remedy: it reads as "your repo is broken" rather than "this advice is
|
|
588
|
+
// not for you".
|
|
589
|
+
const manifestPath = path.join(root, "src/lib/artifacts.mjs");
|
|
590
|
+
const where = exists(manifestPath)
|
|
591
|
+
? "`src/lib/artifacts.mjs`"
|
|
592
|
+
: "your artifact manifest (this repo has no `src/lib/artifacts.mjs` — that path exists only in the groundwork kit itself)";
|
|
539
593
|
push("undeclared-work-docs", "warn", [
|
|
540
594
|
scanned,
|
|
541
|
-
...
|
|
595
|
+
...live.map(
|
|
542
596
|
(r) => `docs/${r.rel} holds ${r.count} queue item(s) and is NOT declared queue.v1 in the manifest — no queue check reads it`,
|
|
543
597
|
),
|
|
544
|
-
|
|
598
|
+
...archives.map(
|
|
599
|
+
(r) =>
|
|
600
|
+
`docs/${r.rel} looks like an ARCHIVE holding ${r.count} archived item(s) — it is NOT declared, and it must NOT be declared queue.v1: that would make every archived row parse as open work`,
|
|
601
|
+
),
|
|
602
|
+
...(live.length
|
|
603
|
+
? [`declare it in ${where} with \`grammar: "queue.v1"\` and re-run \`groundwork artifacts\` — a manifest-derived population cannot find what the manifest does not name`]
|
|
604
|
+
: []),
|
|
545
605
|
]);
|
|
546
606
|
} else {
|
|
547
607
|
push("undeclared-work-docs", "ok", [`${scanned}, no undeclared queue-shaped doc`]);
|
|
@@ -582,7 +642,10 @@ guard("doc-registration", () => {
|
|
|
582
642
|
if (unregistered.length) {
|
|
583
643
|
push("doc-registration", "warn", [
|
|
584
644
|
...unregistered.map((f) => `docs/${f} is not in the artifact manifest — no stated writer, reader, or purpose`),
|
|
585
|
-
|
|
645
|
+
// SAME REMEDY DEFECT AS undeclared-work-docs, and the same fix: name a
|
|
646
|
+
// path the CHECKED repo has. This line is the one a consumer sees most,
|
|
647
|
+
// because an unregistered doc is the most common finding in a fresh repo.
|
|
648
|
+
`add it to ${exists(path.join(root, "src/lib/artifacts.mjs")) ? "src/lib/artifacts.mjs" : "your artifact manifest (this repo has no src/lib/artifacts.mjs — that path exists only in the groundwork kit itself)"} and re-run \`groundwork artifacts\`, or delete it — an unregistered doc is ungoverned, not merely undocumented`,
|
|
586
649
|
]);
|
|
587
650
|
} else {
|
|
588
651
|
push("doc-registration", "ok", [`${onDisk.length} doc(s) under docs/, all registered in the manifest`]);
|
|
@@ -1151,9 +1214,36 @@ function doneLineNamesPhase(doneText, n) {
|
|
|
1151
1214
|
return String(doneText).split("\n").some((line) => re.test(line));
|
|
1152
1215
|
}
|
|
1153
1216
|
|
|
1217
|
+
/**
|
|
1218
|
+
* `owner/repo` for the repository being checked, or null.
|
|
1219
|
+
*
|
|
1220
|
+
* Read from the git remote rather than from package.json: the remote is what
|
|
1221
|
+
* the refs in these documents actually name, and a package can be published
|
|
1222
|
+
* under a name that differs from its repository.
|
|
1223
|
+
*/
|
|
1224
|
+
function originRepoOf(dir) {
|
|
1225
|
+
try {
|
|
1226
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
1227
|
+
cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
1228
|
+
}).trim();
|
|
1229
|
+
const m = /[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url);
|
|
1230
|
+
return m ? m[1] : null;
|
|
1231
|
+
} catch {
|
|
1232
|
+
// No remote, or not a repo. Null is today's behaviour exactly — an unknown
|
|
1233
|
+
// context must not become a guessed one.
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1154
1238
|
function wikilinks(text) {
|
|
1155
1239
|
const out = [];
|
|
1156
|
-
|
|
1240
|
+
// A WIKILINK IN A CODE SPAN IS NOT A LINK. Documentation contains examples of
|
|
1241
|
+
// what it documents, and this check flagged 15/15 quoted specimens — one of
|
|
1242
|
+
// them a deliberately planted `[[orphan]]` control, which means the check
|
|
1243
|
+
// reported a failure ON THE EVIDENCE THAT IT WORKS. Stripping preserves line
|
|
1244
|
+
// structure, so every location this function's callers report still points at
|
|
1245
|
+
// the same place (§withoutCodeContext).
|
|
1246
|
+
for (const m of withoutCodeContext(text).matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
1157
1247
|
let t = m[1].split("|")[0].split("#")[0].trim(); // strip alias + heading
|
|
1158
1248
|
if (!t) continue; // intra-doc heading link
|
|
1159
1249
|
out.push(t);
|
package/src/commands/update.mjs
CHANGED
|
@@ -13,6 +13,8 @@ import { writeArtifacts } from "./artifacts.mjs";
|
|
|
13
13
|
import { refreshableDocs } from "../lib/artifacts.mjs";
|
|
14
14
|
import {
|
|
15
15
|
classify,
|
|
16
|
+
gitVerdictFor,
|
|
17
|
+
isRecoverableRefresh,
|
|
16
18
|
hashFile,
|
|
17
19
|
isProtected,
|
|
18
20
|
readBaseline,
|
|
@@ -61,6 +63,8 @@ export function update(targetDir, flags = {}) {
|
|
|
61
63
|
let refreshed = 0;
|
|
62
64
|
let added = 0;
|
|
63
65
|
let unchanged = 0;
|
|
66
|
+
/** Pre-baseline files refreshed only because git can restore them. */
|
|
67
|
+
const recovered = [];
|
|
64
68
|
for (const name of toRefresh) {
|
|
65
69
|
const src = path.join(PAYLOAD_SKILLS, name, "SKILL.md");
|
|
66
70
|
if (!exists(src)) {
|
|
@@ -69,7 +73,7 @@ export function update(targetDir, flags = {}) {
|
|
|
69
73
|
}
|
|
70
74
|
const rel = path.join(TARGET.skills, name, "SKILL.md");
|
|
71
75
|
const dest = path.join(root, rel);
|
|
72
|
-
const verdict = classify(dest, src, baseline[rel]);
|
|
76
|
+
const verdict = classify(dest, src, baseline[rel], gitVerdictFor(root, rel));
|
|
73
77
|
|
|
74
78
|
if (verdict === "current") {
|
|
75
79
|
unchanged++;
|
|
@@ -80,6 +84,7 @@ export function update(targetDir, flags = {}) {
|
|
|
80
84
|
kept.push({ rel, verdict, kind: "skill" });
|
|
81
85
|
continue;
|
|
82
86
|
}
|
|
87
|
+
if (isRecoverableRefresh(verdict)) recovered.push(rel);
|
|
83
88
|
if (!dry) {
|
|
84
89
|
copyFileSafe(src, dest, { force: true });
|
|
85
90
|
nextBaseline[rel] = hashFile(src);
|
|
@@ -128,7 +133,7 @@ export function update(targetDir, flags = {}) {
|
|
|
128
133
|
const src = path.join(PAYLOAD_DOCS, rel.replace(/^docs\//, ""));
|
|
129
134
|
if (!exists(src)) continue; // ARTIFACTS.md is generated, not in payload — skip
|
|
130
135
|
const dest = path.join(root, rel);
|
|
131
|
-
const verdict = classify(dest, src, baseline[rel]);
|
|
136
|
+
const verdict = classify(dest, src, baseline[rel], gitVerdictFor(root, rel));
|
|
132
137
|
if (verdict === "current") {
|
|
133
138
|
nextBaseline[rel] = hashFile(src);
|
|
134
139
|
continue;
|
|
@@ -167,6 +172,21 @@ export function update(targetDir, flags = {}) {
|
|
|
167
172
|
}
|
|
168
173
|
if (unchanged) log.info(dim(` ${unchanged} skill(s) already current.`));
|
|
169
174
|
|
|
175
|
+
// REFRESHING A PRE-BASELINE FILE IS A CHOICE, SO IT IS REPORTED.
|
|
176
|
+
//
|
|
177
|
+
// These had no baseline to judge by and were previously kept forever on the
|
|
178
|
+
// assumption they had been edited. They are refreshed now because git says
|
|
179
|
+
// they are tracked and clean — recoverable, not unexamined — and saying so is
|
|
180
|
+
// what keeps that from being a silent overwrite.
|
|
181
|
+
if (recovered.length) {
|
|
182
|
+
log.heading(`Refreshed ${recovered.length} pre-baseline file(s) — git shows them tracked and unmodified`);
|
|
183
|
+
for (const rel of recovered) console.log(` ${dim(rel)}`);
|
|
184
|
+
console.log(
|
|
185
|
+
dim(" No baseline existed for these, so they were previously kept indefinitely.\n" +
|
|
186
|
+
" Recover any of them with `git checkout -- <path>`; review with `git diff`.")
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
170
190
|
// Report what we did NOT overwrite. This block is the whole point of the
|
|
171
191
|
// change: the previous version force-copied these and then printed that your
|
|
172
192
|
// docs had been left untouched.
|
package/src/lib/baseline.mjs
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import fs from "node:fs";
|
|
26
26
|
import path from "node:path";
|
|
27
|
+
import { execFileSync } from "node:child_process";
|
|
27
28
|
import { createHash } from "node:crypto";
|
|
28
29
|
import { TARGET } from "./paths.mjs";
|
|
29
30
|
import { exists } from "./fs.mjs";
|
|
@@ -78,14 +79,59 @@ export function writeBaseline(root, files) {
|
|
|
78
79
|
* "modified" — differs from the recorded baseline: a local edit. KEEP.
|
|
79
80
|
* "unknown" — installed, differs from payload, no baseline to judge by. KEEP.
|
|
80
81
|
*/
|
|
81
|
-
export function classify(projectFile, payloadFile, baselineHash) {
|
|
82
|
+
export function classify(projectFile, payloadFile, baselineHash, gitVerdict) {
|
|
82
83
|
if (!exists(projectFile)) return "absent";
|
|
83
84
|
const now = hashFile(projectFile);
|
|
84
85
|
const payload = hashFile(payloadFile);
|
|
85
86
|
if (now !== undefined && now === payload) return "current";
|
|
86
|
-
if (baselineHash === undefined)
|
|
87
|
+
if (baselineHash === undefined) {
|
|
88
|
+
// ASK GIT BEFORE ASSUMING AN EDIT.
|
|
89
|
+
//
|
|
90
|
+
// A file installed before baselines existed has no hash to judge by, and
|
|
91
|
+
// the safe answer was to assume it had been edited and keep it. Safe, and
|
|
92
|
+
// permanent: it kept a two-week-stale card while reporting success, because
|
|
93
|
+
// "no baseline" never becomes "baseline" for a file that is never
|
|
94
|
+
// overwritten. The protection outlived the uncertainty that justified it.
|
|
95
|
+
//
|
|
96
|
+
// What the protection is actually FOR is preventing IRRECOVERABLE loss — a
|
|
97
|
+
// reverted SKILL.md changes agent behaviour silently. Git recoverability
|
|
98
|
+
// removes exactly that: a tracked file with no uncommitted changes can be
|
|
99
|
+
// restored with `git checkout`, so refreshing it costs a command and never
|
|
100
|
+
// a fact. Untracked or dirty, the old assumption still holds, because there
|
|
101
|
+
// the overwrite really is unrecoverable.
|
|
102
|
+
return gitVerdict === "tracked-clean" ? "stale-recoverable" : "unknown";
|
|
103
|
+
}
|
|
87
104
|
return now === baselineHash ? "clean" : "modified";
|
|
88
105
|
}
|
|
89
106
|
|
|
107
|
+
/**
|
|
108
|
+
* What git knows about one path: "tracked-clean" | "tracked-dirty" |
|
|
109
|
+
* "untracked" | "unknown".
|
|
110
|
+
*
|
|
111
|
+
* "unknown" for a non-repo or a failed call, and that default is the
|
|
112
|
+
* conservative one — an unanswerable question must not read as a clean answer.
|
|
113
|
+
*/
|
|
114
|
+
export function gitVerdictFor(root, rel) {
|
|
115
|
+
try {
|
|
116
|
+
const tracked = execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], {
|
|
117
|
+
cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
118
|
+
}).trim();
|
|
119
|
+
if (!tracked) return "untracked";
|
|
120
|
+
} catch {
|
|
121
|
+
return "untracked";
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const dirty = execFileSync("git", ["status", "--porcelain", "--", rel], {
|
|
125
|
+
cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
126
|
+
}).trim();
|
|
127
|
+
return dirty ? "tracked-dirty" : "tracked-clean";
|
|
128
|
+
} catch {
|
|
129
|
+
return "unknown";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
90
133
|
/** True when a classification means "do not overwrite without an explicit flag". */
|
|
91
134
|
export const isProtected = (verdict) => verdict === "modified" || verdict === "unknown";
|
|
135
|
+
|
|
136
|
+
/** True when the file was refreshed only because git can restore it. */
|
|
137
|
+
export const isRecoverableRefresh = (verdict) => verdict === "stale-recoverable";
|