task-pipeline-skill 1.52.0 → 1.53.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 (36) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/CONTRIBUTING.md +3 -3
  3. package/HOW-IT-WORKS.md +1 -1
  4. package/README.md +15 -1
  5. package/SKILL-CARD.md +1 -1
  6. package/bin/lib/artifact-root.js +123 -0
  7. package/bin/lib/migrate-artifacts.js +221 -0
  8. package/bin/task-pipeline.js +58 -0
  9. package/cursor/rules/task-pipeline.mdc +5 -5
  10. package/package.json +4 -3
  11. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  12. package/plugins/task-pipeline/commands/task-pipeline.md +3 -3
  13. package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +2 -2
  14. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +17 -1
  15. package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +3 -3
  16. package/plugins/task-pipeline/skills/task-pipeline/references/adoption.md +1 -1
  17. package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +43 -14
  18. package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +1 -1
  19. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +1 -1
  20. package/plugins/task-pipeline/skills/task-pipeline/references/decomposition.md +1 -1
  21. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +2 -2
  22. package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +4 -4
  23. package/plugins/task-pipeline/skills/task-pipeline/references/learned.md +2 -2
  24. package/plugins/task-pipeline/skills/task-pipeline/references/planning.md +2 -2
  25. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -1
  26. package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +3 -3
  27. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +8 -8
  28. package/plugins/task-pipeline/skills/task-pipeline/references/setup.md +24 -1
  29. package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +1 -1
  30. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +11 -11
  31. package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +6 -6
  32. package/plugins/task-pipeline/skills/task-pipeline/templates/backlog.md +1 -1
  33. package/plugins/task-pipeline/skills/task-pipeline/templates/brief.md +4 -4
  34. package/plugins/task-pipeline/skills/task-pipeline/templates/carryover.md +2 -2
  35. package/plugins/task-pipeline/skills/task-pipeline/templates/retro-archive.md +1 -1
  36. package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,84 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.53.0 — the artifact root stops carrying another pack's name
4
+
5
+ The paperwork directory was called `docs/superpowers/`. The name came from an unrelated
6
+ pack — one whose own tests walk the same path — and `references/artifacts.md` had called
7
+ it "historical convention" since v0.1.0, promising that a host project *may relocate the
8
+ root*. Nothing kept that promise: the path was hardcoded in 24 places in the validator,
9
+ in the gate prose of `pipeline.json`, and in 26 CI plants.
10
+
11
+ ### Added
12
+
13
+ - **`paths.artifacts` in `pipeline.schema.json`.** Any relative path, and it outranks
14
+ both discovered names. This is what turns the v0.1.0 sentence into a mechanism.
15
+ - **The artifact-root rule, resolved rather than spelled.** `paths.artifacts` →
16
+ an existing `docs/evidence/` → an existing `docs/superpowers/` → `docs/evidence/` for
17
+ a project that has neither. A directory is adopted only when it **carries a register**
18
+ (`retro.md`, `backlog.md`, `verification.md`, or a `specs/plans/briefs/retro`
19
+ directory): a project may keep an unrelated `docs/evidence/`, and adopting it on a
20
+ name match would write a run's paperwork into somebody else's folder. The answer is a
21
+ record — `{root, reason, legacy, leftover, collision}` — because a bare string cannot
22
+ say *this is the legacy name*, *records also sit over there*, or *the default landed on
23
+ an occupied directory*.
24
+ - **Two implementations, one table, compared to each other.**
25
+ `bin/lib/artifact-root.js` ships; `test/artifact_root.py` serves the validator;
26
+ `test/artifact_root_test.py` builds all seven cases as real trees and fails when the
27
+ two disagree. Checking each against the table alone would let them drift into two
28
+ readings that are both "right".
29
+ - **`npx task-pipeline migrate-artifacts [--dry-run]`.** Optional, always: the legacy
30
+ name is supported and **no run warns about it**. It moves the directory, refuses when
31
+ `paths.artifacts` is set rather than overriding the operator, never overwrites a
32
+ collision, backs up before writing — and **lists every file elsewhere that names the
33
+ old path without editing one of them.** Rewriting arbitrary documents in somebody's
34
+ repository would mean deciding for them which mentions are a path in use and which are
35
+ a sentence about the old name; this release got that distinction wrong in its own
36
+ sweep before the command was written, which is the argument for not automating it.
37
+ - **`/task-pipeline setup` now opens with the resolved root and why**, with all four
38
+ outcomes spelled out — including the one where the default lands on a directory that
39
+ exists and carries no register, which is a stop-and-ask rather than a write.
40
+
41
+ ### Changed
42
+
43
+ - **The default is `docs/evidence/`**, matching the `evidence-docs` skill this plugin
44
+ already ships. **Nothing migrates on its own.** A project on `docs/superpowers/` keeps
45
+ it, forever, with no warning on any run — an upgrade is a no-op for everyone already
46
+ running.
47
+ - 24 sites in `test/validate.py` and 105 occurrences across 34 files now go through the
48
+ rule: doctrine writes `<artifacts>/`, templates and this repository's own statements
49
+ write the resolved name. **Frozen records were not touched** — a brief from March
50
+ describes where things were in March, and rewriting it would falsify the record.
51
+
52
+ ### Fixed
53
+
54
+ - **A guard that had lost its subject.** The prose sweep rewrote
55
+ `` `docs/superpowers/…` `` to `` `<artifacts>/…` `` — and the guard comparing
56
+ `artifacts.md`'s tables against its layout tree searched for the resolved literal, so
57
+ it found nothing and passed by having nothing to check. Reported by the negative
58
+ self-test as *does not actually fire*, not by the validator, which was green. That is
59
+ standing instruction #6's corollary landing in this repository's own validator one
60
+ release after the instruction was written; the guard is now anchored on the symbol the
61
+ doctrine writes, and it has been watched failing against a planted defect.
62
+ - **A plant that no longer landed.** The CI plant removing the layout tree anchored on a
63
+ bare `superpowers/` — a spelling the path sweep never matched — and said so, loudly,
64
+ because it asserts its own effect. Repointing the other 26 plants recovered 24 broken
65
+ negative self-tests.
66
+ - **A backup taken when nothing moved.** `migrate-artifacts` copied the legacy tree
67
+ before every run, so a second run against a still-colliding tree changed the tree it
68
+ claimed to leave alone. Found by the three-run fixture rather than by reading: the pure
69
+ planner was right and the command that repeats was not, which is standing instruction
70
+ #2 arriving in the release that cites it.
71
+
72
+ Guards: 312 → **313**. Property checks: 9 → 9. The new one reverses the precedence in
73
+ one of the two resolver implementations: the table still describes the correct order, so
74
+ a suite that merely ran both would stay green while they drifted apart on a case the
75
+ table never named. Two new suites join CI — the artifact-root rule (7 cases, both
76
+ implementations compared) and `migrate-artifacts` (7 cases, three real runs with hashes
77
+ compared). 26 CI plants were repointed at the new root and 24 broken negative self-tests
78
+ recovered; two remain unable to fire in a submodule checkout for a reason that predates
79
+ this release (the harness copies to `/tmp` without a resolving `.git`, and both guards
80
+ read git history — CI clones normally and is green).
81
+
3
82
  ## v1.52.0 — the three moments the run's own record could not show
