@xenonbyte/xsk 0.1.0 → 0.1.2

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 (33) hide show
  1. package/README.md +46 -24
  2. package/README.zh-CN.md +46 -24
  3. package/bin/xsk.js +10 -1
  4. package/lib/adapters/opencode.js +7 -1
  5. package/lib/capability.js +29 -2
  6. package/lib/install.js +96 -7
  7. package/lib/manifest.js +21 -1
  8. package/lib/skills.js +16 -2
  9. package/lib/status.js +33 -4
  10. package/lib/uninstall.js +88 -10
  11. package/package.json +1 -1
  12. package/skills/archive-req/SKILL.md +11 -6
  13. package/skills/check/SKILL.md +1 -1
  14. package/skills/consume-point/SKILL.md +43 -0
  15. package/skills/point/SKILL.md +68 -0
  16. package/skills/skill-scaffold/SKILL.md +5 -2
  17. package/skills/write-req/SKILL.md +14 -7
  18. package/templates/fragments/archive-req.behavior.md +8 -3
  19. package/templates/fragments/archive-req.output.md +1 -1
  20. package/templates/fragments/archive-req.purpose.md +1 -1
  21. package/templates/fragments/check.behavior.md +1 -1
  22. package/templates/fragments/consume-point.behavior.md +13 -0
  23. package/templates/fragments/consume-point.output.md +1 -0
  24. package/templates/fragments/consume-point.purpose.md +1 -0
  25. package/templates/fragments/consume-point.triggers.md +5 -0
  26. package/templates/fragments/point.behavior.md +35 -0
  27. package/templates/fragments/point.output.md +1 -0
  28. package/templates/fragments/point.purpose.md +3 -0
  29. package/templates/fragments/point.triggers.md +6 -0
  30. package/templates/fragments/skill-scaffold.behavior.md +5 -2
  31. package/templates/fragments/write-req.behavior.md +11 -4
  32. package/templates/fragments/write-req.output.md +1 -1
  33. package/templates/fragments/write-req.purpose.md +1 -1
package/lib/status.js CHANGED
@@ -5,7 +5,8 @@ const path = require('node:path');
5
5
  const os = require('node:os');
6
6
 
7
7
  const { read, validate, defaultXskRoot, isSafePath, isInsideDir, validateOperationalSemantics } = require('./manifest');
8
- const { ALL_PLATFORMS, MARKER, rootFor } = require('./install');
8
+ const { ALL_PLATFORMS, MARKER, rootFor, commandsRootFor } = require('./install');
9
+ const { contentSha256 } = require('./content-hash');
9
10
 
10
11
  const STATES = ['not-installed', 'ok', 'drift', 'invalid'];
11
12
 
@@ -14,12 +15,30 @@ function expectedInstalledPathType(p) {
14
15
  if (base === 'SKILL.md' || base === MARKER) {
15
16
  return 'file';
16
17
  }
18
+ // A flat command file (commandsRoot/<name>.md) is an expected regular file, so
19
+ // a missing or no-longer-a-file command path reports drift.
20
+ if (base.endsWith('.md')) {
21
+ return 'file';
22
+ }
17
23
  return 'directory';
18
24
  }
19
25
 
20
26
  function recordedPathEntries(manifest) {
27
+ const installedHashes = new Map(
28
+ Array.isArray(manifest.installed_hashes)
29
+ ? manifest.installed_hashes.map((h) => [h.target, h.sha256])
30
+ : [],
31
+ );
21
32
  const installed = Array.isArray(manifest.installed_paths)
22
- ? manifest.installed_paths.map((p) => ({ path: p, expectedType: expectedInstalledPathType(p) }))
33
+ ? manifest.installed_paths.map((p) => {
34
+ const expectedType = expectedInstalledPathType(p);
35
+ const entry = { path: p, expectedType };
36
+ const expectedSha256 = installedHashes.get(p);
37
+ if (expectedType === 'file' && expectedSha256) {
38
+ entry.expectedSha256 = expectedSha256;
39
+ }
40
+ return entry;
41
+ })
23
42
  : [];
24
43
  const backups = Array.isArray(manifest.backups)
25
44
  ? manifest.backups.map((b) => ({ path: b.backup, target: b.target, expectedType: 'file', kind: 'backup' }))
@@ -43,6 +62,14 @@ function pathType(p) {
43
62
  }
44
63
  }
45
64
 
