nitm-ai-dev-toolkit 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/README.md +60 -4
  3. package/bin/cli.js +152 -25
  4. package/bin/factory-archive.js +71 -0
  5. package/bin/factory-prune.js +79 -0
  6. package/bin/factory-watch.js +509 -0
  7. package/package.json +1 -1
  8. package/src/monorepo/.agents/skills/factory/SKILL.md +593 -0
  9. package/src/monorepo/.claude/agents/admin-portal.agent.md +1 -1
  10. package/src/monorepo/.claude/agents/api-specialist.agent.md +1 -1
  11. package/src/monorepo/.claude/agents/backend-architect.agent.md +1 -1
  12. package/src/monorepo/.claude/agents/documenter.agent.md +1 -1
  13. package/src/monorepo/.claude/agents/factory-approver.agent.md +12 -0
  14. package/src/monorepo/.claude/agents/factory-builder.agent.md +12 -0
  15. package/src/monorepo/.claude/agents/factory-reviewer-code.agent.md +12 -0
  16. package/src/monorepo/.claude/agents/factory-reviewer-security.agent.md +12 -0
  17. package/src/monorepo/.claude/agents/factory-reviewer-ui.agent.md +12 -0
  18. package/src/monorepo/.claude/agents/factory-reviewer-ux.agent.md +12 -0
  19. package/src/monorepo/.claude/agents/factory-spec-writer.agent.md +12 -0
  20. package/src/monorepo/.claude/agents/feature-builder.agent.md +1 -1
  21. package/src/monorepo/.claude/agents/frontend-developer.agent.md +1 -1
  22. package/src/monorepo/.claude/agents/green.agent.md +1 -1
  23. package/src/monorepo/.claude/agents/implementer.agent.md +1 -1
  24. package/src/monorepo/.claude/agents/planner.agent.md +1 -1
  25. package/src/monorepo/.claude/agents/red.agent.md +1 -1
  26. package/src/monorepo/.claude/agents/refactor.agent.md +1 -1
  27. package/src/monorepo/.claude/agents/researcher.agent.md +1 -1
  28. package/src/monorepo/.claude/agents/reviewer.agent.md +1 -1
  29. package/src/monorepo/.claude/agents/tdd.agent.md +1 -1
  30. package/src/monorepo/.claude/agents-snippets/factory-approver.md +72 -0
  31. package/src/monorepo/.claude/agents-snippets/factory-builder.md +101 -0
  32. package/src/monorepo/.claude/agents-snippets/factory-reviewer-code.md +57 -0
  33. package/src/monorepo/.claude/agents-snippets/factory-reviewer-security.md +48 -0
  34. package/src/monorepo/.claude/agents-snippets/factory-reviewer-ui.md +64 -0
  35. package/src/monorepo/.claude/agents-snippets/factory-reviewer-ux.md +64 -0
  36. package/src/monorepo/.claude/agents-snippets/factory-spec-writer.md +119 -0
  37. package/src/monorepo/.claude/agents-snippets/feature-builder.md +1 -1
  38. package/src/monorepo/.claude/commands/factory.md +24 -0
  39. package/src/monorepo/.claude/prompt-snippets/assign-tasks.md +6 -2
  40. package/src/monorepo/.claude/prompt-snippets/bootstrap.md +22 -13
  41. package/src/monorepo/.claude/prompt-snippets/factory.md +20 -0
  42. package/src/monorepo/.claude/prompt-snippets/implement-feature.md +2 -2
  43. package/src/monorepo/.claude/prompt-snippets/tasks.md +49 -87
  44. package/src/monorepo/.claude/rules-snippets/documentation.md +8 -6
  45. package/src/monorepo/.cursor/agents/factory-approver.agent.md +11 -0
  46. package/src/monorepo/.cursor/agents/factory-builder.agent.md +11 -0
  47. package/src/monorepo/.cursor/agents/factory-reviewer-code.agent.md +11 -0
  48. package/src/monorepo/.cursor/agents/factory-reviewer-security.agent.md +11 -0
  49. package/src/monorepo/.cursor/agents/factory-reviewer-ui.agent.md +11 -0
  50. package/src/monorepo/.cursor/agents/factory-reviewer-ux.agent.md +11 -0
  51. package/src/monorepo/.cursor/agents/factory-spec-writer.agent.md +11 -0
  52. package/src/monorepo/.cursor/commands/factory.md +24 -0
  53. package/src/monorepo/.github/agents/factory-approver.agent.md +11 -0
  54. package/src/monorepo/.github/agents/factory-builder.agent.md +11 -0
  55. package/src/monorepo/.github/agents/factory-reviewer-code.agent.md +11 -0
  56. package/src/monorepo/.github/agents/factory-reviewer-security.agent.md +11 -0
  57. package/src/monorepo/.github/agents/factory-reviewer-ui.agent.md +11 -0
  58. package/src/monorepo/.github/agents/factory-reviewer-ux.agent.md +11 -0
  59. package/src/monorepo/.github/agents/factory-spec-writer.agent.md +11 -0
  60. package/src/monorepo/.github/prompts/factory.prompt.md +25 -0
  61. package/src/monorepo/.opencode/agents/factory-approver.md +20 -0
  62. package/src/monorepo/.opencode/agents/factory-builder.md +20 -0
  63. package/src/monorepo/.opencode/agents/factory-reviewer-code.md +20 -0
  64. package/src/monorepo/.opencode/agents/factory-reviewer-security.md +20 -0
  65. package/src/monorepo/.opencode/agents/factory-reviewer-ui.md +20 -0
  66. package/src/monorepo/.opencode/agents/factory-reviewer-ux.md +20 -0
  67. package/src/monorepo/.opencode/agents/factory-spec-writer.md +20 -0
  68. package/src/monorepo/.opencode/commands/factory.md +29 -0
  69. package/src/monorepo/AGENTS.md +22 -1
  70. package/src/monorepo/README.md +6 -0
  71. package/src/monorepo/factory/README.md +194 -0
  72. package/src/monorepo/factory/_gitignore +5 -0
  73. package/src/repo/.agents/skills/factory/SKILL.md +593 -0
  74. package/src/repo/.claude/agents/admin-portal.agent.md +1 -1
  75. package/src/repo/.claude/agents/api-specialist.agent.md +1 -1
  76. package/src/repo/.claude/agents/backend-architect.agent.md +1 -1
  77. package/src/repo/.claude/agents/documenter.agent.md +1 -1
  78. package/src/repo/.claude/agents/factory-approver.agent.md +12 -0
  79. package/src/repo/.claude/agents/factory-builder.agent.md +12 -0
  80. package/src/repo/.claude/agents/factory-reviewer-code.agent.md +12 -0
  81. package/src/repo/.claude/agents/factory-reviewer-security.agent.md +12 -0
  82. package/src/repo/.claude/agents/factory-reviewer-ui.agent.md +12 -0
  83. package/src/repo/.claude/agents/factory-reviewer-ux.agent.md +12 -0
  84. package/src/repo/.claude/agents/factory-spec-writer.agent.md +12 -0
  85. package/src/repo/.claude/agents/feature-builder.agent.md +1 -1
  86. package/src/repo/.claude/agents/frontend-developer.agent.md +1 -1
  87. package/src/repo/.claude/agents/green.agent.md +1 -1
  88. package/src/repo/.claude/agents/implementer.agent.md +1 -1
  89. package/src/repo/.claude/agents/planner.agent.md +1 -1
  90. package/src/repo/.claude/agents/red.agent.md +1 -1
  91. package/src/repo/.claude/agents/refactor.agent.md +1 -1
  92. package/src/repo/.claude/agents/researcher.agent.md +1 -1
  93. package/src/repo/.claude/agents/reviewer.agent.md +1 -1
  94. package/src/repo/.claude/agents/tdd.agent.md +1 -1
  95. package/src/repo/.claude/agents-snippets/factory-approver.md +72 -0
  96. package/src/repo/.claude/agents-snippets/factory-builder.md +100 -0
  97. package/src/repo/.claude/agents-snippets/factory-reviewer-code.md +57 -0
  98. package/src/repo/.claude/agents-snippets/factory-reviewer-security.md +48 -0
  99. package/src/repo/.claude/agents-snippets/factory-reviewer-ui.md +64 -0
  100. package/src/repo/.claude/agents-snippets/factory-reviewer-ux.md +64 -0
  101. package/src/repo/.claude/agents-snippets/factory-spec-writer.md +117 -0
  102. package/src/repo/.claude/agents-snippets/feature-builder.md +1 -1
  103. package/src/repo/.claude/commands/factory.md +24 -0
  104. package/src/repo/.claude/prompt-snippets/assign-tasks.md +6 -2
  105. package/src/repo/.claude/prompt-snippets/bootstrap.md +30 -20
  106. package/src/repo/.claude/prompt-snippets/factory.md +20 -0
  107. package/src/repo/.claude/prompt-snippets/implement-feature.md +2 -2
  108. package/src/repo/.claude/prompt-snippets/tasks.md +49 -112
  109. package/src/repo/.cursor/agents/factory-approver.agent.md +11 -0
  110. package/src/repo/.cursor/agents/factory-builder.agent.md +11 -0
  111. package/src/repo/.cursor/agents/factory-reviewer-code.agent.md +11 -0
  112. package/src/repo/.cursor/agents/factory-reviewer-security.agent.md +11 -0
  113. package/src/repo/.cursor/agents/factory-reviewer-ui.agent.md +11 -0
  114. package/src/repo/.cursor/agents/factory-reviewer-ux.agent.md +11 -0
  115. package/src/repo/.cursor/agents/factory-spec-writer.agent.md +11 -0
  116. package/src/repo/.cursor/commands/factory.md +24 -0
  117. package/src/repo/.github/agents/factory-approver.agent.md +11 -0
  118. package/src/repo/.github/agents/factory-builder.agent.md +11 -0
  119. package/src/repo/.github/agents/factory-reviewer-code.agent.md +11 -0
  120. package/src/repo/.github/agents/factory-reviewer-security.agent.md +11 -0
  121. package/src/repo/.github/agents/factory-reviewer-ui.agent.md +11 -0
  122. package/src/repo/.github/agents/factory-reviewer-ux.agent.md +11 -0
  123. package/src/repo/.github/agents/factory-spec-writer.agent.md +11 -0
  124. package/src/repo/.github/prompts/factory.prompt.md +25 -0
  125. package/src/repo/.opencode/agents/factory-approver.md +20 -0
  126. package/src/repo/.opencode/agents/factory-builder.md +20 -0
  127. package/src/repo/.opencode/agents/factory-reviewer-code.md +20 -0
  128. package/src/repo/.opencode/agents/factory-reviewer-security.md +20 -0
  129. package/src/repo/.opencode/agents/factory-reviewer-ui.md +20 -0
  130. package/src/repo/.opencode/agents/factory-reviewer-ux.md +20 -0
  131. package/src/repo/.opencode/agents/factory-spec-writer.md +20 -0
  132. package/src/repo/.opencode/commands/factory.md +29 -0
  133. package/src/repo/AGENTS.md +19 -1
  134. package/src/repo/README.md +6 -0
  135. package/src/repo/factory/README.md +194 -0
  136. package/src/repo/factory/_gitignore +5 -0
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ // `factory prune`: permanently deletes closed jobs from both factory/jobs/ and the
4
+ // sibling factory/archive/. By default only merged jobs; --abandoned also deletes
5
+ // abandoned jobs. Age rule (factory.selectClosedJobs): default only jobs closed more
6
+ // than 7 days ago with a valid updatedAt; --all every such job, with no age limit.
7
+ // Open jobs are counted, never touched. Deletes only the job folders inside those
8
+ // two directories. Stdlib only.
9
+ //
10
+ // Safety: without --yes nothing is deleted (it lists and says to rerun with --yes).
11
+ // --dry-run always only lists, even with --yes.
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const factory = require('./factory-watch.js');
16
+
17
+ function cmdFactoryPrune(cwd, flags = {}, opts = {}) {
18
+ const stdout = opts.stdout || process.stdout;
19
+ const now = opts.now === undefined ? Date.now() : opts.now;
20
+ const all = Boolean(flags.all);
21
+ const dryRun = Boolean(flags.dryRun);
22
+ const yes = Boolean(flags.yes);
23
+ const willDelete = yes && !dryRun;
24
+ const abandoned = Boolean(flags.abandoned);
25
+ const stages = abandoned ? ['merged', 'abandoned'] : ['merged'];
26
+ const jobsDir = factory.resolveDir(cwd, flags);
27
+ const archiveDir = factory.archiveDirOf(jobsDir);
28
+ const line = (text) => stdout.write(text + '\n');
29
+ const locations = [{ name: 'jobs', dir: jobsDir }, { name: 'archive', dir: archiveDir }];
30
+ const result = {
31
+ dryRun, yes, willDelete, abandoned, locations: {},
32
+ candidates: [], deleted: [], skippedOpen: 0, kept: 0, skippedAbandoned: 0, skippedInvalid: [],
33
+ };
34
+
35
+ for (const loc of locations) {
36
+ const sel = factory.selectClosedJobs(loc.dir, { all, now, stages });
37
+ result.locations[loc.name] = { dir: loc.dir, exists: sel.exists };
38
+ if (!sel.exists) continue;
39
+ result.skippedOpen += sel.skippedOpen;
40
+ result.kept += sel.kept;
41
+ result.skippedAbandoned += sel.skippedAbandoned;
42
+ for (const { id, reason } of sel.skippedInvalid) result.skippedInvalid.push({ location: loc.name, id, reason });
43
+ for (const c of sel.candidates) result.candidates.push({ location: loc.name, id: c.id, dir: c.dir, stage: c.stage });
44
+ }
45
+
46
+ if (willDelete) {
47
+ for (const c of result.candidates) {
48
+ fs.rmSync(c.dir, { recursive: true });
49
+ result.deleted.push(c);
50
+ }
51
+ }
52
+
53
+ const verb = willDelete ? 'Deleted' : 'Would delete';
54
+ if (result.candidates.length) {
55
+ line(`${verb} ${result.candidates.length} closed job(s):`);
56
+ for (const c of result.candidates) line(` [${c.location}] ${c.id} ${c.stage}`);
57
+ const count = (name) => result.candidates.filter((c) => c.location === name).length;
58
+ line(` per location: jobs ${count('jobs')}, archive ${count('archive')}`);
59
+ } else {
60
+ line('No closed jobs to delete.');
61
+ }
62
+ if (!willDelete && result.candidates.length) {
63
+ line(dryRun ? 'Dry run: nothing deleted.' : 'Nothing deleted. Rerun with --yes to delete these permanently.');
64
+ }
65
+ line(`Skipped ${result.skippedOpen} open job(s) (never deleted).`);
66
+ if (result.skippedInvalid.length) {
67
+ line(`Skipped ${result.skippedInvalid.length} invalid (not deleted):`);
68
+ for (const { location, id, reason } of result.skippedInvalid) line(` [${location}] ${id} ${reason}`);
69
+ }
70
+ if (!all && result.kept) {
71
+ line(`Kept ${result.kept} closed job(s) newer than ${factory.DEFAULT_AGE_DAYS} days (--all deletes them too).`);
72
+ }
73
+ if (!abandoned && result.skippedAbandoned) {
74
+ line(`Kept ${result.skippedAbandoned} abandoned job(s) (--abandoned deletes them too).`);
75
+ }
76
+ return result;
77
+ }
78
+
79
+ module.exports = { cmdFactoryPrune };
@@ -0,0 +1,509 @@
1
+ 'use strict';
2
+
3
+ // `factory` subcommand: a read-only view of factory/jobs/<NNN-slug>/job.json.
4
+ // `status` prints one table and exits; `watch` is a live terminal UI.
5
+ // Stdlib only. Never writes to the jobs directory (agents write it concurrently).
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const readline = require('readline');
10
+
11
+ const NO_JOBS = 'No factory jobs yet — run /factory <feature> in your agent';
12
+ const DEFAULT_INTERVAL = 2000;
13
+ // Closed jobs are hidden from status/watch unless --all; `factory archive` moves them to archive/.
14
+ const CLOSED_STAGES = new Set(['merged', 'abandoned']);
15
+ const DAY_MS = 24 * 60 * 60 * 1000;
16
+ // `factory archive` and `factory prune` keep closed jobs younger than this unless --all.
17
+ const DEFAULT_AGE_DAYS = 7;
18
+ const CODES = { red: 31, green: 32, yellow: 33, cyan: 36, dim: 2 };
19
+ // Stages waiting on the operator (awaiting-spec, awaiting-plan, awaiting-human) are yellow; needs-human is red.
20
+ // Working stages (spec, plan, build, review, approve) keep the default in-progress colour.
21
+ // `abandoned` is a closed job (`/factory abandon`), so it is dimmed like a finished one.
22
+ const STAGE_COLOR = new Map([
23
+ ['merged', 'green'], ['abandoned', 'dim'], ['needs-human', 'red'], ['awaiting-human', 'yellow'],
24
+ ['awaiting-spec', 'yellow'], ['awaiting-plan', 'yellow'],
25
+ ]);
26
+ const CHIP = new Map([['PASS', ['✓', 'green']], ['CHANGES', ['✗', 'red']]]);
27
+ const PENDING_CHIP = ['·', 'dim'];
28
+ const STAGE_W = 15;
29
+ const RND_W = 3;
30
+ const AGE_W = 4;
31
+ const GAP = ' ';
32
+ const HINT_LIST = '↑/↓ select · Enter open · r refresh · q quit';
33
+ const HINT_DETAIL = '↑/↓ choose file · Enter view · r refresh · q/Esc back';
34
+ const HINT_PAGER = '↑/↓ PgUp/PgDn space scroll · q/Esc back';
35
+
36
+ const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), Math.max(lo, hi));
37
+
38
+ function resolveDir(cwd, flags = {}) {
39
+ return path.resolve(cwd, flags.dir || path.join('factory', 'jobs'));
40
+ }
41
+
42
+ // Archive sits beside the jobs directory: factory/jobs -> factory/archive.
43
+ function archiveDirOf(jobsDir) {
44
+ return path.join(path.dirname(jobsDir), 'archive');
45
+ }
46
+
47
+ function isClosed(job) {
48
+ return CLOSED_STAGES.has(job.stage);
49
+ }
50
+
51
+ function hiddenLine(n) {
52
+ return `${n} closed job${n === 1 ? '' : 's'} hidden (--all to show)`;
53
+ }
54
+
55
+ function parseInterval(v) {
56
+ if (v === undefined) return DEFAULT_INTERVAL;
57
+ const n = Number(v);
58
+ if (!Number.isInteger(n) || n < 100) throw new Error('--interval must be a whole number of milliseconds, at least 100');
59
+ return n;
60
+ }
61
+
62
+ // Honour NO_COLOR (present and non-empty) and only colour real terminals.
63
+ function colorOn(stream, env) {
64
+ return Boolean(stream.isTTY) && (env.NO_COLOR === undefined || env.NO_COLOR === '');
65
+ }
66
+
67
+ function paint(color, text, on) {
68
+ return on && CODES[color] ? `\x1b[${CODES[color]}m${text}\x1b[0m` : text;
69
+ }
70
+
71
+ function truncate(s, w) {
72
+ if (w <= 0) return '';
73
+ if (s.length <= w) return s;
74
+ return w === 1 ? '…' : s.slice(0, w - 1) + '…';
75
+ }
76
+
77
+ function formatAge(updatedAt, now = Date.now()) {
78
+ const ts = typeof updatedAt === 'number' ? updatedAt : Date.parse(updatedAt);
79
+ if (!Number.isFinite(ts)) return '-';
80
+ const s = Math.max(0, Math.floor((now - ts) / 1000));
81
+ if (s < 60) return `${s}s`;
82
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
83
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
84
+ return `${Math.floor(s / 86400)}d`;
85
+ }
86
+
87
+ // Strip control characters so agent-written text cannot break the layout.
88
+ function clean(v) { return String(v).replace(/[\x00-\x1f\x7f]/g, ' '); }
89
+ function field(v, fallback) { return typeof v === 'string' && v.trim() ? clean(v) : fallback; }
90
+
91
+ // Tolerant of partial/malformed job.json: missing parts fall back to defaults.
92
+ // issue_key (nullable) and feature_dir are optional; absent or null means ''.
93
+ function normalizeJob(raw, dirName, dir) {
94
+ const job = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
95
+ const reviews = job.reviews && typeof job.reviews === 'object' ? job.reviews : {};
96
+ const lensSrc = Array.isArray(job.lenses) ? job.lenses : Object.keys(reviews);
97
+ return {
98
+ dirName,
99
+ dir,
100
+ id: field(job.id, dirName),
101
+ feature: field(job.feature, ''),
102
+ stage: field(job.stage, '?'),
103
+ round: Number.isInteger(job.round) ? String(job.round) : '-',
104
+ lenses: lensSrc.filter((l) => typeof l === 'string').map(clean),
105
+ reviews,
106
+ updatedAt: job.updatedAt,
107
+ issueKey: field(job.issue_key, ''),
108
+ featureDir: field(job.feature_dir, ''),
109
+ // Set only when the job is `needs-human` because its spec or plan agent failed.
110
+ failedStage: job.failed_stage === 'spec' || job.failed_stage === 'plan' ? job.failed_stage : null,
111
+ };
112
+ }
113
+
114
+ // Raw parsed job.json, or null when missing or unparseable.
115
+ function readJob(jobDir) {
116
+ try {
117
+ return JSON.parse(fs.readFileSync(path.join(jobDir, 'job.json'), 'utf8'));
118
+ } catch (_) {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function sortById(jobs) {
124
+ return jobs.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
125
+ }
126
+
127
+ function loadJobs(dir) {
128
+ let entries;
129
+ try {
130
+ entries = fs.readdirSync(dir, { withFileTypes: true });
131
+ } catch (_) {
132
+ return { exists: false, jobs: [] };
133
+ }
134
+ const jobs = sortById(entries
135
+ .filter((e) => e.isDirectory())
136
+ .map((e) => {
137
+ const jobDir = path.join(dir, e.name);
138
+ return normalizeJob(readJob(jobDir), e.name, jobDir);
139
+ }));
140
+ return { exists: true, jobs };
141
+ }
142
+
143
+ // Shared by `factory archive` and `factory prune`. Sorts the job folders in `dir`:
144
+ // candidates closed jobs in `stages` eligible under the rule (default: updatedAt more than
145
+ // `ageDays` before `now`; all: every such job, even with no date)
146
+ // skippedOpen open jobs (counted only, never touched)
147
+ // skippedAbandoned abandoned jobs left out because `stages` does not include them
148
+ // kept closed jobs newer than `ageDays` (default mode only)
149
+ // skippedInvalid unreadable job.json, or a closed job with no usable date in default mode
150
+ // `stages` defaults to both closed stages (archive); prune passes ['merged'] unless --abandoned.
151
+ function selectClosedJobs(dir, {
152
+ all = false, now = Date.now(), ageDays = DEFAULT_AGE_DAYS, stages = ['merged', 'abandoned'],
153
+ } = {}) {
154
+ let entries;
155
+ try {
156
+ entries = fs.readdirSync(dir, { withFileTypes: true });
157
+ } catch (_) {
158
+ return { exists: false, candidates: [], skippedOpen: 0, kept: 0, skippedAbandoned: 0, skippedInvalid: [] };
159
+ }
160
+ const out = { exists: true, candidates: [], skippedOpen: 0, kept: 0, skippedAbandoned: 0, skippedInvalid: [] };
161
+ const ids = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
162
+ for (const id of ids) {
163
+ const dirPath = path.join(dir, id);
164
+ const raw = readJob(dirPath);
165
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
166
+ out.skippedInvalid.push({ id, reason: 'missing or unparseable job.json' });
167
+ continue;
168
+ }
169
+ if (!isClosed(raw)) {
170
+ out.skippedOpen++;
171
+ continue;
172
+ }
173
+ if (!stages.includes(raw.stage)) {
174
+ if (raw.stage === 'abandoned') out.skippedAbandoned++;
175
+ continue;
176
+ }
177
+ const ts = typeof raw.updatedAt === 'number' ? raw.updatedAt : Date.parse(raw.updatedAt);
178
+ if (!all) {
179
+ if (!Number.isFinite(ts)) {
180
+ out.skippedInvalid.push({ id, reason: 'no valid updatedAt, age unknown' });
181
+ continue;
182
+ }
183
+ if (now - ts <= ageDays * DAY_MS) {
184
+ out.kept++;
185
+ continue;
186
+ }
187
+ }
188
+ out.candidates.push({ id, dir: dirPath, stage: raw.stage });
189
+ }
190
+ return out;
191
+ }
192
+
193
+ // What status and watch list. By default closed jobs are hidden and counted;
194
+ // with `all` the archive is included too, each job marked `archived`.
195
+ function loadListed(dir, all) {
196
+ const own = loadJobs(dir);
197
+ if (all) {
198
+ const archived = loadJobs(archiveDirOf(dir)).jobs.map((j) => ({ ...j, archived: true }));
199
+ return { exists: own.exists, jobs: sortById(own.jobs.concat(archived)), hidden: 0 };
200
+ }
201
+ return {
202
+ exists: own.exists,
203
+ jobs: own.jobs.filter((j) => !isClosed(j)),
204
+ hidden: own.jobs.filter(isClosed).length,
205
+ };
206
+ }
207
+
208
+ // Markdown files inside one job dir, including round-<N>/ subfolders.
209
+ function listMarkdown(jobDir) {
210
+ const out = [];
211
+ const walk = (rel) => {
212
+ let entries;
213
+ try {
214
+ entries = fs.readdirSync(path.join(jobDir, rel), { withFileTypes: true });
215
+ } catch (_) {
216
+ return;
217
+ }
218
+ for (const e of entries) {
219
+ const r = path.join(rel, e.name);
220
+ if (e.isDirectory()) walk(r);
221
+ else if (e.name.endsWith('.md')) out.push(r);
222
+ }
223
+ };
224
+ walk('');
225
+ return out.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
226
+ }
227
+
228
+ function chipsOf(job) {
229
+ return job.lenses.map((lens) => {
230
+ const [sym, color] = CHIP.get(String(job.reviews[lens]).toUpperCase()) || PENDING_CHIP;
231
+ return { plain: `${sym} ${lens}`, color };
232
+ });
233
+ }
234
+
235
+ function plainLenses(job) {
236
+ return chipsOf(job).map((c) => c.plain).join(' ');
237
+ }
238
+
239
+ // Column widths shared by every row so the table lines up; feature takes the rest.
240
+ function layout(jobs, width) {
241
+ const idW = Math.max(2, ...jobs.map((j) => j.id.length));
242
+ const lensW = Math.max(6, ...jobs.map((j) => plainLenses(j).length));
243
+ const featW = Math.max(0, width - idW - STAGE_W - RND_W - lensW - AGE_W - GAP.length * 5);
244
+ return { idW, lensW, featW };
245
+ }
246
+
247
+ function formatHeader(lay) {
248
+ return [
249
+ 'ID'.padEnd(lay.idW),
250
+ 'STAGE'.padEnd(STAGE_W),
251
+ 'RND'.padEnd(RND_W),
252
+ 'LENSES'.padEnd(lay.lensW),
253
+ truncate('FEATURE', lay.featW).padEnd(lay.featW),
254
+ 'AGE'.padStart(AGE_W),
255
+ ].join(GAP);
256
+ }
257
+
258
+ function formatRow(job, lay, { color = false, now } = {}) {
259
+ const chips = chipsOf(job);
260
+ const lenses = chips.map((c) => paint(c.color, c.plain, color)).join(' ');
261
+ return [
262
+ job.id.padEnd(lay.idW),
263
+ paint(STAGE_COLOR.get(job.stage) || 'cyan', truncate(job.stage, STAGE_W).padEnd(STAGE_W), color),
264
+ job.round.padEnd(RND_W),
265
+ lenses + ' '.repeat(lay.lensW - plainLenses(job).length),
266
+ truncate(job.archived ? `[archived] ${job.feature}` : job.feature, lay.featW).padEnd(lay.featW),
267
+ formatAge(job.updatedAt, now).padStart(AGE_W),
268
+ ].join(GAP);
269
+ }
270
+
271
+ function cmdFactoryStatus(cwd, flags = {}, opts = {}) {
272
+ const stdout = opts.stdout || process.stdout;
273
+ const on = colorOn(stdout, opts.env || process.env);
274
+ const dir = resolveDir(cwd, flags);
275
+ const { exists, jobs, hidden } = loadListed(dir, Boolean(flags.all));
276
+ const line = (text) => stdout.write(text + '\n');
277
+ if (!exists) {
278
+ line(`${paint('cyan', '•', on)} ${NO_JOBS}`);
279
+ } else if (!jobs.length) {
280
+ const msg = hidden ? `No open jobs in ${dir}.` : `No jobs in ${dir} yet.`;
281
+ line(`${paint('cyan', '•', on)} ${msg}`);
282
+ } else {
283
+ const lay = layout(jobs, stdout.columns || 100);
284
+ line(paint('dim', formatHeader(lay), on));
285
+ for (const job of jobs) line(formatRow(job, lay, { color: on, now: opts.now }));
286
+ }
287
+ if (hidden) line(paint('dim', hiddenLine(hidden), on));
288
+ return { dir, exists, jobs, hidden };
289
+ }
290
+
291
+ // ---- watch (live TUI) -------------------------------------------------------
292
+
293
+ function openJob(state) {
294
+ return state.jobs.find((j) => j.dir === state.openDir) || null;
295
+ }
296
+
297
+ function readText(file) {
298
+ try {
299
+ return fs.readFileSync(file, 'utf8');
300
+ } catch (e) {
301
+ return `(cannot read ${path.basename(file)}: ${e.code || e.message})`;
302
+ }
303
+ }
304
+
305
+ function listScreen(state, ctx) {
306
+ const { cols, rows, on, now, dir } = ctx;
307
+ const footer = state.hidden ? 1 : 0;
308
+ const lines = [truncate(`factory · ${state.jobs.length} job(s) · ${dir}`, cols), ''];
309
+ if (!state.exists) {
310
+ lines.push(paint('dim', NO_JOBS, on));
311
+ } else if (!state.jobs.length) {
312
+ lines.push(paint('dim', state.hidden ? `No open jobs in ${dir}.` : `No jobs in ${dir} yet.`, on));
313
+ } else {
314
+ const lay = layout(state.jobs, cols - 2);
315
+ const visible = Math.max(1, rows - 5 - footer);
316
+ if (state.selected < state.top) state.top = state.selected;
317
+ if (state.selected >= state.top + visible) state.top = state.selected - visible + 1;
318
+ lines.push(' ' + paint('dim', formatHeader(lay), on));
319
+ state.jobs.slice(state.top, state.top + visible).forEach((job, i) => {
320
+ const mark = state.top + i === state.selected ? '› ' : ' ';
321
+ lines.push(mark + formatRow(job, lay, { color: on, now }));
322
+ });
323
+ }
324
+ lines.push('');
325
+ if (state.hidden) lines.push(paint('dim', hiddenLine(state.hidden), on));
326
+ lines.push(paint('dim', HINT_LIST, on));
327
+ return lines;
328
+ }
329
+
330
+ function detailScreen(state, ctx, job) {
331
+ const { cols, rows, on } = ctx;
332
+ const issue = job.issueKey ? ` ${job.issueKey}` : '';
333
+ const failed = job.failedStage ? ` failed: ${job.failedStage}` : '';
334
+ const lines = [truncate(`${job.id} ${job.stage} round ${job.round}${issue}${failed} ${job.feature}`, cols)];
335
+ if (job.featureDir) lines.push(paint('dim', truncate(job.featureDir, cols), on));
336
+ lines.push('');
337
+ if (!state.files.length) {
338
+ lines.push(paint('dim', '(no markdown files yet)', on));
339
+ } else {
340
+ const visible = Math.max(1, rows - 4 - (job.featureDir ? 1 : 0));
341
+ const start = clamp(state.fileIdx - Math.floor(visible / 2), 0, state.files.length - visible);
342
+ state.files.slice(start, start + visible).forEach((f, i) => {
343
+ const mark = start + i === state.fileIdx ? '› ' : ' ';
344
+ lines.push(truncate(mark + f, cols));
345
+ });
346
+ }
347
+ lines.push('', paint('dim', HINT_DETAIL, on));
348
+ return lines;
349
+ }
350
+
351
+ function pagerScreen(state, ctx, job) {
352
+ const { cols, rows, on } = ctx;
353
+ const body = readText(path.join(job.dir, state.file))
354
+ .replace(/\t/g, ' ')
355
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
356
+ .split('\n');
357
+ const visible = Math.max(1, rows - 3);
358
+ state.scroll = clamp(state.scroll, 0, body.length - visible);
359
+ const end = Math.min(body.length, state.scroll + visible);
360
+ const lines = [truncate(`${state.file} lines ${state.scroll + 1}-${end} of ${body.length}`, cols), ''];
361
+ body.slice(state.scroll, end).forEach((l) => lines.push(truncate(l, cols)));
362
+ lines.push(paint('dim', HINT_PAGER, on));
363
+ return lines;
364
+ }
365
+
366
+ function screen(state, ctx) {
367
+ const job = openJob(state);
368
+ if (state.view === 'detail' && job) return detailScreen(state, ctx, job);
369
+ if (state.view === 'pager' && job) return pagerScreen(state, ctx, job);
370
+ return listScreen(state, ctx);
371
+ }
372
+
373
+ function cmdFactoryWatch(cwd, flags = {}, opts = {}) {
374
+ const interval = parseInterval(flags.interval);
375
+ const stdin = opts.stdin || process.stdin;
376
+ const stdout = opts.stdout || process.stdout;
377
+ if (!stdout.isTTY || !stdin.isTTY) return cmdFactoryStatus(cwd, flags, opts);
378
+
379
+ const dir = resolveDir(cwd, flags);
380
+ const on = colorOn(stdout, opts.env || process.env);
381
+ const all = Boolean(flags.all);
382
+ const state = {
383
+ view: 'list', selected: 0, top: 0, exists: false, jobs: [], hidden: 0,
384
+ openDir: null, files: [], fileIdx: 0, file: null, scroll: 0,
385
+ };
386
+
387
+ function refresh() {
388
+ const r = loadListed(dir, all);
389
+ state.exists = r.exists;
390
+ state.jobs = r.jobs;
391
+ state.hidden = r.hidden;
392
+ state.selected = clamp(state.selected, 0, r.jobs.length - 1);
393
+ const job = openJob(state);
394
+ if (state.view !== 'list' && !job) state.view = 'list';
395
+ if (state.view === 'detail') {
396
+ state.files = listMarkdown(job.dir);
397
+ state.fileIdx = clamp(state.fileIdx, 0, state.files.length - 1);
398
+ }
399
+ }
400
+
401
+ function render() {
402
+ const ctx = { cols: stdout.columns || 80, rows: stdout.rows || 24, on, now: Date.now(), dir };
403
+ const lines = screen(state, ctx);
404
+ stdout.write('\x1b[H' + lines.slice(0, ctx.rows).map((l) => l + '\x1b[K').join('\n') + '\x1b[J');
405
+ }
406
+
407
+ let timer = null;
408
+ let restored = false;
409
+ // Idempotent: every exit path (q, Ctrl-C, SIGTERM, crash, normal end) goes through here.
410
+ function restore() {
411
+ if (restored) return;
412
+ restored = true;
413
+ clearInterval(timer);
414
+ stdin.removeListener('keypress', onKeypress);
415
+ if (stdin.setRawMode) stdin.setRawMode(false);
416
+ stdin.pause();
417
+ stdout.write('\x1b[?25h\x1b[?1049l');
418
+ process.removeListener('SIGINT', onSigint);
419
+ process.removeListener('SIGTERM', onSigterm);
420
+ stdout.removeListener('resize', render);
421
+ process.removeListener('uncaughtException', onCrash);
422
+ process.removeListener('exit', restore);
423
+ }
424
+ function onSigint() { restore(); process.exit(130); }
425
+ function onSigterm() { restore(); process.exit(143); }
426
+ function onCrash(e) {
427
+ restore();
428
+ console.error(e && e.stack ? e.stack : String(e));
429
+ process.exit(1);
430
+ }
431
+
432
+ function onKey(str, key = {}) {
433
+ const name = key.name;
434
+ if (key.ctrl && name === 'c') {
435
+ process.exitCode = 130;
436
+ return restore();
437
+ }
438
+ const step = name === 'up' || name === 'k' ? -1 : name === 'down' || name === 'j' ? 1 : 0;
439
+ const enter = name === 'return' || name === 'enter';
440
+ const page = Math.max(1, (stdout.rows || 24) - 3);
441
+ if (name === 'r') refresh();
442
+ else if (state.view === 'list') {
443
+ if (name === 'q') return restore();
444
+ if (step) state.selected = clamp(state.selected + step, 0, state.jobs.length - 1);
445
+ const job = state.jobs[state.selected];
446
+ if (enter && job) {
447
+ state.openDir = job.dir;
448
+ state.files = listMarkdown(job.dir);
449
+ state.fileIdx = 0;
450
+ state.view = 'detail';
451
+ }
452
+ } else if (state.view === 'detail') {
453
+ if (name === 'q' || name === 'escape') state.view = 'list';
454
+ else if (step) state.fileIdx = clamp(state.fileIdx + step, 0, state.files.length - 1);
455
+ else if (enter && state.files.length) {
456
+ state.file = state.files[state.fileIdx];
457
+ state.scroll = 0;
458
+ state.view = 'pager';
459
+ }
460
+ } else {
461
+ if (name === 'q' || name === 'escape') state.view = 'detail';
462
+ else if (step) state.scroll = Math.max(0, state.scroll + step);
463
+ else if (name === 'pageup') state.scroll = Math.max(0, state.scroll - page);
464
+ else if (name === 'pagedown' || name === 'space') state.scroll += page;
465
+ }
466
+ render();
467
+ }
468
+ const onKeypress = (str, key) => onKey(str, key);
469
+
470
+ stdout.write('\x1b[?1049h\x1b[?25l');
471
+ readline.emitKeypressEvents(stdin);
472
+ stdin.setRawMode(true);
473
+ stdin.on('keypress', onKeypress);
474
+ stdin.resume();
475
+ process.on('SIGINT', onSigint);
476
+ process.on('SIGTERM', onSigterm);
477
+ stdout.on('resize', render);
478
+ process.on('uncaughtException', onCrash);
479
+ process.on('exit', restore);
480
+
481
+ refresh();
482
+ render();
483
+ timer = setInterval(() => { refresh(); render(); }, interval);
484
+ }
485
+
486
+ module.exports = {
487
+ DEFAULT_INTERVAL,
488
+ NO_JOBS,
489
+ CLOSED_STAGES,
490
+ DEFAULT_AGE_DAYS,
491
+ resolveDir,
492
+ archiveDirOf,
493
+ isClosed,
494
+ hiddenLine,
495
+ selectClosedJobs,
496
+ parseInterval,
497
+ readJob,
498
+ loadJobs,
499
+ loadListed,
500
+ listMarkdown,
501
+ normalizeJob,
502
+ formatAge,
503
+ truncate,
504
+ layout,
505
+ formatHeader,
506
+ formatRow,
507
+ cmdFactoryStatus,
508
+ cmdFactoryWatch,
509
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nitm-ai-dev-toolkit",
3
- "version": "4.0.0",
3
+ "version": "5.0.0",
4
4
  "description": "Scaffold AI agent config (Cursor, GitHub Copilot, Claude Code, OpenCode, Codex) into any repo, then hand off to your AI agent to finish setup.",
5
5
  "bin": {
6
6
  "nitm-ai-dev-toolkit": "bin/cli.js"