4
83
 
5
84
  ### Added
package/CONTRIBUTING.md CHANGED
@@ -371,11 +371,11 @@ seven files where thirteen state the condition, the disclosure check named five
371
371
  rule and the command. Every miss was found by a reader or a sweep, never by the guard —
372
372
  **nobody notices a corpus that is too small, because everything inside it passes.**
373
373
  Exclusions are allowed and must each carry a reason in the code: a changelog narrates
374
- old formats, `docs/superpowers/specs/` are point-in-time records.
374
+ old formats, `docs/evidence/specs/` are point-in-time records.
375
375
  *(guard: `a worked GATE verdict omits ` and `states the cold-retirement condition as` — both now run over corpora walked from disk, so a new surface joins by existing)*
376
376
 
377
377
  **44. A carry-over row still `open` names a board id, and the board row names it back.**
378
- `docs/superpowers/backlog.md` is the project's queue between runs; the ledger's `open`
378
+ `docs/evidence/backlog.md` is the project's queue between runs; the ledger's `open`
379
379
  was a home that pointed nowhere, and rows across eight ledgers sat in it — sixteen by the first, positional count, twenty-four once the check stopped reading by column. Both
380
380
  directions are checked because they are different failures — an id nobody issued, and a
381
381
  row traceable to nothing. The test is **position-free**: a row is open if any of its cells
@@ -387,7 +387,7 @@ design and it was wrong for the same reason.
387
387
  *(guard: `with no board id` and `names no Source`)*
388
388
 
389
389
  **45. Every shipped REQ has a verification row, and `Human` is a date or `never`.**
390
- `docs/superpowers/verification.md` records the one thing no check can decide — whether a
390
+ `docs/evidence/verification.md` records the one thing no check can decide — whether a
391
391
  person looked after it shipped. Both directions: a shipped REQ with no row, and a row
392
392
  whose REQ is in no brief. It keys to the brief because eight of nine briefs carry
393
393
  machine-readable REQ rows while ten acceptance files carry their coverage table in nearly
package/HOW-IT-WORKS.md CHANGED
@@ -380,5 +380,5 @@ exclusion clause as their reason to refuse.
380
380
  | change it | [`CONTRIBUTING.md`](CONTRIBUTING.md) — the invariants live there |
