avtc-pi-featyard 1.1.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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +194 -0
  3. package/agents/fy-design-reviewer.md +77 -0
  4. package/agents/fy-feature-verifier.md +24 -0
  5. package/agents/fy-general-reviewer.md +81 -0
  6. package/agents/fy-guidelines-reviewer.md +46 -0
  7. package/agents/fy-implementer.md +20 -0
  8. package/agents/fy-performance-reviewer.md +46 -0
  9. package/agents/fy-plan-reviewer.md +74 -0
  10. package/agents/fy-plan-verifier.md +44 -0
  11. package/agents/fy-quality-reviewer.md +50 -0
  12. package/agents/fy-requirements-reviewer.md +46 -0
  13. package/agents/fy-researcher.md +6 -0
  14. package/agents/fy-security-reviewer.md +46 -0
  15. package/agents/fy-task-verifier.md +42 -0
  16. package/agents/fy-testing-reviewer.md +60 -0
  17. package/assets/images/kanban-board.png +0 -0
  18. package/assets/images/settings-dropdown.png +0 -0
  19. package/assets/images/settings.png +0 -0
  20. package/assets/images/widget.png +0 -0
  21. package/docs/CONFIGURATION.md +266 -0
  22. package/index.ts +8 -0
  23. package/package.json +85 -0
  24. package/skills/fy-design/SKILL.md +108 -0
  25. package/skills/fy-design-review/SKILL.md +45 -0
  26. package/skills/fy-finish/SKILL.md +28 -0
  27. package/skills/fy-implement/SKILL.md +36 -0
  28. package/skills/fy-plan/SKILL.md +77 -0
  29. package/skills/fy-plan-review/SKILL.md +45 -0
  30. package/skills/fy-research/SKILL.md +38 -0
  31. package/skills/fy-review/SKILL.md +88 -0
  32. package/skills/fy-verify/SKILL.md +23 -0
  33. package/src/commands/auto-agent-commands.ts +748 -0
  34. package/src/commands/kanban-commands.ts +278 -0
  35. package/src/commands/workflow-commands.ts +531 -0
  36. package/src/compaction/compact-handler.ts +371 -0
  37. package/src/compaction/compact-message.ts +52 -0
  38. package/src/compaction/compact-trigger.ts +121 -0
  39. package/src/compaction/safe-editor-write.ts +18 -0
  40. package/src/events/agent/agent-end.ts +19 -0
  41. package/src/events/agent/agent-settled.ts +20 -0
  42. package/src/events/agent/agent-start.ts +18 -0
  43. package/src/events/agent/turn-end.ts +20 -0
  44. package/src/events/agent/turn-start.ts +19 -0
  45. package/src/events/event-deps.ts +33 -0
  46. package/src/events/index.ts +91 -0
  47. package/src/events/input/input.ts +143 -0
  48. package/src/events/session/context.ts +68 -0
  49. package/src/events/session/model-select.ts +20 -0
  50. package/src/events/session/session-compact.ts +20 -0
  51. package/src/events/session/session-shutdown.ts +26 -0
  52. package/src/events/session/session-start.ts +45 -0
  53. package/src/events/session/session-tree.ts +17 -0
  54. package/src/events/tool/tool-call.ts +58 -0
  55. package/src/events/tool/tool-result.ts +54 -0
  56. package/src/git/git-queries.ts +154 -0
  57. package/src/git/resolve-base-branch.ts +173 -0
  58. package/src/git/worktrees/worktree-helpers.ts +142 -0
  59. package/src/git/worktrees/worktree-interception.ts +66 -0
  60. package/src/git/worktrees/worktree-lifecycle.ts +380 -0
  61. package/src/guardrails/completion-gating.ts +63 -0
  62. package/src/guardrails/file-classifier.ts +461 -0
  63. package/src/guardrails/force-add-guard.ts +312 -0
  64. package/src/guardrails/guardrail-tracker.ts +73 -0
  65. package/src/guardrails/guardrail-violations.ts +47 -0
  66. package/src/guardrails/guardrails-engine.ts +663 -0
  67. package/src/guardrails/session-guardrails.ts +75 -0
  68. package/src/guardrails/shell-decompose.ts +285 -0
  69. package/src/guardrails/tdd-enforcement.ts +122 -0
  70. package/src/guardrails/test-output.ts +272 -0
  71. package/src/index.ts +598 -0
  72. package/src/integrations/parallel-work-guardrail-integration.ts +29 -0
  73. package/src/integrations/subagent-integration.ts +166 -0
  74. package/src/integrations/todo-integration.ts +58 -0
  75. package/src/kanban/auto-agent/auto-agent-cleanup.ts +37 -0
  76. package/src/kanban/auto-agent/auto-agent-events.ts +142 -0
  77. package/src/kanban/auto-agent/auto-agent-grace-period.ts +83 -0
  78. package/src/kanban/auto-agent/auto-agent-lifecycle.ts +497 -0
  79. package/src/kanban/auto-agent/auto-agent-notify.ts +43 -0
  80. package/src/kanban/auto-agent/auto-agent-pause.ts +44 -0
  81. package/src/kanban/auto-agent/auto-agent-state-machine.ts +629 -0
  82. package/src/kanban/data/kanban-database.ts +851 -0
  83. package/src/kanban/data/kanban-db-schema.ts +137 -0
  84. package/src/kanban/data/kanban-detect-project.ts +43 -0
  85. package/src/kanban/data/kanban-move-feature.ts +43 -0
  86. package/src/kanban/data/kanban-types.ts +64 -0
  87. package/src/kanban/ensure-feature.ts +226 -0
  88. package/src/kanban/kanban-board-ui/app.js +1125 -0
  89. package/src/kanban/kanban-board-ui/csv-parser.d.ts +5 -0
  90. package/src/kanban/kanban-board-ui/csv-parser.js +80 -0
  91. package/src/kanban/kanban-board-ui/index.html +127 -0
  92. package/src/kanban/kanban-board-ui/style.css +820 -0
  93. package/src/kanban/kanban-bridge.ts +216 -0
  94. package/src/kanban/kanban-context.ts +43 -0
  95. package/src/kanban/kanban-events.ts +34 -0
  96. package/src/kanban/kanban-generate-title.ts +259 -0
  97. package/src/kanban/kanban-generate-topic.ts +170 -0
  98. package/src/kanban/kanban-operations.ts +170 -0
  99. package/src/kanban/kanban-server.ts +931 -0
  100. package/src/kanban/kanban-turn-handlers.ts +55 -0
  101. package/src/kanban/model-capture.ts +34 -0
  102. package/src/log.ts +47 -0
  103. package/src/phases/env-sync.ts +64 -0
  104. package/src/phases/execution-mode.ts +79 -0
  105. package/src/phases/implement-to-verify.ts +31 -0
  106. package/src/phases/phase-progression.ts +249 -0
  107. package/src/phases/phase-transitions.ts +226 -0
  108. package/src/phases/workflow-router.ts +98 -0
  109. package/src/prompts/finish-instructions.ts +143 -0
  110. package/src/prompts/skill-block-builder.ts +73 -0
  111. package/src/prompts/skill-expansion.ts +262 -0
  112. package/src/prompts/task-gate-skill.ts +83 -0
  113. package/src/prompts/template-engine.ts +433 -0
  114. package/src/prompts/text-blocks.ts +279 -0
  115. package/src/review/review-context.ts +56 -0
  116. package/src/review/review-counter.ts +66 -0
  117. package/src/review/review-empty-loop-tracking.ts +52 -0
  118. package/src/review/review-loops.ts +217 -0
  119. package/src/review/review-report.ts +101 -0
  120. package/src/settings/model-overrides.ts +269 -0
  121. package/src/settings/settings-schema.ts +458 -0
  122. package/src/settings/settings-types.ts +81 -0
  123. package/src/settings/settings-ui.ts +222 -0
  124. package/src/shared/errors.ts +24 -0
  125. package/src/shared/global.d.ts +22 -0
  126. package/src/shared/session-entries.ts +24 -0
  127. package/src/shared/types.ts +195 -0
  128. package/src/shared/workflow-refs.ts +253 -0
  129. package/src/shared/workflow-types.ts +180 -0
  130. package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
  131. package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
  132. package/src/snippets/vendored/subscribe-to-parallel-work-guardrail.ts +35 -0
  133. package/src/snippets/vendored/subscribe-to-subagent.ts +67 -0
  134. package/src/snippets/vendored/subscribe-to-todo.ts +63 -0
  135. package/src/state/archive-artifacts.ts +602 -0
  136. package/src/state/artifact-junction.ts +462 -0
  137. package/src/state/artifact-paths.ts +230 -0
  138. package/src/state/feature-activation.ts +239 -0
  139. package/src/state/feature-management.ts +325 -0
  140. package/src/state/feature-record-store.ts +39 -0
  141. package/src/state/feature-session.ts +263 -0
  142. package/src/state/feature-state.ts +439 -0
  143. package/src/state/post-turn-dispatch.ts +106 -0
  144. package/src/state/session-lifecycle.ts +346 -0
  145. package/src/state/state-persistence.ts +122 -0
  146. package/src/state/worth-notes.ts +89 -0
  147. package/src/tools/add-to-backlog.ts +117 -0
  148. package/src/tools/phase-ready.ts +742 -0
  149. package/src/tools/task-ready-advance.ts +235 -0
  150. package/src/tools/text-result.ts +15 -0
  151. package/src/ui/featyard-widget.ts +250 -0
  152. package/src/ui/manage-features-dialog.ts +246 -0
  153. package/src/ui/select-dialog.ts +27 -0