65
+ function matchesExpectedSha256(entry) {
66
+ try {
67
+ return contentSha256(fs.readFileSync(entry.path)) === entry.expectedSha256;
68
+ } catch (e) {
69
+ return false;
70
+ }
71
+ }
72
+
46
73
  function recordedPathHasDrift(entry, options) {
47
74
  const opts = options || {};
48
75
  const exists = opts.exists || ((p) => fs.existsSync(p));
@@ -51,6 +78,7 @@ function recordedPathHasDrift(entry, options) {
51
78
  if (!safe(entry.path, entry)) return true;
52
79
  if (!exists(entry.path, entry)) return true;
53
80
  if (typeOf && typeOf(entry.path, entry) !== entry.expectedType) return true;
81
+ if (entry.expectedSha256 && !matchesExpectedSha256(entry)) return true;
54
82
  return false;
55
83
  }
56
84
 
@@ -104,7 +132,8 @@ function computeStatus(options) {
104
132
  let skillsRoot = null;
105
133
  if (validate(manifest, { expectedPlatform: platform })) {
106
134
  skillsRoot = rootFor(platform, opts.platformRoots);
107
- const semantics = validateOperationalSemantics({ platform, skillsRoot, manifest });
135
+ const commandsRoot = commandsRootFor(platform, opts.platformCommandsRoots);
136
+ const semantics = validateOperationalSemantics({ platform, skillsRoot, commandsRoot, manifest });
108
137
  if (!semantics.valid) {
109
138
  result.platforms[platform] = { state: 'invalid', reason: semantics.reason };
110
139
  continue;
@@ -158,7 +187,7 @@ function render(result, options) {
158
187
  line += ` v${entry.version}`;
159
188
  }
160
189
  if (entry.missing && entry.missing.length) {
161
- line += `; ${entry.missing.length} recorded path(s) missing`;
190
+ line += `; ${entry.missing.length} recorded path(s) drifted`;
162
191
  }
163
192
  if (entry.reason) {
164
193
  line += ` - ${entry.reason}`;
package/lib/uninstall.js CHANGED
@@ -13,7 +13,9 @@ const {
13
13
  removeManifest,
14
14
  manifestPath,
15
15
  atomicWriteFile,
16
+ assertSafePath,
16
17
  isInsideDir,
18
+ isSafePath,
17
19
  } = require('./manifest');
18
20
  const { get } = require('./skills');
19
21
  const { buildSkill } = require('./generator');
@@ -124,10 +126,25 @@ function capturePathState(targetPath) {
124
126
  }
125
127
  }
126
128
 
129
+ // Rollback restore re-runs the same path-safety walk install/uninstall use
130
+ // everywhere else. The capture-to-restore window is fully synchronous, but a
131
+ // guarded delete/create here keeps the documented "no symlink traversal or
132
+ // removal" invariant true even if an ancestor were swapped under us: it fails
133
+ // closed (rollback reports an error) rather than following a symlink out of the
134
+ // owned roots. allowNonDirectoryTarget lets the target itself be a regular file
135
+ // (the common case); a symlinked target or any symlinked ancestor still throws.
127
136
  function removePathForRestore(targetPath) {
137
+ assertSafePath(targetPath, 'rollback restore target', { allowNonDirectoryTarget: true });
128
138
  fs.rmSync(targetPath, { recursive: true, force: true });
129
139
  }
130
140
 
141
+ function ensureRestoreParent(targetPath) {
142
+ const dir = path.dirname(targetPath);
143
+ assertSafePath(dir, 'rollback restore dir');
144
+ fs.mkdirSync(dir, { recursive: true });
145
+ assertSafePath(dir, 'rollback restore dir');
146
+ }
147
+
131
148
  function restorePathState(targetPath, state) {
132
149
  if (!state.exists) {
133
150
  removePathForRestore(targetPath);
@@ -137,6 +154,7 @@ function restorePathState(targetPath, state) {
137
154
  if (fs.existsSync(targetPath) && !fs.lstatSync(targetPath).isDirectory()) {
138
155
  removePathForRestore(targetPath);
139
156
  }
157
+ assertSafePath(targetPath, 'rollback restore dir');
140
158
  fs.mkdirSync(targetPath, { recursive: true, mode: state.mode });
141
159
  try {
142
160
  fs.chmodSync(targetPath, state.mode);
@@ -146,12 +164,17 @@ function restorePathState(targetPath, state) {
146
164
  return;
147
165
  }
148
166
  removePathForRestore(targetPath);
149
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
167
+ ensureRestoreParent(targetPath);
150
168
  if (state.type === 'symlink') {
169
+ assertSafePath(targetPath, 'rollback restore symlink', { allowNonDirectoryTarget: true });
151
170
  fs.symlinkSync(state.link, targetPath);
152
171
  return;
153
172
  }
154
173
  if (state.type === 'file') {
174
+ // writeFileSync follows a symlink at targetPath; re-assert it is not one
175
+ // (removePathForRestore cleared it, so this is normally an ENOENT no-op)
176
+ // before writing, then preserve the captured mode via writeFileSync+chmod.
177
+ assertSafePath(targetPath, 'rollback restore file', { allowNonDirectoryTarget: true });
155
178
  fs.writeFileSync(targetPath, state.content, { mode: state.mode });
156
179
  try {
157
180
  fs.chmodSync(targetPath, state.mode);
@@ -178,7 +201,9 @@ function createRollbackJournal() {
178
201
  };
179
202
  }
180
203
 
181
- function uninstallPlatform({ platform, xskRoot, skillsRoot }) {
204
+ function uninstallPlatform({ platform, xskRoot, skillsRoot, commandsRoot }) {
205
+ // Lazy require avoids a load-time cycle (install.js requires uninstall.js).
206
+ const { isCommandFilePath } = require('./install');
182
207
  let manifest;
183
208
  try {
184
209
  manifest = read(platform, { xskRoot });
@@ -226,7 +251,7 @@ function uninstallPlatform({ platform, xskRoot, skillsRoot }) {
226
251
  };
227
252
  }
228
253
 
229
- const sem = validateOperationalSemantics({ platform, skillsRoot, manifest });
254
+ const sem = validateOperationalSemantics({ platform, skillsRoot, commandsRoot, manifest });
230
255
  if (!sem.valid) {
231
256
  return {
232
257
  platform,
@@ -243,7 +268,12 @@ function uninstallPlatform({ platform, xskRoot, skillsRoot }) {
243
268
  }
244
269
 
245
270
  const backups = manifest.backups || [];
246
- const skillDirs = classifyPaths(manifest.installed_paths);
271
+ // Command files are flat, markerless `.md` files outside skillsRoot. Filter
272
+ // them out before classifyPaths / ownedDirs / validateBackupTargets so a flat
273
+ // command path is never synthesized into a <cmd>.md/SKILL.md skill triple.
274
+ const commandPaths = manifest.installed_paths.filter((p) => isCommandFilePath(p));
275
+ const skillInstalledPaths = manifest.installed_paths.filter((p) => !isCommandFilePath(p));
276
+ const skillDirs = classifyPaths(skillInstalledPaths);
247
277
  const backupTargets = validateBackupTargets(backups, skillDirs, skillsRoot);
248
278
  if (!backupTargets.valid) {
249
279
  return {
@@ -274,8 +304,9 @@ function uninstallPlatform({ platform, xskRoot, skillsRoot }) {
274
304
  let error = null;
275
305
  const manifestPaths = new Set(manifest.installed_paths);
276
306
 
307
+ const retainedCommands = [];
277
308
  const ownedDirs = new Set(
278
- manifest.installed_paths.filter((p) => {
309
+ skillInstalledPaths.filter((p) => {
279
310
  const base = path.basename(p);
280
311
  return base !== 'SKILL.md' && base !== MARKER;
281
312
  }),
@@ -493,13 +524,57 @@ function uninstallPlatform({ platform, xskRoot, skillsRoot }) {
493
524
  }
494
525
  }
495
526
 
496
- const retainedPaths = [...retainedDirs, ...retainedFiles, ...retainedMarkers];
527
+ // Dedicated command-file pass: remove a hash-matched (owned) command file,
528
+ // retain a hash-mismatched (user-edited) one, and prune command files for
529
+ // skills no longer installed. Command files carry no marker and no backup, so
530
+ // ownership rests solely on the recorded installed_hashes content match.
531
+ for (const commandPath of commandPaths) {
532
+ if (!isSafePath(commandPath, 'command file', { allowNonDirectoryTarget: true })) {
533
+ refused.push(commandPath);
534
+ retainedCommands.push(commandPath);
535
+ partial = true;
536
+ continue;
537
+ }
538
+ let stat = null;
539
+ try {
540
+ stat = fs.lstatSync(commandPath);
541
+ } catch (e) {
542
+ if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) {
543
+ stat = null;
544
+ } else {
545
+ throw e;
546
+ }
547
+ }
548
+ if (stat === null) {
549
+ // Already gone: drop it from the manifest (nothing to remove or retain).
550
+ continue;
551
+ }
552
+ if (!stat.isFile()) {
553
+ refused.push(commandPath);
554
+ retainedCommands.push(commandPath);
555
+ partial = true;
556
+ continue;
557
+ }
558
+ const recordedHash = installedHashes.get(commandPath);
559
+ const onDisk = fs.readFileSync(commandPath);
560
+ const owned = Boolean(recordedHash) && contentSha256(onDisk) === recordedHash;
561
+ if (!owned) {
562
+ retainedCommands.push(commandPath);
563
+ partial = true;
564
+ continue;
565
+ }
566
+ rollbackJournal.capture(commandPath);
567
+ fs.rmSync(commandPath, { force: true });
568
+ removed.push(commandPath);
569
+ }
570
+
571
+ const retainedPaths = [...retainedDirs, ...retainedFiles, ...retainedMarkers, ...retainedCommands];
497
572
  let narrowed = null;
498
573
  if (retainedPaths.length > 0 || retainedBackups.length > 0) {
499
574
  narrowed = create(platform, manifest.version);
500
575
  narrowed.installed_paths = retainedPaths;
501
576
  narrowed.backups = retainedBackups;
502
- narrowed.installed_hashes = retainedInstalledHashes(manifest, retainedFiles);
577
+ narrowed.installed_hashes = retainedInstalledHashes(manifest, [...retainedFiles, ...retainedCommands]);
503
578
  try {
504
579
  rollbackJournal.capture(manifestPath(platform, { xskRoot }));
505
580
  write(platform, narrowed, { xskRoot });
@@ -532,7 +607,7 @@ function uninstallPlatform({ platform, xskRoot, skillsRoot }) {
532
607
  platform,
533
608
  removed,
534
609
  restored,
535
- retained: retainedFiles,
610
+ retained: [...retainedFiles, ...retainedCommands],
536
611
  skipped,
537
612
  refused,
538
613
  partial,
@@ -549,13 +624,14 @@ function uninstall(options) {
549
624
  const opts = options || {};
550
625
  const platforms = opts.platforms || ['claude', 'codex', 'opencode', 'gemini'];
551
626
  const xskRoot = opts.xskRoot || defaultXskRoot();
552
- const { rootFor } = require('./install');
627
+ const { rootFor, commandsRootFor } = require('./install');
553
628
 
554
629
  const summary = { platforms: {} };
555
630
  let exitCode = 0;
556
631
  for (const platform of platforms) {
557
632
  const skillsRoot = rootFor(platform, opts.platformRoots);
558
- const res = uninstallPlatform({ platform, xskRoot, skillsRoot });
633
+ const commandsRoot = commandsRootFor(platform, opts.platformCommandsRoots);
634
+ const res = uninstallPlatform({ platform, xskRoot, skillsRoot, commandsRoot });
559
635
  summary.platforms[platform] = res;
560
636
  if (res.exitCode > exitCode) {
561
637
  exitCode = res.exitCode;
@@ -570,6 +646,8 @@ module.exports = {
570
646
  uninstallPlatform,
571
647
  classifyPaths,
572
648
  generatedContentFor,
649
+ removePathForRestore,
650
+ restorePathState,
573
651
  isSymlink,
574
652
  safeBackupForSkill,
575
653
  PARTIAL_EXIT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenonbyte/xsk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Agent skill aggregator. Install a curated set of agent skills across Claude Code, Codex, opencode, and Gemini with manifest-backed safety.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: xsk-archive-req
3
- description: Archive the active requirement document into requirements/archive/ and leave zero active docs.
3
+ description: Archive the active requirement document into .xsk/requirements/archive/ and leave zero active docs.
4
4
  ---
5
5
 
6
6
  # xsk-archive-req
7
7
 
8
- Archive the active requirement document into `requirements/archive/`. After it runs, zero active requirement docs remain.
8
+ Archive the active requirement document into `.xsk/requirements/archive/`. After it runs, zero active requirement docs remain.
9
9
 
10
10
  ## When to use
11
11
 
@@ -17,17 +17,22 @@ Match the intent, not the exact words. Common cues:
17
17
 
18
18
  ## How it works
19
19
 
20
- 1. Scan `requirements/*.md` (excluding `requirements/archive/`) for the single document whose frontmatter has `status: active`. If more than one exists, stop, list the offending paths, and report the broken invariant for the user to resolve.
20
+ 1. Scan `.xsk/requirements/*.md` (excluding `.xsk/requirements/archive/`) for the single document whose frontmatter has `status: active`. If more than one exists, stop, list the offending paths, and report the broken invariant for the user to resolve.
21
21
  2. If there is no active document, refuse with a one-line reason and stop. Do not archive anything.
22
22
  3. Read the active doc slug and validate it against `^[a-z0-9]+(-[a-z0-9]+)*$`. If the slug is missing/invalid, stop with the reason before any write.
23
- 4. Check the archive target `requirements/archive/<slug>.md`. If it already exists, stop, ask the user how to proceed, and leave it unchanged, writing nothing.
24
- 5. Write the fully-updated archived content to `requirements/archive/<slug>.md`: set `status: archived`, add `archived_at: <ISO date>`, and preserve every other frontmatter field and the entire body unchanged.
23
+ 4. Check the archive target `.xsk/requirements/archive/<slug>.md`. If it already exists, stop, ask the user how to proceed, and leave it unchanged, writing nothing.
24
+ 5. Write the fully-updated archived content to `.xsk/requirements/archive/<slug>.md`: set `status: archived`, add `archived_at: <ISO date>`, and preserve every other frontmatter field and the entire body unchanged.
25
25
  6. Confirm it landed as written, then remove the source active doc.
26
26
  7. After archiving, confirm zero active documents remain.
27
+ 8. Offer to commit the archival, git-aware. The archive copy lives under the git-ignored `requirements/archive/`, so from git's view the only change is the deletion of the source active doc. If the project is a git repository and that active doc was tracked (`git ls-files --error-unmatch <path>` succeeds), ask the user once whether to commit this archival, and act on the answer:
28
+ - On yes, commit only that deleted path. Stage the deletion first, then commit only that path: `git add -- <the removed active doc path> && git commit -m "docs(xsk): archive requirement <slug>" -- <the removed active doc path>`. Never `git add -A` or `git add .`.
29
+ - On no, leave the working tree as is and report that the deletion was left for the user to commit.
30
+
31
+ If the doc was untracked, or the project is not a git repository, skip the prompt and say nothing about committing.
27
32
 
28
33
  ## Output
29
34
 
30
- Either a one-line refusal that names the missing/invalid slug, a stop-and-ask response when `requirements/archive/<slug>.md` already exists and it was left unchanged, writing nothing, or the archived path (`requirements/archive/<slug>.md`) plus confirmation that it landed before the source active doc was removed and that no active requirement docs remain.
35
+ Either a one-line refusal that names the missing/invalid slug, a stop-and-ask response when `.xsk/requirements/archive/<slug>.md` already exists and it was left unchanged, writing nothing, or the archived path (`.xsk/requirements/archive/<slug>.md`) plus confirmation that it landed before the source active doc was removed and that no active requirement docs remain, and, when the removed doc was tracked by git, whether the user committed the archival or left it uncommitted.
31
36
 
32
37
  ## Conventions shared across xsk skills
33
38
 
@@ -45,7 +45,7 @@ Match the intent, not the exact words. Common cues:
45
45
 
46
46
  **6. Gate every finding on evidence.** A HIGH or CRITICAL finding needs three things: the exact file and line, the concrete trigger that produces the bad outcome, and why existing guards do not already prevent it. Missing any one, downgrade it or drop it. Do not pad the report with low-confidence noise that trains the reader to ignore the real findings.
47
47
 
48
- **7. Route the fixes.** Apply safe, risk-free fixes (typos, missing imports, obvious style) right away. Batch behavior-changing fixes (added null checks, new error handling) into one confirmation block instead of asking one at a time. Leave architecture and security tradeoffs for the user to decide, and mark informational notes as advisory.
48
+ **7. Route the fixes. Review-only by default.** Do not modify files during a review. List the safe, risk-free mechanical fixes (typos, missing imports, obvious style) separately from the findings, and apply them only when the user explicitly asks for fixes. Batch behavior-changing fixes (added null checks, new error handling) into one confirmation block instead of asking one at a time. Leave architecture and security tradeoffs for the user to decide, and mark informational notes as advisory.
49
49
 
50
50
  **8. Verify before claiming done.** Run the project's own verification (its tests, lint, type check, build, or syntax check) and read the output. For a bug fix, a regression test that fails on the old code must exist before the fix counts as done. If no verification command is available, say so plainly and call it a gap. Never present unverified work as passing.
51
51
 
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: xsk-consume-point
3
+ description: Fold selected .xsk/points into one .xsk/requirements doc via xsk-write-req, archiving consumed points write-before-remove and leaving unconsumed points active.
4
+ ---
5
+
6
+ # xsk-consume-point
7
+
8
+ Fold one or more researched point documents from `.xsk/points/` into a single requirement document via `xsk-write-req`. Points that land in the requirement are archived as `consumed`; the rest stay active or are dropped only on user confirmation.
9
+
10
+ ## When to use
11
+
12
+ Match the intent, not the exact words. Common cues:
13
+
14
+ - "把这些 point 变成需求", "消费 point", "把调研结果写成需求"
15
+ - "consume points", "turn points into a requirement", "fold points into a req"
16
+ - any request to convert accumulated point documents into a structured requirement document
17
+
18
+ ## How it works
19
+
20
+ **1. Scan and list available points.** Read all `.md` files under `.xsk/points/` excluding `.xsk/points/archive/`. For each, extract `slug`, `status`, and the `## Aspect` line. Display the list with status, highlighting `ready` entries as eligible. If no files exist, or all are already archived, stop with a one-line reason and do not proceed. If no unarchived point has `status: ready`, stop with a one-line reason, leave every point unchanged, and do not proceed.
21
+
22
+ **2. Have the user select.** Ask the user which `ready` points to fold into the requirement. Only points with `status: ready` may be selected. If the user selects or names any `researching` or otherwise non-ready point, stop without writing or archiving anything and say it must be completed by `xsk-point` first. If the user selects none, stop without writing anything.
23
+
24
+ **3. Guard the single-active requirement.** Scan `.xsk/requirements/*.md` (excluding `.xsk/requirements/archive/`) for frontmatter `status: active`. If one exists, prompt the user: append the selected points to the existing active requirement, or abort. If the user aborts, leave `.xsk/requirements/` and `.xsk/points/` entirely unchanged and stop. If more than one active requirement exists, stop, list the offending paths, and report the broken invariant for the user to resolve.
25
+
26
+ **4. Hand off to xsk-write-req.** Pass the selected `ready` points to `xsk-write-req` as the input need. Before the handoff, re-read each selected point and confirm `status: ready`; if any selected point is no longer ready, stop, archive nothing, and leave all points unchanged. For each selected point, supply the `## Aspect` and `## Landed plan` as the core input, with `## Research` as supporting context. Do not duplicate or modify the `xsk-write-req` behavior; invoke it by reference. If `xsk-write-req` stops for any reason (blocking decision, user abort, audit failure), archive nothing and leave all points unchanged.
27
+
28
+ **5. Archive folded points as consumed (write-before-remove).** After the requirement lands successfully, for each selected point that was folded in: set `status: consumed`, add `consumed_at: <ISO date>` and `consumed_by: .xsk/requirements/<slug>.md` to the frontmatter. Before writing, check the archive target `.xsk/points/archive/<slug>.md`: if it already exists, stop, ask the user how to proceed, and leave that point's source and archive unchanged rather than overwriting. Otherwise write the updated content to `.xsk/points/archive/<slug>.md`, confirm it landed, then remove `.xsk/points/<slug>.md`. Complete write-before-remove for every folded point before reporting done.
29
+
30
+ **6. Leave excluded and deferred points active.** Points not selected, or points the user deferred, stay in `.xsk/points/` unchanged with their current status.
31
+
32
+ **7. Drop consume-rejected points only on user confirmation.** If the user decides a point should be discarded rather than deferred, confirm before writing. On confirmation, set `status: dropped`, add `dropped_at: <ISO date>` and a one-line `dropped_reason`. Before writing, check the archive target `.xsk/points/archive/<slug>.md`: if it already exists, stop, ask the user how to proceed, and leave it unchanged, writing nothing. Otherwise write to `.xsk/points/archive/<slug>.md` (write-before-remove), then remove the source file.
33
+
34
+ ## Output
35
+
36
+ The path of the produced requirement document (or the existing active doc if appended), plus a summary of which points were archived as `consumed` and which remain active or were dropped.
37
+
38
+ ## Conventions shared across xsk skills
39
+
40
+ - Triggers are matched by intent, not by exact wording. The phrases listed under "When to use" are cues, not a required incantation.
41
+ - Write in natural, direct prose. No formulaic openers, no filler conclusions, no restating the request before you answer it.
42
+ - When a decision would change the implementation, surface it as a short question and let the user decide. Do not pick silently.
43
+ - These are instruction skills. They shape how work is approached, not what the agent is technically capable of.
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: xsk-point
3
+ description: Research one aspect into .xsk/points/ to a decision-complete landed plan using xsk-think discipline, then persist and manage it as a named point document.
4
+ ---
5
+
6
+ # xsk-point
7
+
8
+ Research one aspect of the current project to a decision-complete landed plan and persist it as a point document in `.xsk/points/`. The result is a concrete, slug-named file an implementer can consume without re-doing the research.
9
+
10
+ It is research-and-persist only. It writes no code, no scaffolding, and no requirement document.
11
+
12
+ ## When to use
13
+
14
+ Match the intent, not the exact words. Common cues:
15
+
16
+ - "研究一下", "调研一下", "先搞清楚", "这块需要研究"
17
+ - "spike this", "research this", "figure out the approach for", "investigate"
18
+ - any request to explore and nail down one aspect before writing a requirement or starting implementation
19
+ - a slug argument that names an existing point (triggers a refinement of that point, not a new one)
20
+
21
+ ## How it works
22
+
23
+ **1. Ground in the current project.** Read the relevant code, config, and docs before forming any opinion. Never rely on memory for project-specific facts. Identify the one aspect under investigation and state it as a single sentence.
24
+
25
+ **2. Research with xsk-think's discipline.** Apply the same depth-matching, option-weighing, and premise-collapse discipline that `xsk-think` uses: match depth to the problem (lightweight, evaluation, or triage), declare premise collapse explicitly if one assumption invalidates everything, and reach a decision-complete landed plan. Do not stop at "it depends" - resolve the options and commit to one. If a blocking decision can only be resolved by the user, surface it as a single question before writing anything.
26
+
27
+ **3. Re-invoke-by-slug to refine.** If the user supplies a slug and `.xsk/points/<slug>.md` already exists, load it, treat the existing `## Research` and `## Landed plan` as prior context, and refine in place rather than starting over. Update the file with the refined content; do not create a duplicate.
28
+
29
+ **4. Persist the point document.** Write `.xsk/points/<slug>.md` where `<slug>` is generated from the aspect title using the pattern `^[a-z0-9]+(-[a-z0-9]+)*$`. Set `status: researching` while work is in progress; promote to `status: ready` only when the `## Landed plan` is decision-complete and the user confirms.
30
+
31
+ Point document schema:
32
+
33
+ ```markdown
34
+ ---
35
+ status: researching | ready
36
+ slug: <slug>
37
+ created_at: <ISO date>
38
+ ---
39
+
40
+ # <title>
41
+
42
+ ## Aspect
43
+
44
+ One-sentence statement of the aspect under investigation.
45
+
46
+ ## Research
47
+
48
+ Evidence gathered, options considered, and tradeoffs weighed. Grounded in project reality.
49
+
50
+ ## Landed plan
51
+
52
+ The concrete, decision-complete outcome. No "TBD". No open forks.
53
+ ```
54
+
55
+ **5. Ensure the gitignore line.** Ensure `.xsk/.gitignore` contains the line `points/archive/` (relative to `.xsk/`). Create `.xsk/` and `.xsk/.gitignore` if absent; append the line only if it is missing; never overwrite an existing `.xsk/.gitignore`.
56
+
57
+ **6. Drop only on user confirmation.** If the user asks to drop a point, confirm before writing. On confirmation, set `status: dropped`, add `dropped_at: <ISO date>` and a one-line `dropped_reason` to the frontmatter. Before writing, check the archive target `.xsk/points/archive/<slug>.md`: if it already exists, stop, ask the user how to proceed, and leave everything unchanged, writing nothing. Otherwise move the file to `.xsk/points/archive/<slug>.md` using write-before-remove (write the archive copy first, confirm it landed, then remove the source).
58
+
59
+ ## Output
60
+
61
+ The path of the point document and its current `status`: active points (`researching`, `ready`) are at `.xsk/points/<slug>.md`; dropped points are at `.xsk/points/archive/<slug>.md`.
62
+
63
+ ## Conventions shared across xsk skills
64
+
65
+ - Triggers are matched by intent, not by exact wording. The phrases listed under "When to use" are cues, not a required incantation.
66
+ - Write in natural, direct prose. No formulaic openers, no filler conclusions, no restating the request before you answer it.
67
+ - When a decision would change the implementation, surface it as a short question and let the user decide. Do not pick silently.
68
+ - These are instruction skills. They shape how work is approached, not what the agent is technically capable of.
@@ -32,11 +32,14 @@ Match the intent, not the exact words. Common cues:
32
32
  - Unknown options fail loud.
33
33
  - `status` validates manifest **shape**, not just parse success.
34
34
  - Removed or renamed commands leave no stale references (grep-clean across CLI, help, README, generated text, `AGENTS.md`, `CLAUDE.md`).
35
- - Four platforms, all full: Claude Code, Codex, opencode, Gemini.
36
- - Manifest-backed install safety: owned-only removal, ownership markers, atomic writes, symlink refusal.
35
+ - Four platforms covered: Claude Code, Codex, opencode, Gemini.
36
+ - User-invocable on every platform, not merely present. Installing a skill must make it invocable on each target platform. Platforms that do not auto-expose skill files as slash commands need a verified platform-specific invocation artifact carrying the skill body and the platform's argument placeholder so invocation arguments are not dropped. This project currently implements that artifact for opencode as a command file; do not claim it for another platform until adapter, install, uninstall, status, docs, and tests all cover that platform. Claude exposes skill files directly. Verify per platform rather than assuming.
37
+ - Manifest-backed install safety: owned-only removal, ownership markers, atomic writes, symlink refusal, and content-hash modification detection. The manifest records a hash per owned file; a previously generated install is recognized by that hash even without the marker (markerless detection), so a user-edited owned file is detected and refused or rolled back rather than silently overwritten.
37
38
  - `install` is uninstall-first: a reinstall resets the previously-owned files (pruning skills no longer installed) before regenerating, so no manual `uninstall` is needed. It still refuses to overwrite a user-edited owned file and rolls back instead of destroying it.
39
+ - Built from source: skills are generated from a single per-skill source, not hand-maintained per platform or per file. The reference composes each skill from per-section fragments (`purpose`, `triggers`, `behavior`, `output`) plus a shared common body and a template, with one registry listing the skills. The build is deterministic, and the committed packed skill output stays byte-for-byte in sync with the generator, enforced by a test. The golden snapshot below is the masked form of that output.
38
40
  - A golden snapshot of the generated skill shell, masking embedded `shared/` body.
39
41
  - A bilingual README (`README.md` plus `README.zh-CN.md`) with identical headings, English literals preserved, and content-pinning tests.
42
+ - Test coverage spans the install surface, not only generation and docs: install, uninstall, uninstall-first reset, transactional rollback, and the safety refusals (a user-edited owned file, symlinked paths) are exercised by executable tests, alongside the golden snapshot, README parity, and self-conformance tests.
40
43
 
41
44
  ### Self-conformance
42
45
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: xsk-write-req
3
- description: Turn plain-language needs into a grounded requirement document in requirements/, with a self-audit gate before it is finalized.
3
+ description: Turn plain-language needs into a grounded requirement document in .xsk/requirements/, with a self-audit gate before it is finalized.
4
4
  ---
5
5
 
6
6
  # xsk-write-req
7
7
 
8
- Convert plain-language ("白话") needs into a compliant requirement document in `requirements/`, grounded in the current project's actual code. The output is a doc an implementer can act on without guessing, not a transcript of the request.
8
+ Convert plain-language ("白话") needs into a compliant requirement document in `.xsk/requirements/`, grounded in the current project's actual code. The output is a doc an implementer can act on without guessing, not a transcript of the request.
9
9
 
10
10
  ## When to use
11
11
 
@@ -19,16 +19,17 @@ Match the intent, not the exact words. Common cues:
19
19
 
20
20
  **1. Read the project first.** `grep` and `read` the current project structure, config files (`package.json` and the like), and relevant code to ground the requirement in reality. Never quote defaults from memory.
21
21
 
22
- **2. Locate or create the active requirement doc.** Scan `requirements/*.md` for frontmatter `status: active`. There is **at most one** active doc at a time. If more than one exists, stop, list the offending paths, and report the broken invariant for the user to resolve. If one exists, lock onto it and append or refine. If none exists, create `requirements/<slug>.md` with `status: active` and a slug generated from the need.
22
+ **2. Locate or create the active requirement doc.** Scan `.xsk/requirements/*.md` for frontmatter `status: active`. There is **at most one** active doc at a time. If more than one exists, stop, list the offending paths, and report the broken invariant for the user to resolve. If one exists, lock onto it and append or refine. If none exists, create `.xsk/requirements/<slug>.md` with `status: active` and a slug generated from the need.
23
23
 
24
- **3. Ensure the directory convention.** Auto-create `requirements/.gitignore` containing `archive/` if it is absent (creating `requirements/` and `requirements/archive/` as needed). Never overwrite an existing `.gitignore`; append `archive/` only if the line is missing.
24
+ **3. Ensure the directory convention.** Ensure `.xsk/.gitignore` contains the line `requirements/archive/` (create `.xsk/` and `.xsk/.gitignore` if absent; append only if the line is missing; never overwrite an existing `.xsk/.gitignore`).
25
25
 
26
26
  **4. Convert fuzzy into concrete.** Match the document structure to the input richness:
27
27
 
28
28
  - A minimal one-liner becomes a concise requirement: Goal, Scope, Acceptance. Do not force a Background section that would be filler.
29
29
  - Richer input becomes Background, Goal, Scope (in and out), Requirements, Open Questions, Checkpoints.
30
+ - Open Questions holds only non-blocking items, and each one names its disposition: escalate it to the user to decide (step 5), or defer it to a named owner at a named checkpoint. A choice that would fork the implementation is never parked here. Resolve it inline, or raise it as a decision point.
30
31
 
31
- **5. Decision points go to the user.** When a genuine technical or scoping choice would change the implementation, stop and ask the user to decide. Surface the options and the tradeoffs; let them pick. Do not pick silently.
32
+ **5. Decision points go to the user, resolved one at a time.** When a genuine technical or scoping choice would change the implementation, stop and ask the user to decide: surface the options and the tradeoffs, let them pick, do not pick silently. When several genuine decisions exist, take them in dependency order rather than all at once. Surface the most upstream open one first, fold the answer in, then re-derive what remains before surfacing the next. A resolved decision often removes or reshapes the forks beneath it, so never surface a fork whose options still depend on an open decision.
32
33
 
33
34
  **6. Brainstorm the genuinely open-ended.** Where the need is open-ended, run a short exploration with the user before writing.
34
35
 
@@ -38,8 +39,9 @@ Match the intent, not the exact words. Common cues:
38
39
 
39
40
  - **Conflict check:** verify no internal contradictions (Goal versus Scope, in-scope versus out-of-scope, Requirements versus Open Questions, any two statements that cannot both hold). Resolve every conflict, or surface it to the user. A finalized doc contains zero conflicts.
40
41
  - **Ambiguity check:** verify no undefined terms, unstated assumptions, or vague qualifiers ("fast", "supported", "as needed") that would force the implementer to guess. Tighten each to a concrete, testable statement. A finalized doc leaves no ambiguity that blocks implementation.
42
+ - **Open-questions check:** verify every Open Question is non-blocking and names a disposition (escalated to the user, or deferred to a named owner at a named checkpoint). Resolve or raise anything that would block or fork implementation before finalizing. A finalized doc parks no blocking or unowned question.
41
43
  - **Checkpoint gates:** ensure the requirement defines the verification points where downstream implementation must pause and confirm against the requirement (acceptance criteria, integration gates, review gates). If it lacks them, add them before finalizing.
42
- - If the audit surfaces issues only the user can resolve, stop, list them, and ask. Do not write a contradictory or under-specified doc.
44
+ The audit is a loop, not a single pass. If it surfaces issues only the user can resolve, take them one at a time in dependency order (as in step 5), then re-run the checks before finalizing. Repeat until one full pass finds zero conflicts, zero blocking ambiguity, and nothing left for the user to resolve. Do not write a contradictory or under-specified doc.
43
45
 
44
46
  The document frontmatter:
45
47
 
@@ -51,9 +53,14 @@ created_at: <ISO date>
51
53
  ---
52
54
  ```
53
55
 
56
+ **9. Offer to commit, git-aware.** Only after the doc passes the self-audit and is finalized, decide whether to offer a commit. If the project is not a git repository (`git rev-parse --is-inside-work-tree` fails), or the requirement doc is byte-identical to what git already tracks, skip silently and say nothing about committing. Otherwise ask the user once whether to commit this requirement, and act on the answer:
57
+
58
+ - On yes, commit only the paths this run wrote: the requirement doc, plus `.xsk/.gitignore` if this run created it. Stage those exact paths first, then commit only them: `git add -- <those paths> && git commit -m "docs(xsk): write requirement <slug>" -- <those paths>`. Stage first because a bare `git commit -- <path>` rejects an untracked new doc. Never `git add -A` or `git add .`, so unrelated working-tree changes are never swept in.
59
+ - On no, leave the doc uncommitted and report that it was left for the user to commit.
60
+
54
61
  ## Output
55
62
 
56
- The path of the requirement file, with confirmation that it is now the single active doc.
63
+ The path of the requirement file, with confirmation that it is now the single active doc, and, when a commit was offered, whether the user committed it or left it uncommitted.
57
64
 
58
65
  ## Conventions shared across xsk skills
59
66
 
@@ -1,7 +1,12 @@
1
- 1. Scan `requirements/*.md` (excluding `requirements/archive/`) for the single document whose frontmatter has `status: active`. If more than one exists, stop, list the offending paths, and report the broken invariant for the user to resolve.
1
+ 1. Scan `.xsk/requirements/*.md` (excluding `.xsk/requirements/archive/`) for the single document whose frontmatter has `status: active`. If more than one exists, stop, list the offending paths, and report the broken invariant for the user to resolve.
2
2
  2. If there is no active document, refuse with a one-line reason and stop. Do not archive anything.
3
3
  3. Read the active doc slug and validate it against `^[a-z0-9]+(-[a-z0-9]+)*$`. If the slug is missing/invalid, stop with the reason before any write.
4
- 4. Check the archive target `requirements/archive/<slug>.md`. If it already exists, stop, ask the user how to proceed, and leave it unchanged, writing nothing.
5
- 5. Write the fully-updated archived content to `requirements/archive/<slug>.md`: set `status: archived`, add `archived_at: <ISO date>`, and preserve every other frontmatter field and the entire body unchanged.
4
+ 4. Check the archive target `.xsk/requirements/archive/<slug>.md`. If it already exists, stop, ask the user how to proceed, and leave it unchanged, writing nothing.
5
+ 5. Write the fully-updated archived content to `.xsk/requirements/archive/<slug>.md`: set `status: archived`, add `archived_at: <ISO date>`, and preserve every other frontmatter field and the entire body unchanged.
6
6
  6. Confirm it landed as written, then remove the source active doc.
7
7
  7. After archiving, confirm zero active documents remain.
8
+ 8. Offer to commit the archival, git-aware. The archive copy lives under the git-ignored `requirements/archive/`, so from git's view the only change is the deletion of the source active doc. If the project is a git repository and that active doc was tracked (`git ls-files --error-unmatch <path>` succeeds), ask the user once whether to commit this archival, and act on the answer:
9
+ - On yes, commit only that deleted path. Stage the deletion first, then commit only that path: `git add -- <the removed active doc path> && git commit -m "docs(xsk): archive requirement <slug>" -- <the removed active doc path>`. Never `git add -A` or `git add .`.
10
+ - On no, leave the working tree as is and report that the deletion was left for the user to commit.
11
+
12
+ If the doc was untracked, or the project is not a git repository, skip the prompt and say nothing about committing.
@@ -1 +1 @@
1
- Either a one-line refusal that names the missing/invalid slug, a stop-and-ask response when `requirements/archive/<slug>.md` already exists and it was left unchanged, writing nothing, or the archived path (`requirements/archive/<slug>.md`) plus confirmation that it landed before the source active doc was removed and that no active requirement docs remain.
1
+ Either a one-line refusal that names the missing/invalid slug, a stop-and-ask response when `.xsk/requirements/archive/<slug>.md` already exists and it was left unchanged, writing nothing, or the archived path (`.xsk/requirements/archive/<slug>.md`) plus confirmation that it landed before the source active doc was removed and that no active requirement docs remain, and, when the removed doc was tracked by git, whether the user committed the archival or left it uncommitted.
@@ -1 +1 @@
1
- Archive the active requirement document into `requirements/archive/`. After it runs, zero active requirement docs remain.
1
+ Archive the active requirement document into `.xsk/requirements/archive/`. After it runs, zero active requirement docs remain.
@@ -24,6 +24,6 @@
24
24
 
25
25
  **6. Gate every finding on evidence.** A HIGH or CRITICAL finding needs three things: the exact file and line, the concrete trigger that produces the bad outcome, and why existing guards do not already prevent it. Missing any one, downgrade it or drop it. Do not pad the report with low-confidence noise that trains the reader to ignore the real findings.
26
26
 
27
- **7. Route the fixes.** Apply safe, risk-free fixes (typos, missing imports, obvious style) right away. Batch behavior-changing fixes (added null checks, new error handling) into one confirmation block instead of asking one at a time. Leave architecture and security tradeoffs for the user to decide, and mark informational notes as advisory.
27
+ **7. Route the fixes. Review-only by default.** Do not modify files during a review. List the safe, risk-free mechanical fixes (typos, missing imports, obvious style) separately from the findings, and apply them only when the user explicitly asks for fixes. Batch behavior-changing fixes (added null checks, new error handling) into one confirmation block instead of asking one at a time. Leave architecture and security tradeoffs for the user to decide, and mark informational notes as advisory.
28
28
 
29
29
  **8. Verify before claiming done.** Run the project's own verification (its tests, lint, type check, build, or syntax check) and read the output. For a bug fix, a regression test that fails on the old code must exist before the fix counts as done. If no verification command is available, say so plainly and call it a gap. Never present unverified work as passing.
@@ -0,0 +1,13 @@
1
+ **1. Scan and list available points.** Read all `.md` files under `.xsk/points/` excluding `.xsk/points/archive/`. For each, extract `slug`, `status`, and the `## Aspect` line. Display the list with status, highlighting `ready` entries as eligible. If no files exist, or all are already archived, stop with a one-line reason and do not proceed. If no unarchived point has `status: ready`, stop with a one-line reason, leave every point unchanged, and do not proceed.
2
+
3
+ **2. Have the user select.** Ask the user which `ready` points to fold into the requirement. Only points with `status: ready` may be selected. If the user selects or names any `researching` or otherwise non-ready point, stop without writing or archiving anything and say it must be completed by `xsk-point` first. If the user selects none, stop without writing anything.
4
+
5
+ **3. Guard the single-active requirement.** Scan `.xsk/requirements/*.md` (excluding `.xsk/requirements/archive/`) for frontmatter `status: active`. If one exists, prompt the user: append the selected points to the existing active requirement, or abort. If the user aborts, leave `.xsk/requirements/` and `.xsk/points/` entirely unchanged and stop. If more than one active requirement exists, stop, list the offending paths, and report the broken invariant for the user to resolve.
6
+
7
+ **4. Hand off to xsk-write-req.** Pass the selected `ready` points to `xsk-write-req` as the input need. Before the handoff, re-read each selected point and confirm `status: ready`; if any selected point is no longer ready, stop, archive nothing, and leave all points unchanged. For each selected point, supply the `## Aspect` and `## Landed plan` as the core input, with `## Research` as supporting context. Do not duplicate or modify the `xsk-write-req` behavior; invoke it by reference. If `xsk-write-req` stops for any reason (blocking decision, user abort, audit failure), archive nothing and leave all points unchanged.
8
+
9
+ **5. Archive folded points as consumed (write-before-remove).** After the requirement lands successfully, for each selected point that was folded in: set `status: consumed`, add `consumed_at: <ISO date>` and `consumed_by: .xsk/requirements/<slug>.md` to the frontmatter. Before writing, check the archive target `.xsk/points/archive/<slug>.md`: if it already exists, stop, ask the user how to proceed, and leave that point's source and archive unchanged rather than overwriting. Otherwise write the updated content to `.xsk/points/archive/<slug>.md`, confirm it landed, then remove `.xsk/points/<slug>.md`. Complete write-before-remove for every folded point before reporting done.
10
+
11
+ **6. Leave excluded and deferred points active.** Points not selected, or points the user deferred, stay in `.xsk/points/` unchanged with their current status.
12
+
13
+ **7. Drop consume-rejected points only on user confirmation.** If the user decides a point should be discarded rather than deferred, confirm before writing. On confirmation, set `status: dropped`, add `dropped_at: <ISO date>` and a one-line `dropped_reason`. Before writing, check the archive target `.xsk/points/archive/<slug>.md`: if it already exists, stop, ask the user how to proceed, and leave it unchanged, writing nothing. Otherwise write to `.xsk/points/archive/<slug>.md` (write-before-remove), then remove the source file.
@@ -0,0 +1 @@
1
+ The path of the produced requirement document (or the existing active doc if appended), plus a summary of which points were archived as `consumed` and which remain active or were dropped.
@@ -0,0 +1 @@
1
+ Fold one or more researched point documents from `.xsk/points/` into a single requirement document via `xsk-write-req`. Points that land in the requirement are archived as `consumed`; the rest stay active or are dropped only on user confirmation.
@@ -0,0 +1,5 @@
1
+ Match the intent, not the exact words. Common cues:
2
+
3
+ - "把这些 point 变成需求", "消费 point", "把调研结果写成需求"
4
+ - "consume points", "turn points into a requirement", "fold points into a req"
5
+ - any request to convert accumulated point documents into a structured requirement document