381
381
  | why a release happened | [`CHANGELOG.md`](CHANGELOG.md) |
382
382
  | the stage doctrine itself | `plugins/task-pipeline/skills/task-pipeline/references/` |
383
- | what is open | [`docs/superpowers/backlog.md`](docs/superpowers/backlog.md) |
383
+ | what is open | [`docs/evidence/backlog.md`](docs/evidence/backlog.md) |
384
384
  | what is genuinely unresolved | [`docs/OPEN_QUESTIONS.md`](docs/OPEN_QUESTIONS.md) |
package/README.md CHANGED
@@ -584,7 +584,7 @@ class of failure gets caught, fixed and forgotten five times, and nothing in the
584
584
  pipeline notices it is the same one.
585
585
 
586
586
  The last act of stage 10 is therefore a **retrospective**, written to
587
- `docs/superpowers/retro.md` — **one file per project, not per run**
587
+ `docs/evidence/retro.md` — **one file per project, not per run**
588
588
  ([`retrospective.md`](plugins/task-pipeline/skills/task-pipeline/references/retrospective.md)).
589
589
  Every run **stamps and prunes** — in that order, because one retirement trigger
590
590
  counts firings across the last five run stamps **or sixty days**, whichever comes
@@ -784,6 +784,20 @@ npx task-pipeline-skill # from the npm registry
784
784
  on npm; installs the same skill + `/task-pipeline` command into `~/.claude`,
785
785
  idempotent, `--force` to overwrite)
786
786
 
787
+ **Moving the paperwork off the legacy directory name — optional, always:**
788
+ ```bash
789
+ npx task-pipeline-skill migrate-artifacts --dry-run # the plan, nothing written
790
+ npx task-pipeline-skill migrate-artifacts # do it
791
+ ```
792
+ The artifact root was `docs/superpowers/` until v1.53.0 and the default is now
793
+ `docs/evidence/`. **A project on the old name keeps it and no run warns about it** —
794
+ the root is resolved (`paths.artifacts` in `pipeline.json`, else an existing
795
+ `docs/evidence/`, else an existing `docs/superpowers/`, else the new default), so an
796
+ upgrade changes nothing for a project already running. The command moves the directory,
797
+ never overwrites a collision, refuses when `paths.artifacts` is set rather than
798
+ overriding you, and **lists every other file that names the old path without editing
799
+ one of them** — only you can tell a path in use from a sentence about the old name.
800
+
787
801
  **Cursor:** the skills CLI above with `--agent cursor`, or per project copy
788
802
  [`cursor/rules/task-pipeline.mdc`](cursor/rules/task-pipeline.mdc) into the repo's
789
803
  `.cursor/rules/`. Cursor has no global rules directory — use the skills CLI for a
package/SKILL-CARD.md CHANGED
@@ -12,7 +12,7 @@ harmless.
12
12
  |---|---|
13
13
  | **Purpose** | Runs a substantial task through ten gated delivery stages — intake grill, docs study, brainstorm, spec, plan, subagent build, tests, lint/deploy, post-deploy, docs+registers, acceptance — refusing to advance until each gate passes |