@@ -0,0 +1,602 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Archive engine for stale feature artifacts + state.
6
+ *
7
+ * Design (docs/featyard/designs/2026-06-29-done-feature-artifact-lifecycle-design.md):
8
+ * featyard writes process artifacts (task-plans, research, reviews, feature-state) OUT of the
9
+ * git repo under a `.featyard` junction → `~/.pi/featyard/artifacts/<key>/`. Over time, completed and
10
+ * abandoned features leave stale artifact dirs behind. This module relocates them to a sibling
11
+ * `artifacts-archive/<key>/` tree, preserving them (move-not-delete) so the operation is reversible.
12
+ *
13
+ * Two drivers share this engine:
14
+ * - `archiveStaleArtifacts` — the background sweep (runs once on activation then every 24h,
15
+ * subagent-skipped, defers to the next microtask). Threshold comes from the caller (the activation/timer read
16
+ * the setting) — this module does NOT import the settings layer (dependency inversion), keeping
17
+ * it a pure, unit-testable leaf.
18
+ * - `archiveArtifactsOlderThan` — the manual `/fy:archive-artifacts <days>` command (on demand,
19
+ * not subagent-skipped, supports `excludeSlug` to protect the active in-session feature).
20
+ *
21
+ * Routing (where a candidate lands in the archive — MIRRORS the live tree so the archive is a
22
+ * tree-copy of `.featyard/`):
23
+ * - a feature's artifacts archive as ONE logical unit (all-or-nothing, by the newest mtime across
24
+ * the whole group — so a slug's old reviews/ and fresh research/ never split across `.featyard/` and
25
+ * the archive), but each member lands at its tree-mirror path: `archiveBase/<area>/<slug>/` (dirs)
26
+ * or `archiveBase/<area>/<file>` (flat files). So `reviews/<slug>/` → `archiveBase/reviews/<slug>/`,
27
+ * `task-plans/<slug>-task-plan.md` → `archiveBase/task-plans/<slug>-task-plan.md`, etc.
28
+ * - bare `<YYYY-MM-DD>` date-fallback dir → `archiveBase/<area>/<date>/` (slug-less; each area is an
29
+ * INDEPENDENT group, since fallbacks have no slug to bind them).
30
+ * - bare files under an area (orphan research outputs) → `archiveBase/<area>/<file>`, keeping their
31
+ * name+extension (the slug key drives only the all-or-nothing decision, not the destination).
32
+ *
33
+ * mtime (not the folder-name date) is the age signal — immune to the wrong-year-in-name typos local
34
+ * models sometimes write. The folder name only decides date-fallback-vs-slug routing.
35
+ *
36
+ * Failure mode: best-effort log+continue — per-item errors are collected in `errors[]`; a failure
37
+ * never aborts the remaining items and never touches the `.featyard` junction (all paths are absolute and
38
+ * derived from `externalDir`/`archiveBase`, which are siblings under the external store, not the
39
+ * junction itself). Move primitive is idempotent + atomic (renameSync) with a copy+remove fallback
40
+ * for cross-volume (EXDEV) and dest-already-exists (merge) cases.
41
+ */
42
+ import { cpSync, type Dirent, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
43
+ import * as path from "node:path";
44
+
45
+ import { isSubagentSession } from "./state-persistence.js";
46
+
47
+ export const MS_PER_DAY = 86_400_000;
48
+
49
+ /**
50
+ * True iff `target` resolves to `base` itself or strictly inside it.
51
+ *
52
+ * Path-safety containment guard: used to reject archive destinations built from readdirSync
53
+ * basenames that could escape `archiveBase` via traversal (`..`, absolute segments, or OS-specific
54
+ * separators in a filename). Uses `path.resolve` so `..` components are normalized away before the
55
+ * prefix check. Single-user/local trust model makes this defense-in-depth, but a planted or
56
+ * cross-OS-authored filename must never yield a write outside the archive tree.
57
+ */
58
+ export function isPathInside(base: string, target: string): boolean {
59
+ const rb = path.resolve(base);
60
+ const rt = path.resolve(target);
61
+ return rt === rb || rt.startsWith(rb.endsWith(path.sep) ? rb : rb + path.sep);
62
+ }
63
+
64
+ /** A single source → destination move within an archive group. */
65
+ export interface ArchiveMember {
66
+ /** Absolute source path (a directory or a file). */
67
+ src: string;
68
+ /** Absolute destination path (the source is moved here). */
69
+ dest: string;
70
+ }
71
+
72
+ /** A logical unit of archiving: one slug (all areas + state) or one date-fallback dir. */
73
+ export interface ArchiveGroup {
74
+ /** Slug name, or `_date-fallback/<date>/<area>` for date-fallback groups. */
75
+ key: string;
76
+ /** True for bare-`<date>` date-fallback dirs; false for slug groups. */
77
+ isDateFallback: boolean;
78
+ /** The src→dest moves that make up this group. */
79
+ members: ArchiveMember[];
80
+ }
81
+
82
+ export interface EnumerateArchiveSetOptions {
83
+ /** Live artifacts root (holds task-plans/, research/, reviews/, feature-state). */
84
+ externalDir: string;
85
+ /** Archive root (siblings: live `<key>/` + archive `artifacts-archive/<key>/`). */
86
+ archiveBase: string;
87
+ /** Archive candidates whose newest mtime is older than this many days. */
88
+ maxAgeDays: number;
89
+ /** When set (non-null), the whole slug group matching this slug is excluded (active-feature protection). */
90
+ excludeSlug?: string | null;
91
+ /** Injection point for the current time (ms since epoch); defaults to Date.now. */
92
+ now?: number;
93
+ }
94
+
95
+ export interface EnumerateArchiveSetResult {
96
+ /** Groups old enough to archive (and not excluded). */
97
+ stale: ArchiveGroup[];
98
+ /** Absolute source paths that were excluded via `excludeSlug`. */
99
+ skipped: string[];
100
+ }
101
+
102
+ export type MoveResult = { ok: true } | { ok: false; error: string };
103
+
104
+ /**
105
+ * The fs primitives moveArtifact depends on. Injected (required, not optional) so tests can
106
+ * substitute fakes — vitest cannot intercept `node:fs` builtin named imports under jiti, so the
107
+ * EXDEV cross-volume + cp/rm-failure branches could not be exercised any other way. Production
108
+ * callers pass {@link REAL_FS_MOVE_OPS} (the real `node:fs` bindings).
109
+ */
110
+ export interface FsMoveOps {
111
+ existsSync: (path: string) => boolean;
112
+ mkdirSync: (path: string, opts: { recursive: boolean }) => void;
113
+ renameSync: (src: string, dest: string) => void;
114
+ cpSync: (src: string, dest: string, opts: { recursive: boolean; force: boolean }) => void;
115
+ rmSync: (path: string, opts: { recursive: boolean; force: boolean }) => void;
116
+ }
117
+
118
+ /** The real `node:fs` bindings for {@link FsMoveOps} — passed by production callers of moveArtifact. */
119
+ export const REAL_FS_MOVE_OPS: FsMoveOps = {
120
+ existsSync,
121
+ mkdirSync,
122
+ renameSync,
123
+ cpSync,
124
+ rmSync,
125
+ };
126
+
127
+ /** A bare ISO date (`YYYY-MM-DD`) — identifies date-fallback dirs (no slug). */
128
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
129
+
130
+ /**
131
+ * Move a file or directory from `src` to `dest`, never throwing.
132
+ *
133
+ * Ensures the destination parent exists first (`mkdirSync({recursive:true})`) — without it the
134
+ * first archive's `renameSync` throws `ENOENT` (the destination tree does not pre-exist), which the
135
+ * EXDEV-only fallback would not catch. Then attempts an atomic same-volume `renameSync`; on ANY
136
+ * failure falls back to a recursive `cpSync({force})` + `rmSync` (handles cross-volume `EXDEV` and
137
+ * dest-already-exists merge). Returns `{ok:false, error}` for a missing source or an unrecoverable
138
+ * failure (collected by callers, never thrown).
139
+ */
140
+ export function moveArtifact(src: string, dest: string, fs: FsMoveOps): MoveResult {
141
+ if (!fs.existsSync(src)) {
142
+ return { ok: false, error: `archive move: source missing: ${src}` };
143
+ }
144
+ try {
145
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
146
+ } catch (e) {
147
+ return { ok: false, error: `archive move: dest parent mkdir failed for ${dest}: ${(e as Error).message}` };
148
+ }
149
+ try {
150
+ fs.renameSync(src, dest);
151
+ return { ok: true };
152
+ } catch (renameErr) {
153
+ // Cross-volume (EXDEV) or dest-already-exists — fall back to copy (merge) + remove.
154
+ // The two steps are separated so a failure names the actual failing step (a copy success +
155
+ // remove failure leaves the source for the next sweep to re-merge, idempotently).
156
+ try {
157
+ fs.cpSync(src, dest, { recursive: true, force: true });
158
+ } catch (cpErr) {
159
+ return {
160
+ ok: false,
161
+ error: `archive move: ${src} → ${dest} failed (rename: ${(renameErr as Error).message}; copy: ${(cpErr as Error).message})`,
162
+ };
163
+ }
164
+ try {
165
+ fs.rmSync(src, { recursive: true, force: true });
166
+ } catch (rmErr) {
167
+ return {
168
+ ok: false,
169
+ error: `archive move: ${src} → ${dest} copied but source cleanup failed (rename: ${(renameErr as Error).message}; remove: ${(rmErr as Error).message})`,
170
+ };
171
+ }
172
+ return { ok: true };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Newest mtime (ms since epoch) anywhere in the tree rooted at `absPath`.
178
+ *
179
+ * A file → its own mtime. A non-empty directory → the recursive max of its contents' mtimes
180
+ * (the directory's own mtime is NOT used — content is the age signal). An empty or unreadable
181
+ * directory, or a nonexistent path → 0 (treated as ancient → stale, since empty dirs are cruft).
182
+ */
183
+ export function newestMtimeInTree(absPath: string): number {
184
+ let stats: ReturnType<typeof lstatSync>;
185
+ try {
186
+ // lstatSync (not statSync) so symlinks are treated as LEAVES — never dereferenced. A symlink
187
+ // loop in the tree would otherwise cause unbounded recursion (defense-in-depth).
188
+ stats = lstatSync(absPath);
189
+ } catch {
190
+ return 0;
191
+ }
192
+ if (!stats.isDirectory()) {
193
+ return stats.mtimeMs;
194
+ }
195
+ const entries = tryReadDir(absPath);
196
+ if (entries === null || entries.length === 0) {
197
+ return 0;
198
+ }
199
+ let max = 0;
200
+ for (const entry of entries) {
201
+ max = Math.max(max, newestMtimeInTree(path.join(absPath, entry.name)));
202
+ }
203
+ return max;
204
+ }
205
+
206
+ /**
207
+ * Derive the slug key for a task-plan file name. The convention is `<slug>-task-plan.md`; a file
208
+ * that does not match falls back to its basename without the `.md` extension (so old-shape flat
209
+ * fallback files still route by a slug-like key and archive as a lone group).
210
+ */
211
+ function taskPlanSlug(name: string): string {
212
+ if (/-task-plan\.md$/i.test(name)) {
213
+ return name.replace(/-task-plan\.md$/i, "");
214
+ }
215
+ return name.replace(/\.md$/i, "");
216
+ }
217
+
218
+ /** Read a directory's entries (with types), returning null if it is absent or unreadable. */
219
+ function tryReadDir(dir: string): Dirent[] | null {
220
+ try {
221
+ return readdirSync(dir, { withFileTypes: true });
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+
227
+ /** The areas (subdirs of `externalDir`) that hold per-slug artifact dirs. */
228
+ const DIR_AREAS = ["reviews", "research"] as const;
229
+ /** The flat-file area: task-plans holds a mix of `<slug>-*.md` files and bare-`<date>/` dirs. */
230
+ const TASK_PLANS = "task-plans";
231
+ /** The state area: `feature-state/` holds one `<slug>.json` file per feature. */
232
+ const FEATURE_STATE = "feature-state";
233
+
234
+ interface Candidate {
235
+ key: string;
236
+ isDateFallback: boolean;
237
+ member: ArchiveMember;
238
+ }
239
+
240
+ /**
241
+ * Scan `externalDir`'s artifact areas and group candidates by their archive key.
242
+ *
243
+ * - `reviews/` + `research/` hold per-slug DIRECTORIES, bare-`<date>/` date-fallback dirs, and
244
+ * occasional bare files (orphan research outputs not tied to a feature).
245
+ * - `task-plans/` holds a MIX: flat `<slug>-task-plan.md` files (slug-routed) and bare-`<date>/`
246
+ * directories (date-fallback, the post-normalization shape); also legacy `<slug>/` or
247
+ * `<date>-<topic>/` directories (slug-routed).
248
+ * - `feature-state/` holds one `<slug>.json` file per feature (slug-routed).
249
+ *
250
+ * Slug groups merge a slug's members across ALL areas (all-or-nothing — a feature archives as one
251
+ * unit, gated on the newest mtime across all its files). Date-fallback dirs are slug-less → each
252
+ * `<area>/<date>` dir is its own independent group keyed `_date-fallback/<date>/<area>`.
253
+ *
254
+ * The archive MIRRORS the live tree: every member routes to `archiveBase/<area>/<name>` (dirs) or
255
+ * `archiveBase/<area>/<file>` (flat files), so the archive is a tree-copy of the live layout and a
256
+ * bare file keeps its name+extension. The slug key drives only the all-or-nothing DECISION, not the
257
+ * physical destination (a feature's files land across the area folders, as in the live store).
258
+ */
259
+ function collectCandidates(externalDir: string, archiveBase: string): Candidate[] {
260
+ const candidates: Candidate[] = [];
261
+
262
+ // 1. reviews/ + research/ — per-slug dirs + bare-<date> date-fallback dirs (+ occasional bare
263
+ // files). The archive MIRRORS the live tree ({area}/{name}) so restoring is a tree copy and
264
+ // bare files keep their name+extension (an earlier {name}/{area} inversion turned a bare file
265
+ // like `foo.md` into a `foo.md/research` folder, losing the name). The slug key still groups a
266
+ // feature's areas for all-or-nothing archival; only the destination layout mirrors the live tree.
267
+ for (const area of DIR_AREAS) {
268
+ const areaDir = path.join(externalDir, area);
269
+ const entries = tryReadDir(areaDir);
270
+ if (entries === null) continue; // area absent — no candidates here
271
+ for (const entry of entries) {
272
+ const name = entry.name;
273
+ if (DATE_RE.test(name)) {
274
+ candidates.push({
275
+ key: `_date-fallback/${name}/${area}`,
276
+ isDateFallback: true,
277
+ member: { src: path.join(areaDir, name), dest: path.join(archiveBase, area, name) },
278
+ });
279
+ } else {
280
+ candidates.push({
281
+ key: name,
282
+ isDateFallback: false,
283
+ member: { src: path.join(areaDir, name), dest: path.join(archiveBase, area, name) },
284
+ });
285
+ }
286
+ }
287
+ }
288
+
289
+ // 2. task-plans/ — mixed flat files + bare-<date> dirs (+ slug/topic dirs). Archive mirrors the
290
+ // live tree: dirs → task-plans/{name}, flat files → task-plans/{name}. The slug key still groups
291
+ // a feature's areas for all-or-nothing archival.
292
+ const tpDir = path.join(externalDir, TASK_PLANS);
293
+ const tpEntries = tryReadDir(tpDir);
294
+ if (tpEntries) {
295
+ for (const entry of tpEntries) {
296
+ const name = entry.name;
297
+ const src = path.join(tpDir, name);
298
+ if (entry.isDirectory() && DATE_RE.test(name)) {
299
+ // Bare-<date> dir (post-normalization shape) → date-fallback.
300
+ candidates.push({
301
+ key: `_date-fallback/${name}/${TASK_PLANS}`,
302
+ isDateFallback: true,
303
+ member: { src, dest: path.join(archiveBase, TASK_PLANS, name) },
304
+ });
305
+ } else if (entry.isDirectory()) {
306
+ // A slug/topic dir → slug-routed (whole dir moves to task-plans/{name}).
307
+ candidates.push({
308
+ key: name,
309
+ isDateFallback: false,
310
+ member: { src, dest: path.join(archiveBase, TASK_PLANS, name) },
311
+ });
312
+ } else {
313
+ // A flat `<slug>-*.md` file → slug-routed (keyed by the derived slug), archived flat at
314
+ // task-plans/{name} (mirrors the live flat layout).
315
+ const slug = taskPlanSlug(name);
316
+ candidates.push({
317
+ key: slug,
318
+ isDateFallback: false,
319
+ member: { src, dest: path.join(archiveBase, TASK_PLANS, name) },
320
+ });
321
+ }
322
+ }
323
+ }
324
+
325
+ // 3. feature-state/ — one <slug>.json file per feature (slug-routed). Archive mirrors the live
326
+ // flat layout: feature-state/{name}.
327
+ const fsDir = path.join(externalDir, FEATURE_STATE);
328
+ const fsEntries = tryReadDir(fsDir);
329
+ if (fsEntries) {
330
+ for (const entry of fsEntries) {
331
+ if (!entry.isFile()) continue;
332
+ const name = entry.name;
333
+ const slug = name.replace(/\.json$/i, "");
334
+ candidates.push({
335
+ key: slug,
336
+ isDateFallback: false,
337
+ member: {
338
+ src: path.join(fsDir, name),
339
+ dest: path.join(archiveBase, FEATURE_STATE, name),
340
+ },
341
+ });
342
+ }
343
+ }
344
+
345
+ // Path-safety containment: drop any candidate whose resolved dest escapes `archiveBase`
346
+ // (a planted or cross-OS-authored filename must never yield a write outside the archive tree).
347
+ // Defense-in-depth under the single-user trust model; silently skipped (best-effort).
348
+ return candidates.filter((c) => isPathInside(archiveBase, c.member.dest));
349
+ }
350
+
351
+ /**
352
+ * Enumerate the archive set: group candidates by key, drop excluded slugs, and keep only groups
353
+ * whose newest mtime across ALL members is older than `maxAgeDays` from `now`.
354
+ */
355
+ export function enumerateArchiveSet(opts: EnumerateArchiveSetOptions): EnumerateArchiveSetResult {
356
+ const now = opts.now ?? Date.now();
357
+ const cutoff = now - opts.maxAgeDays * MS_PER_DAY;
358
+
359
+ const candidates = collectCandidates(opts.externalDir, opts.archiveBase);
360
+
361
+ // Group members by key (merges a slug's areas + state; date-fallback keys are already unique).
362
+ const byKey = new Map<string, { isDateFallback: boolean; members: ArchiveMember[] }>();
363
+ for (const c of candidates) {
364
+ let group = byKey.get(c.key);
365
+ if (!group) {
366
+ group = { isDateFallback: c.isDateFallback, members: [] };
367
+ byKey.set(c.key, group);
368
+ }
369
+ group.members.push(c.member);
370
+ }
371
+
372
+ const stale: ArchiveGroup[] = [];
373
+ const skipped: string[] = [];
374
+
375
+ for (const [key, group] of byKey) {
376
+ // Exclude the active feature's whole slug group (date-fallback groups never match a slug).
377
+ if (!group.isDateFallback && opts.excludeSlug != null && key === opts.excludeSlug) {
378
+ for (const m of group.members) skipped.push(m.src);
379
+ continue;
380
+ }
381
+ // Age = newest mtime across the whole group (one-unit invariant).
382
+ let newest = 0;
383
+ for (const m of group.members) {
384
+ newest = Math.max(newest, newestMtimeInTree(m.src));
385
+ }
386
+ if (newest < cutoff) {
387
+ stale.push({ key, isDateFallback: group.isDateFallback, members: group.members });
388
+ }
389
+ }
390
+
391
+ return { stale, skipped };
392
+ }
393
+
394
+ /**
395
+ * Move every member of every stale group via the shared move primitive. Collects per-item errors
396
+ * (best-effort) rather than aborting — shared by the background sweep and the manual command so
397
+ * behavior is identical (single shared helper, no logic fork).
398
+ */
399
+ function moveStaleMembers(stale: ArchiveGroup[]): {
400
+ archived: string[];
401
+ errors: string[];
402
+ archivedSlugGroups: number;
403
+ archivedDateFallbackGroups: number;
404
+ } {
405
+ const archived: string[] = [];
406
+ const errors: string[] = [];
407
+ let archivedSlugGroups = 0;
408
+ let archivedDateFallbackGroups = 0;
409
+ for (const group of stale) {
410
+ let allMembersMoved = true;
411
+ for (const member of group.members) {
412
+ const result = moveArtifact(member.src, member.dest, REAL_FS_MOVE_OPS);
413
+ if (result.ok) {
414
+ archived.push(member.dest);
415
+ } else {
416
+ errors.push(result.error);
417
+ allMembersMoved = false;
418
+ }
419
+ }
420
+ // A group counts as archived only when every member moved (the all-or-nothing unit).
421
+ if (allMembersMoved) {
422
+ if (group.isDateFallback) archivedDateFallbackGroups++;
423
+ else archivedSlugGroups++;
424
+ }
425
+ }
426
+ return { archived, errors, archivedSlugGroups, archivedDateFallbackGroups };
427
+ }
428
+
429
+ /**
430
+ * Background sweep entry point — archives every stale group older than `maxAgeDays`.
431
+ *
432
+ * Skips ALL work in subagent sessions (`isSubagentSession`) and yields once before doing any fs
433
+ * work so the caller (activation) proceeds synchronously — the sweep runs in the microtask queue,
434
+ * not blocking pi start. The caller passes the threshold (derived from the setting) — this module
435
+ * does NOT import the settings layer (dependency inversion). Failures are best-effort: per-item
436
+ * errors land in `errors[]`, the promise never rejects.
437
+ */
438
+ export async function archiveStaleArtifacts(opts: {
439
+ externalDir: string;
440
+ archiveBase: string;
441
+ maxAgeDays: number;
442
+ now?: number;
443
+ }): Promise<{ archived: string[]; errors: string[]; archivedSlugGroups: number; archivedDateFallbackGroups: number }> {
444
+ if (isSubagentSession()) {
445
+ return { archived: [], errors: [], archivedSlugGroups: 0, archivedDateFallbackGroups: 0 };
446
+ }
447
+ // Yield so the activation caller (`archiveStaleArtifacts` awaited inside an async sweep
448
+ // callback) returns before the fs sweep begins. NOTE: this defers only to the next microtask — the synchronous fs body (the
449
+ // recursive newestMtimeInTree walk + moves) still runs on the event loop. Acceptable at current
450
+ // scale (sub-100ms at <500 files); for future scale, chunk the walk/moves across setImmediate ticks.
451
+ await Promise.resolve();
452
+
453
+ const { stale } = enumerateArchiveSet({
454
+ externalDir: opts.externalDir,
455
+ archiveBase: opts.archiveBase,
456
+ maxAgeDays: opts.maxAgeDays,
457
+ now: opts.now,
458
+ });
459
+ return moveStaleMembers(stale);
460
+ }
461
+
462
+ /**
463
+ * Manual-command entry point — archives every group older than `days`, with an `excludeSlug` to
464
+ * protect the active in-session feature. Does NOT skip subagent sessions (the command runs on
465
+ * demand, regardless of session type). Shares `enumerateArchiveSet` + `moveArtifact` with the
466
+ * background sweep so behavior is identical (single shared helper, no logic fork).
467
+ */
468
+ export async function archiveArtifactsOlderThan(opts: {
469
+ externalDir: string;
470
+ archiveBase: string;
471
+ days: number;
472
+ excludeSlug?: string | null;
473
+ now?: number;
474
+ }): Promise<{
475
+ archived: string[];
476
+ skipped: string[];
477
+ errors: string[];
478
+ archivedSlugGroups: number;
479
+ archivedDateFallbackGroups: number;
480
+ }> {
481
+ const { stale, skipped } = enumerateArchiveSet({
482
+ externalDir: opts.externalDir,
483
+ archiveBase: opts.archiveBase,
484
+ maxAgeDays: opts.days,
485
+ excludeSlug: opts.excludeSlug,
486
+ now: opts.now,
487
+ });
488
+ return { ...moveStaleMembers(stale), skipped };
489
+ }
490
+
491
+ // ── Design docs (two-source sweep) ───────────────────────────────────────
492
+ // Design docs are flat `*-design.md` files that may live in EITHER recognized dir — the
493
+ // in-repo `docs/featyard/designs/` (committed) or the out-of-repo `.featyard/designs/` (local, via the
494
+ // junction). Unlike the slug-grouped artifact set above, each design doc archives independently
495
+ // (no cross-area grouping). Reuses `moveArtifact` so the move is the same atomic/EXDEV-safe
496
+ // primitive as the rest of the engine.
497
+
498
+ /** A design doc pending archive: its source + its archive destination. */
499
+ export interface DesignArchiveMember {
500
+ src: string;
501
+ dest: string;
502
+ /** The feature slug derived from the filename (`<slug>-design.md`). */
503
+ slug: string;
504
+ }
505
+
506
+ /**
507
+ * Derive a feature slug from a design-doc filename (`<slug>-design.md`). Returns the basename
508
+ * without the `-design.md` suffix; a name that does not match falls back to the basename minus the
509
+ * `.md` extension (so an oddly-named file still routes under a slug-like key).
510
+ */
511
+ function designDocSlug(name: string): string {
512
+ if (/-design\.md$/i.test(name)) {
513
+ return name.replace(/-design\.md$/i, "");
514
+ }
515
+ return name.replace(/\.md$/i, "");
516
+ }
517
+
518
+ /**
519
+ * Enumerate design docs older than `maxAgeDays` across one or more roots (typically BOTH
520
+ * `.featyard/designs` and `docs/featyard/designs`). Each `*-design.md` file is its own archive unit, keyed by
521
+ * the slug derived from its name, and routes to `archiveBase/designs/<file>` — mirroring the live
522
+ * `designs/` layout so all archived designs stay browsable together. The active feature's doc
523
+ * (`<excludeSlug>-design.md`) is skipped, not archived. A missing/unreadable dir is a no-op
524
+ * (best-effort). Age is the file's own mtime (a design doc is a leaf, not a tree).
525
+ */
526
+ export function enumerateDesigns(opts: {
527
+ designsDirs: readonly string[];
528
+ archiveBase: string;
529
+ maxAgeDays: number;
530
+ excludeSlug?: string | null;
531
+ now?: number;
532
+ }): { stale: DesignArchiveMember[]; skipped: string[] } {
533
+ const now = opts.now ?? Date.now();
534
+ const cutoff = now - opts.maxAgeDays * MS_PER_DAY;
535
+
536
+ const stale: DesignArchiveMember[] = [];
537
+ const skipped: string[] = [];
538
+
539
+ for (const dir of opts.designsDirs) {
540
+ let entries: Dirent[] | null;
541
+ try {
542
+ entries = readdirSync(dir, { withFileTypes: true });
543
+ } catch {
544
+ continue; // dir absent/unreadable — no candidates here
545
+ }
546
+ for (const entry of entries) {
547
+ if (!entry.isFile() || !/-design\.md$/i.test(entry.name)) continue;
548
+ const slug = designDocSlug(entry.name);
549
+ const src = path.join(dir, entry.name);
550
+ // Exclude the active feature's design doc (resume would be artifact-degraded without it).
551
+ if (opts.excludeSlug != null && slug === opts.excludeSlug) {
552
+ skipped.push(src);
553
+ continue;
554
+ }
555
+ const dest = path.join(opts.archiveBase, "designs", entry.name);
556
+ // Path-safety containment: drop a candidate whose dest escapes archiveBase.
557
+ if (!isPathInside(opts.archiveBase, dest)) continue;
558
+ // Age = the file's own mtime (leaf, not a tree).
559
+ if (newestMtimeInTree(src) < cutoff) {
560
+ stale.push({ src, dest, slug });
561
+ }
562
+ }
563
+ }
564
+
565
+ return { stale, skipped };
566
+ }
567
+
568
+ /**
569
+ * Manual-command / sweep entry point for design docs — archives every design doc older than
570
+ * `days` across the given roots, with an `excludeSlug` to protect the active in-session feature.
571
+ * Does NOT skip subagent sessions (mirrors `archiveArtifactsOlderThan` — the manual command runs on
572
+ * demand; the background sweep caller decides subagent-skipping). Shares `moveArtifact` with the
573
+ * rest of the engine so the move is identical. Failures are best-effort: per-item errors land in
574
+ * `errors[]`, the promise never rejects. A group (here, a single file) counts as archived only
575
+ * when its move succeeded.
576
+ */
577
+ export async function archiveDesignsOlderThan(opts: {
578
+ designsDirs: readonly string[];
579
+ archiveBase: string;
580
+ days: number;
581
+ excludeSlug?: string | null;
582
+ now?: number;
583
+ }): Promise<{ archived: string[]; skipped: string[]; errors: string[]; archivedCount: number }> {
584
+ const { stale, skipped } = enumerateDesigns({
585
+ designsDirs: opts.designsDirs,
586
+ archiveBase: opts.archiveBase,
587
+ maxAgeDays: opts.days,
588
+ excludeSlug: opts.excludeSlug,
589
+ now: opts.now,
590
+ });
591
+ const archived: string[] = [];
592
+ const errors: string[] = [];
593
+ for (const member of stale) {
594
+ const result = moveArtifact(member.src, member.dest, REAL_FS_MOVE_OPS);
595
+ if (result.ok) {
596
+ archived.push(member.dest);
597
+ } else {
598
+ errors.push(result.error);
599
+ }
600
+ }
601
+ return { archived, skipped, errors, archivedCount: archived.length };
602
+ }