14
14
  | **Owner** | ssheleg ([github.com/ssheleg/task-pipeline](https://github.com/ssheleg/task-pipeline)) |
15
- | **Version** | 1.52.0 |
15
+ | **Version** | 1.53.0 |
16
16
  | **Surface** | Claude Code (filesystem skill + plugin) and the vercel `skills` CLI. **Not** uploaded to the Skills API; custom Skills do not sync across surfaces |
17
17
  | **Dependencies** | None required. Optional: `context7` (MCP), `figma` (MCP), super-ux, agent-sync, graphify, obsidian-wiki. Every stage's doctrine ships in-repo; the one conditional requirement is super-ux for the stage-3 UX track on a user-facing task |
18
18
  | **Evaluation status** | Suite authored, 5 categories. One recorded run, **self-observed by the author**; **zero blind runs on zero of three models** — the split, and the numbers, live in [`evals/RESULTS.md`](evals/RESULTS.md) and are computed by `evals/run.py` |
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * Where this project's run artifacts live — the shipped answer.
5
+ *
6
+ * The same ordered rule as `test/artifact_root.py`, which serves the validator.
7
+ * `test/artifact_root_test.py` runs both against
8
+ * `test/fixtures/artifact-root-cases.json` and fails when they disagree, which is
9
+ * what makes two implementations of one rule affordable at all.
10
+ *
11
+ * 1. `paths.artifacts` in `pipeline.json` wins outright. Any relative path.
12
+ * 2. otherwise the first KNOWN name that exists AND CARRIES A REGISTER.
13
+ * `docs/evidence/` before `docs/superpowers/`, so a partial migration
14
+ * resolves forward and moving one file at a time never splits a project.
15
+ * 3. otherwise `docs/evidence/`, the default for a project that has none.
16
+ *
17
+ * **Carrying a register is the whole difference between a root and a directory.**
18
+ * A project may keep an unrelated `docs/evidence/`; adopting it on bare existence
19
+ * would write a run's paperwork into somebody else's folder.
20
+ *
21
+ * **The answer is a record, not a string.** A bare path cannot say *this is the
22
+ * legacy name*, *records also sit over there*, or *the default landed on an
23
+ * occupied directory* — and a caller that cannot know those things writes blind.
24
+ *
25
+ * This file lives under `bin/` because that is what the package ships
26
+ * (`package.json` → `files`), and `bin/task-pipeline.js` is its only caller.
27
+ * Run directly it prints the record as JSON, which is how the Python test asks it.
28
+ */
29
+
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+
33
+ /** Ordered. The new name first, so a partial migration resolves forward. */
34
+ const KNOWN = ['docs/evidence', 'docs/superpowers'];
35
+
36
+ /** The name this pipeline used until 2026-08-13. Supported, not deprecated. */
37
+ const LEGACY = 'docs/superpowers';
38
+
39
+ /** What makes a directory a root rather than a directory. Any ONE of these. */
40
+ const REGISTERS = ['retro.md', 'backlog.md', 'verification.md',
41
+ 'specs', 'plans', 'briefs', 'retro'];
42
+
43
+ /** Does this directory hold any artifact this pipeline recognises? */
44
+ function carriesRegister(dir) {
45
+ let st;
46
+ try { st = fs.statSync(dir); } catch (e) { return false; }
47
+ if (!st.isDirectory()) return false;
48
+ return REGISTERS.some((name) => fs.existsSync(path.join(dir, name)));
49
+ }
50
+
51
+ /**
52
+ * `paths.artifacts` from `pipeline.json`, or `null`.
53
+ *
54
+ * An unreadable or malformed config yields `null` rather than throwing: the
55
+ * resolver's job is to answer, and a project with a broken config still has a
56
+ * directory layout. The config's own validity is the schema check's business.
57
+ */
58
+ function configured(project) {
59
+ let cfg;
60
+ try {
61
+ cfg = JSON.parse(fs.readFileSync(path.join(project, 'pipeline.json'), 'utf8'));
62
+ } catch (e) {
63
+ return null;
64
+ }
65
+ const value = cfg && cfg.paths && cfg.paths.artifacts;
66
+ if (typeof value === 'string' && value.trim()) {
67
+ return value.trim().replace(/\/+$/, '');
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * `{root, reason, legacy, leftover, collision}` for a project directory.
74
+ * `root` is relative to `project` and posix-separated.
75
+ */
76
+ function resolve(project) {
77
+ if (typeof project !== 'string' || !project) {
78
+ // A resolver handed nothing must not answer as though it were handed a
79
+ // project: standing instruction #1, in the one line where it is cheapest.
80
+ throw new TypeError('resolve() needs a project directory');
81
+ }
82
+
83
+ let root = configured(project);
84
+ let reason = root ? 'configured' : null;
85
+
86
+ if (!root) {
87
+ for (const name of KNOWN) {
88
+ if (carriesRegister(path.join(project, name))) {
89
+ root = name;
90
+ reason = name === LEGACY ? 'legacy' : 'found';
91
+ break;
92
+ }
93
+ }
94
+ }
95
+
96
+ if (!root) {
97
+ root = KNOWN[0];
98
+ reason = 'default';
99
+ }
100
+
101
+ // `leftover` answers "what else carries records here", a different question
102
+ // from "which root won" — so it is computed the same way whatever chose the root.
103
+ const leftover = KNOWN.find(
104
+ (n) => n !== root && carriesRegister(path.join(project, n))
105
+ ) || null;
106
+
107
+ // The default landing on a directory that exists but is not a root: answer,
108
+ // and say so, so the caller asks instead of writing into it.
109
+ let collision = false;
110
+ if (reason === 'default') {
111
+ const dir = path.join(project, root);
112
+ collision = fs.existsSync(dir) && !carriesRegister(dir);
113
+ }
114
+
115
+ return { root, reason, legacy: reason === 'legacy', leftover, collision };
116
+ }
117
+
118
+ module.exports = { resolve, carriesRegister, configured, KNOWN, LEGACY, REGISTERS };
119
+
120
+ if (require.main === module) {
121
+ const target = process.argv[2] || process.cwd();
122
+ process.stdout.write(JSON.stringify(resolve(target)) + '\n');
123
+ }
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+ /**
3
+ * `migrate-artifacts` — move a project's paperwork off the legacy root, on request.
4
+ *
5
+ * **Nothing calls this on its own.** The resolver supports `docs/superpowers/`
6
+ * indefinitely and no run warns about it, so a project that never runs this command is
7
+ * not behind. This exists because `task-pipeline` is published: without it, a project
8
+ * that WANTS the new name has to do the move by hand and guess what else to touch.
9
+ *
10
+ * What it does, and the line it does not cross:
11
+ *
12
+ * - **It moves the directory.** That part is mechanical and safe to automate.
13
+ * - **It does not rewrite your prose.** Files elsewhere in the project that name the
14
+ * old path are LISTED, never edited. A command that rewrote arbitrary documents in
15
+ * somebody's repository would be deciding, for them, which mentions were a path in
16
+ * use and which were a path being discussed — the exact distinction a mechanical
17
+ * replace cannot make, and one this pipeline got wrong in its own sweep before
18
+ * shipping this file.
19
+ * - **A partial state is a state, not a fault.** Where both roots exist it moves what
20
+ * does not collide and names what does. Nothing is ever overwritten.
21
+ * - **A configured root is refused, not overridden.** `paths.artifacts` is the
22
+ * operator saying where the paperwork goes; moving it would contradict the config
23
+ * the resolver is about to read.
24
+ *
25
+ * The backup is a mechanism, not a habit: a copy that cannot be taken CANCELS the
26
+ * move. Copies land under `.task-pipeline/backups/`, and a name already taken in the
27
+ * same second gets a suffix — two backups inside one second were one backup once, and
28
+ * the second overwrote the first.
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const { execFileSync } = require('child_process');
34
+
35
+ const { resolve, LEGACY, KNOWN } = require('./artifact-root.js');
36
+
37
+ const NEW = KNOWN[0];
38
+
39
+ /** Every file under `dir`, repo-relative, posix-separated. */
40
+ function walk(dir, base, out) {
41
+ out = out || [];
42
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
43
+ const full = path.join(dir, e.name);
44
+ if (e.isDirectory()) walk(full, base, out);
45
+ else out.push(path.relative(base, full).split(path.sep).join('/'));
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** Is this a git work tree? `git mv` keeps the history a rename would drop. */
51
+ function inGitWorkTree(project) {
52
+ try {
53
+ execFileSync('git', ['-C', project, 'rev-parse', '--is-inside-work-tree'],
54
+ { stdio: ['ignore', 'pipe', 'ignore'] });
55
+ return true;
56
+ } catch (e) {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Which files elsewhere in the project still name the old path.
63
+ *
64
+ * Reported, never rewritten. Skips the artifact roots themselves — a brief written in
65
+ * March describes where things were in March, and correcting it would falsify the
66
+ * record it exists to be.
67
+ */
68
+ function mentionsElsewhere(project) {
69
+ const hits = [];
70
+ const skipDirs = new Set(['.git', 'node_modules', 'graphify-out', 'dist', 'build']);
71
+ const exts = ['.md', '.mdc', '.json', '.yml', '.yaml', '.sh', '.py', '.js', '.ts'];
72
+ const walkAll = (dir) => {
73
+ let entries;
74
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
75
+ for (const e of entries) {
76
+ const full = path.join(dir, e.name);
77
+ const rel = path.relative(project, full).split(path.sep).join('/');
78
+ if (e.isDirectory()) {
79
+ if (skipDirs.has(e.name)) continue;
80
+ if (rel === LEGACY || rel === NEW) continue; // the records themselves
81
+ walkAll(full);
82
+ } else if (exts.includes(path.extname(e.name))) {
83
+ let text;
84
+ try { text = fs.readFileSync(full, 'utf8'); } catch (err) { continue; }
85
+ const n = text.split(`${LEGACY}/`).length - 1;
86
+ if (n) hits.push({ file: rel, count: n });
87
+ }
88
+ }
89
+ };
90
+ walkAll(project);
91
+ return hits.sort((a, b) => b.count - a.count);
92
+ }
93
+
94
+ /**
95
+ * What the move would do. Pure: reads the tree, writes nothing.
96
+ *
97
+ * `moves` are safe; `collisions` are files whose target already exists and which are
98
+ * therefore left where they are. `mentions` is the list this command refuses to edit.
99
+ */
100
+ function plan(project) {
101
+ const info = resolve(project);
102
+ const legacyAbs = path.join(project, LEGACY);
103
+
104
+ if (info.reason === 'configured') {
105
+ return { action: 'refused', info,
106
+ why: `pipeline.json sets paths.artifacts to "${info.root}". Moving the `
107
+ + 'directory would contradict the config the resolver reads first. '
108
+ + 'Change or remove that key first if the move is what you want.' };
109
+ }
110
+ if (!fs.existsSync(legacyAbs)) {
111
+ return { action: 'nothing', info,
112
+ why: `no ${LEGACY}/ in this project — the root is already "${info.root}".` };
113
+ }
114
+
115
+ const files = walk(legacyAbs, legacyAbs);
116
+ const moves = [];
117
+ const collisions = [];
118
+ for (const rel of files) {
119
+ const target = path.join(project, NEW, rel);
120
+ if (fs.existsSync(target)) collisions.push(`${NEW}/${rel}`);
121
+ else moves.push({ from: `${LEGACY}/${rel}`, to: `${NEW}/${rel}` });
122
+ }
123
+ return { action: 'move', info, moves, collisions,
124
+ mentions: mentionsElsewhere(project) };
125
+ }
126
+
127
+ /** A preview that shows what LEAVES, not only what arrives. */
128
+ function render(p) {
129
+ const L = [];
130
+ if (p.action === 'refused') { L.push(`refused: ${p.why}`); return L.join('\n'); }
131
+ if (p.action === 'nothing') { L.push(`nothing to do: ${p.why}`); return L.join('\n'); }
132
+
133
+ L.push(`artifact root: ${p.info.root} (${p.info.reason})`);
134
+ L.push('');
135
+ L.push(`${p.moves.length} file(s) move:`);
136
+ for (const m of p.moves) {
137
+ L.push(` - ${m.from}`); // what leaves
138
+ L.push(` + ${m.to}`); // and where it arrives
139
+ }
140
+ if (p.collisions.length) {
141
+ L.push('');
142
+ L.push(`${p.collisions.length} file(s) NOT moved — the target already exists and `
143
+ + 'nothing here overwrites:');
144
+ for (const c of p.collisions) L.push(` ! ${c}`);
145
+ L.push(` ${LEGACY}/ stays in place for these.`);
146
+ }
147
+ if (p.mentions.length) {
148
+ L.push('');
149
+ L.push(`${p.mentions.length} file(s) elsewhere still name ${LEGACY}/ — `
150
+ + 'LISTED, NOT EDITED:');
151
+ for (const m of p.mentions) L.push(` ? ${m.file} (${m.count})`);
152
+ L.push(' Some of those are paths in use and some are sentences about the old');
153
+ L.push(' name. Only you can tell them apart, so this command will not try.');
154
+ }
155
+ return L.join('\n');
156
+ }
157
+
158
+ /** A copy that cannot be taken cancels the move. Returns the backup directory. */
159
+ function backup(project, stamp) {
160
+ const dir = path.join(project, '.task-pipeline', 'backups');
161
+ fs.mkdirSync(dir, { recursive: true });
162
+ let target = path.join(dir, `artifacts-${stamp}`);
163
+ let n = 1;
164
+ // A stamp resolves to the second and an agent moves faster than that: a name
165
+ // already taken gets a suffix rather than the previous copy's contents.
166
+ while (fs.existsSync(target)) target = path.join(dir, `artifacts-${stamp}-${++n}`);
167
+ fs.cpSync(path.join(project, LEGACY), target, { recursive: true });
168
+ const copied = walk(target, target).length;
169
+ const wanted = walk(path.join(project, LEGACY), path.join(project, LEGACY)).length;
170
+ if (copied !== wanted) {
171
+ throw new Error(`backup incomplete (${copied}/${wanted} files) — move cancelled`);
172
+ }
173
+ return target;
174
+ }
175
+
176
+ /** Do it. Returns `{plan, backupDir, moved}`. */
177
+ function apply(project, opts) {
178
+ const o = opts || {};
179
+ const p = plan(project);
180
+ if (p.action !== 'move') return { plan: p, backupDir: null, moved: 0 };
181
+
182
+ // Nothing movable is nothing to do — and a backup taken anyway would make every
183
+ // repeat run change the tree it claims to leave alone. Found by the three-run
184
+ // fixture, not by reading: the plan was right and the command that repeats was not,
185
+ // which is standing instruction #2 arriving in the file that cites it.
186
+ if (!p.moves.length) return { plan: p, backupDir: null, moved: 0 };
187
+
188
+ const backupDir = backup(project, o.stamp || 'unstamped');
189
+ const git = inGitWorkTree(project);
190
+ let moved = 0;
191
+ for (const m of p.moves) {
192
+ const from = path.join(project, m.from);
193
+ const to = path.join(project, m.to);
194
+ fs.mkdirSync(path.dirname(to), { recursive: true });
195
+ if (git) {
196
+ try {
197
+ execFileSync('git', ['-C', project, 'mv', m.from, m.to], { stdio: 'ignore' });
198
+ } catch (e) {
199
+ fs.renameSync(from, to); // untracked files git mv refuses
200
+ }
201
+ } else {
202
+ fs.renameSync(from, to);
203
+ }
204
+ moved += 1;
205
+ }
206
+ // The legacy directory goes only when nothing is left in it — a collision means
207
+ // records still live there and removing it would delete them.
208
+ pruneEmpty(path.join(project, LEGACY));
209
+ return { plan: p, backupDir, moved };
210
+ }
211
+
212
+ /** Remove empty directories, deepest first. Never removes a directory with content. */
213
+ function pruneEmpty(dir) {
214
+ if (!fs.existsSync(dir)) return;
215
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
216
+ if (e.isDirectory()) pruneEmpty(path.join(dir, e.name));
217
+ }
218
+ if (!fs.readdirSync(dir).length) fs.rmdirSync(dir);
219
+ }
220
+
221
+ module.exports = { plan, render, apply, backup, mentionsElsewhere, NEW, LEGACY };
@@ -26,6 +26,14 @@ Usage:
26
26
  into ~/.claude (skip existing unless --force)
27
27
  npx task-pipeline-skill --help
28
28
 
29
+ npx task-pipeline-skill migrate-artifacts [--dry-run]
30
+ move this project's paperwork from the legacy
31
+ docs/superpowers/ to docs/evidence/. Optional:
32
+ the legacy name is supported forever and no run
33
+ warns about it. Moves the directory, LISTS every
34
+ other file that names the old path, and edits
35
+ none of them.
36
+
29
37
  Other install paths:
30
38
  Claude Code plugin: /plugin marketplace add ${REPO}
31
39
  /plugin install task-pipeline@task-pipeline
@@ -87,12 +95,62 @@ function offerRouters() {
87
95
  }
88
96
  }
89
97
 
98
+ /**
99
+ * `migrate-artifacts` — the one verb this installer grew.
100
+ *
101
+ * Kept here rather than in a second binary because the package ships one `bin`, and a
102
+ * project that wants the move should not have to learn a second command name. The work
103
+ * itself is in `lib/migrate-artifacts.js`, which is pure up to the moment it copies.
104
+ */
105
+ function migrateArtifacts(args) {
106
+ const mig = require('./lib/migrate-artifacts.js');
107
+ const project = process.cwd();
108
+ const dry = args.includes('--dry-run');
109
+ const unknown = args.filter((a) => a !== '--dry-run');
110
+ if (unknown.length) {
111
+ console.error(`unknown argument(s) for migrate-artifacts: ${unknown.join(' ')}`);
112
+ return 2;
113
+ }
114
+
115
+ const p = mig.plan(project);
116
+ console.log(mig.render(p));
117
+ if (p.action === 'refused') return 3;
118
+ if (p.action === 'nothing') return 0;
119
+ if (dry) {
120
+ console.log('\n--dry-run: nothing was written.');
121
+ return 0;
122
+ }
123
+
124
+ let r;
125
+ try {
126
+ r = mig.apply(project, { stamp: new Date().toISOString().replace(/[:.]/g, '-') });
127
+ } catch (e) {
128
+ // A copy that cannot be taken cancels the move, and says so instead of
129
+ // degrading to "moved anyway".
130
+ console.error(`\nmove cancelled: ${e.message}`);
131
+ return 1;
132
+ }
133
+ console.log(`\nbackup: ${r.backupDir}`);
134
+ console.log(`moved: ${r.moved} file(s)`);
135
+ if (r.plan.collisions.length) {
136
+ console.log(`kept: ${r.plan.collisions.length} file(s) left in `
137
+ + `${mig.LEGACY}/ — their targets already existed`);
138
+ }
139
+ if (r.plan.mentions.length) {
140
+ console.log(`review: ${r.plan.mentions.length} file(s) still name ${mig.LEGACY}/ `
141
+ + '— listed above, none edited');
142
+ }
143
+ return 0;
144
+ }
145
+
90
146
  function main(argv) {
91
147
  const args = argv.slice(2);
92
148
  if (args.includes('--help') || args.includes('-h')) {
93
149
  usage();
94
150
  return 0;
95
151
  }
152
+ if (args[0] === 'migrate-artifacts') return migrateArtifacts(args.slice(1));
153
+
96
154
  const force = args.includes('--force');
97
155
  const unknown = args.filter((a) => a !== '--force');
98
156
  if (unknown.length) {
@@ -96,7 +96,7 @@ ledger. A date is the graph's own reply about itself; the lag is the only part
96
96
  that says whether it describes the tree you are about to change. A graph goes stale
97
97
  like any other source, and it **points while the code decides** — never the
98
98
  tiebreaker.
99
- **Read `docs/superpowers/retro.md` in full if it exists** — its *standing
99
+ **Read `docs/evidence/retro.md` in full if it exists** — its *standing
100
100
  instructions* are what previous runs of this pipeline got wrong in this project, and
101
101
  they bind this run (they are capped at ten, so reading them all is cheap). Stamp
102
102
  each one the moment it fires: that date is the only evidence behind the
@@ -174,12 +174,12 @@ Seed a **carry-over ledger** next to the brief, append-only. Anything deferred,
174
174
  dropped, or left half-done goes in the moment it's said — including implementer
175
175
  concerns and non-blocking review findings. **Deferred out loud is forgotten.**
176
176
 
177
- Read **the verification ledger** — `docs/superpowers/verification.md` — and quote how
177
+ Read **the verification ledger** — `docs/evidence/verification.md` — and quote how
178
178
  many rows sit at `never`: that is the project's standing exposure, and stage 0 is where
179
179
  it is cheapest to look. `never` is a fact, not a failure; the count has no floor and may
180
180
  never be given a target.
181
181
 
182
- Read **the board** — `docs/superpowers/backlog.md`, the project's work-list *between*
182
+ Read **the board** — `docs/evidence/backlog.md`, the project's work-list *between*
183
183
  runs — and quote its open count in the brief; seed it from the template when absent. Its
184
184
  priority is **computed, never assigned**: `prio = sev × blast + age_bonus`, with the
185
185
  three inputs in the row so a ranking can be checked rather than trusted, and `age` moving
@@ -342,7 +342,7 @@ not authorize an outward, irreversible action — stage 7 stops and asks.
342
342
  `git -C <repo> status --porcelain`, `git -C <repo> log @{u}..HEAD --oneline`.
343
343
  The fix is two commands and the second is the one that gets forgotten:
344
344
  `git -C <submodule> push`, then `git add <submodule> && git commit`.
345
- **The last act is the retrospective** — `docs/superpowers/retro.md`, **one file
345
+ **The last act is the retrospective** — `docs/evidence/retro.md`, **one file
346
346
  per project, not per run**. Every gate above is good at *this* run and blind
347
347
  across runs: the same class of failure gets caught, fixed and forgotten five
348
348
  times and nothing notices it is the same one. So, in this order —
@@ -352,7 +352,7 @@ not authorize an outward, irreversible action — stage 7 stops and asks.
352
352
 
353
353
  **0. Resolve the ledger onto the board first.** Every carry-over row still
354
354
  `open`, `unresolved`, or homed `backlog` leaves with a `B-NNN` id on
355
- `docs/superpowers/backlog.md`, and the ledger row is updated to name it — both
355
+ `docs/evidence/backlog.md`, and the ledger row is updated to name it — both
356
356
  directions, because a pointer to an id nobody issued and a board row traceable to
357
357
  nothing are different failures. Then re-derive the board's priorities: `age` moved
358
358
  while the run was happening.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-pipeline-skill",
3
- "version": "1.52.0",
3
+ "version": "1.53.0",
4
4
  "description": "Full-cycle delivery pipeline for coding agents: a mandatory built-in intake grill, then 10 gated stages (docs, brainstorm+decompose, spec, plan, build, tests, lint/deploy, post-deploy, docs/wiki, acceptance). Every stage's doctrine ships inside the skill — no companion plugin required. This package is the installer CLI.",
5
5
  "bin": {
6
6
  "task-pipeline": "bin/task-pipeline.js"
@@ -9,8 +9,9 @@
9
9
  "test": "python3 test/validate.py",
10
10
  "test:negatives": "python3 test/negatives.py",
11
11
  "test:probe": "python3 test/probe.py --self-test",
12
- "test:all": "python3 test/validate.py && python3 test/negatives.py && npm run test:probe && npm run test:hooks",
13
- "test:hooks": "python3 test/release_gate_test.py"
12
+ "test:all": "python3 test/validate.py && python3 test/negatives.py && npm run test:probe && npm run test:hooks && npm run test:artifacts",
13
+ "test:hooks": "python3 test/release_gate_test.py",
14
+ "test:artifacts": "python3 test/artifact_root_test.py && python3 test/migrate_artifacts_test.py"
14
15
  },
15
16
  "files": [
16
17
  "bin",
@@ -2,7 +2,7 @@
2
2
  "name": "task-pipeline",
3
3
  "displayName": "Task Pipeline",
4
4
  "description": "Runs a substantial task through a mandatory built-in intake grill, then 10 gated stages (docs, brainstorm+decompose, spec, plan, subagent build, tests, lint/deploy, post-deploy, docs/wiki, acceptance). Every stage's doctrine is built into the skill — no companion plugin required — with typed auto/manual gates, a frozen requirement spine that closes with evidence, a work board and a verification ledger that outlive a run, an exposure line naming what shipped unconfirmed, a progress rail computed from the project's own config, a loop guard whose review ceiling measures rather than stops, and stage-3 tracks for what a product does, how it sounds and how it looks. Two modes need no task: `checkup` (what is unverified) and `setup` (audit existing docs). Retro insights can publish upstream as issues, opt-in and redacted.",
5
- "version": "1.52.0",
5
+ "version": "1.53.0",
6
6
  "author": {
7
7
  "name": "ssheleg",
8
8
  "url": "https://x.com/sshlg93"