@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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 (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +606 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +88 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,207 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The apply ORCHESTRATION — the ordering, stamping and accounting shared by
5
+ * every provider's `spec-sync apply`, with the tracker calls themselves left
6
+ * to provider `ops` (deferred to this shape until two providers existed to
7
+ * validate it: one folds workflow state into its create/update payloads, the
8
+ * other must move status by a separate transition call — so state application
9
+ * lives inside the ops, and the skeleton owns only what is identical).
10
+ *
11
+ * What the skeleton owns, in order:
12
+ *
13
+ * 1. resolve every workflow state BEFORE the first write, so a bad
14
+ * `config.states` value cannot strand the spec mid-apply;
15
+ * 2. the twin-mint guard: an EXACT-title issue held by no spec is a positive
16
+ * signal the issue already exists — refuse to mint a second (a fuzzy
17
+ * match must never count: being wrong here blocks a legitimate push);
18
+ * 3. mint-or-update the spec issue, stamping the identifier the moment the
19
+ * create lands — an interrupt after that point must not mint a twin;
20
+ * 4. sub-issue creates, each STAMPED AS IT LANDS, then updates (keyed by
21
+ * ref, never by id — the read-back matches by ref);
22
+ * 5. read back what the tracker stored and run the same check `verify` runs;
23
+ * 6. record the snapshot from the now-stamped files, so the next push is
24
+ * empty.
25
+ *
26
+ * Orphan accounting rides the whole way: `created` is what the tracker holds,
27
+ * `stamped` what the repo recorded, and the one case a re-run makes WORSE is a
28
+ * create whose stamp could not be written — the identifiers travel out on the
29
+ * error so the caller can name them.
30
+ *
31
+ * @param {object} ops the provider's tracker calls:
32
+ * - `prevalidateStates(buckets)` throw early on a bad state name
33
+ * - `mintIssue({ title, issue })` → `{ id, identifier, url }`
34
+ * - `updateIssue(existing, issue)` apply `plan.issue` to a live issue
35
+ * - `mintSubIssue(parentId, sub)` → `{ id, identifier }`
36
+ * - `updateSubIssue(sub)` apply a sub-issue update
37
+ * - `remoteEditedLine(identifier)` the someone-else-edited warning
38
+ * - `twinAdvice(twin, title, specName)` the refusal message for a twin
39
+ */
40
+
41
+ const path = require('node:path')
42
+
43
+ const {
44
+ readSnapshot,
45
+ readBase,
46
+ projectionOf,
47
+ recordPush,
48
+ writeFrontmatter,
49
+ stampSubIssueId,
50
+ fieldsOf,
51
+ remoteDescriptionEdited,
52
+ remoteSubIssueEdited,
53
+ listPhaseFiles,
54
+ } = require('../../sync-core')
55
+ const { makeSpecHelpers } = require('./specs.js')
56
+
57
+ /**
58
+ * The outer accounting wrapper: run the inner apply, and on ANY throw attach
59
+ * what had been stamped versus created by the time it happened.
60
+ */
61
+ async function applySpecPlan(args) {
62
+ const progress = {}
63
+ try {
64
+ return await applyInner({ ...args, progress })
65
+ } catch (error) {
66
+ const r = progress.result
67
+ if (error && typeof error === 'object' && error.stamped === undefined) {
68
+ error.stamped = r ? (r.issue ? 1 : 0) + Object.keys(r.subIssues || {}).length : 0
69
+ error.created = progress.created || []
70
+ error.orphans = error.created.slice(error.stamped)
71
+ }
72
+ throw error
73
+ }
74
+ }
75
+
76
+ async function applyInner({ dir, config, snapshotDir, plan, adapter, ops, progress, forceNew = false, program = 'spec-sync' }) {
77
+ const fields = fieldsOf(config)
78
+ const helpers = makeSpecHelpers({ program })
79
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
80
+ const identifier = helpers.linkedIdentifier(path.join(snapshotDir, overviewFile), config)
81
+ const lines = []
82
+ const result = { issue: null, subIssues: {} }
83
+ // Handed out immediately, so a throw from anywhere below still reports what
84
+ // had been stamped by the time it happened.
85
+ if (progress) progress.result = result
86
+
87
+ // 1. Every state resolved BEFORE the first write.
88
+ const wanted = new Set()
89
+ if (plan.issue && plan.issue.state) wanted.add(plan.issue.state)
90
+ for (const s of (plan.subIssues && plan.subIssues.create) || []) if (s.state) wanted.add(s.state)
91
+ for (const s of (plan.subIssues && plan.subIssues.update) || []) if (s.state) wanted.add(s.state)
92
+ ops.prevalidateStates([...wanted])
93
+
94
+ let parentId = null
95
+ if (!identifier) {
96
+ // 2. REFUSE TO MINT A TWIN. An EXACT match held by no spec is a positive
97
+ // signal that the issue already exists; a near-match must never count —
98
+ // being wrong here blocks a legitimate push, and a renamed spec is
99
+ // answered by adopting the issue, not by fuzz.
100
+ if (!forceNew && adapter.searchIssues) {
101
+ const title = String(plan.title || readSnapshot(snapshotDir, config).title || '').trim()
102
+ if (title) {
103
+ let found = []
104
+ try {
105
+ found = (await ops.searchTwin(title)) || []
106
+ } catch {
107
+ // A search we could not run is not evidence of an orphan. Carry on
108
+ // and mint — the cannot-tell case goes to the harmless branch, which
109
+ // here is the one that does the work the caller asked for.
110
+ found = []
111
+ }
112
+ const claimed = new Set(helpers.listSpecs(dir, config).map((sp) => sp.identifier).filter(Boolean))
113
+ const twin = found.find(
114
+ (i) => i && i.identifier && !claimed.has(i.identifier) && String(i.title || '').trim() === title,
115
+ )
116
+ if (twin) throw new Error(ops.twinAdvice(twin, title, path.basename(snapshotDir)))
117
+ }
118
+ }
119
+ // 3. Mint, then stamp IMMEDIATELY: noted before it is stamped, so a stamp
120
+ // that cannot be written can still name the identifier it strands.
121
+ const created = await ops.mintIssue({ title: readSnapshot(snapshotDir, config).title, issue: plan.issue })
122
+ parentId = created.id
123
+ if (progress) (progress.created = progress.created || []).push(created.identifier)
124
+ writeFrontmatter(snapshotDir, config, {
125
+ [fields.identifier]: created.identifier,
126
+ [fields.url]: created.url,
127
+ })
128
+ result.issue = { id: created.id, identifier: created.identifier, url: created.url }
129
+ lines.push(` issue created: ${created.identifier}`)
130
+ } else {
131
+ const existing = await adapter.readIssue(identifier)
132
+ if (!existing || !existing.id) throw new Error(ops.missingIssueMessage(identifier))
133
+ parentId = existing.id
134
+ result.issue = { id: existing.id, identifier: existing.identifier, url: existing.url }
135
+
136
+ // SOMEONE ELSE EDITED THE DESCRIPTION SINCE THE LAST PUSH, and this write
137
+ // replaces it. A WARNING AND NEVER A REFUSAL: one-way sync means the repo
138
+ // wins; what was wrong was winning silently.
139
+ if (remoteDescriptionEdited(readBase(dir, identifier, config), existing, config) === true) {
140
+ lines.push(ops.remoteEditedLine(identifier))
141
+ }
142
+ if (plan.issue) {
143
+ await ops.updateIssue(existing, plan.issue)
144
+ lines.push(` issue updated: ${identifier}`)
145
+ }
146
+ }
147
+
148
+ // 4. Sub-issue creates — each stamped as it lands, for the same reason.
149
+ for (const sub of (plan.subIssues && plan.subIssues.create) || []) {
150
+ const created = await ops.mintSubIssue(parentId, sub)
151
+ if (progress) (progress.created = progress.created || []).push(created.identifier)
152
+ const file = resolvePhaseFileIn(snapshotDir, sub.ref)
153
+ if (!file) throw new Error(`no phase file for ref ${sub.ref}`)
154
+ stampSubIssueId(snapshotDir, file, created.identifier, config)
155
+ result.subIssues[sub.ref] = created.identifier
156
+ lines.push(` sub-issue created: ${sub.ref} → ${created.identifier}`)
157
+ }
158
+
159
+ // 5. Sub-issue updates — keyed by REF, never by id: the read-back matches
160
+ // sub-issues to phases by ref, and a plan written before updates carried a
161
+ // ref still resolves — by id, off the projection.
162
+ const refById = new Map()
163
+ for (const s of projectionOf(snapshotDir, config).subIssues || []) {
164
+ if (s.id != null) refById.set(String(s.id), s.ref)
165
+ }
166
+ // A phase body edited on the tracker gets the same warning as the spec
167
+ // issue. The read is spent only where the snapshot recorded something to
168
+ // compare against, so an old snapshot costs no extra calls.
169
+ const base = readBase(dir, identifier, config)
170
+ for (const sub of (plan.subIssues && plan.subIssues.update) || []) {
171
+ const streams = base && base.subIssueStreams
172
+ if (sub.goal != null && streams && streams[String(sub.id)]) {
173
+ const before = await adapter.readIssue(sub.id)
174
+ if (remoteSubIssueEdited(base, sub.id, before, config) === true) lines.push(ops.remoteEditedLine(sub.id))
175
+ }
176
+ await ops.updateSubIssue(sub)
177
+ result.subIssues[sub.ref || refById.get(String(sub.id)) || sub.id] = sub.id
178
+ lines.push(` sub-issue updated: ${sub.id}`)
179
+ }
180
+
181
+ // 6. Read back what the tracker stored and run the SAME check verify runs.
182
+ const stored = { issue: undefined, subIssues: {} }
183
+ if (result.issue) {
184
+ const back = await adapter.readIssue(result.issue.id)
185
+ if (back && typeof back.description === 'string') stored.issue = back.description
186
+ }
187
+ for (const [ref, id] of Object.entries(result.subIssues)) {
188
+ const back = await adapter.readIssue(id)
189
+ if (back && typeof back.description === 'string') stored.subIssues[ref] = back.description
190
+ }
191
+ const verify = helpers.verifyLines(snapshotDir, config, stored, result.issue ? result.issue.identifier : identifier)
192
+ lines.push(...verify.map((l) => ` ${l}`))
193
+ const lost = verify.some((l) => l.includes('!!') || l.includes('??'))
194
+
195
+ // 7. Record the snapshot from the now-stamped files, so the next push is empty.
196
+ const file = recordPush({ dir, snapshotDir, identifier: helpers.specIdentifier(snapshotDir, config), config })
197
+ lines.push(` snapshot: ${path.relative(dir, file)}`)
198
+ return { result, lines, lost }
199
+ }
200
+
201
+ // The phase file a sub-issue ref names — the ref is the phase-file basename.
202
+ function resolvePhaseFileIn(snapshotDir, ref) {
203
+ const want = String(ref).replace(/\.md$/, '')
204
+ return listPhaseFiles(snapshotDir).find((f) => f.replace(/\.md$/, '') === want) || null
205
+ }
206
+
207
+ module.exports = { applySpecPlan }
@@ -0,0 +1,68 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * A table-driven flag parser for a provider CLI, carrying the one discipline
5
+ * the first provider learned the hard way: an UNKNOWN `--flag` is a refusal,
6
+ * never a silent positional. (A renamed flag once parsed as a no-op — the
7
+ * repair invocation ran the wrong command, ignored the flag, and exited 0.)
8
+ *
9
+ * The table maps `--flag` → spec:
10
+ * - `{ kind: 'boolean' }` sets `flags[name] = true`
11
+ * - `{ kind: 'value' }` takes the next token verbatim
12
+ * - `{ kind: 'string' }` takes the next token, trimmed ('' kept)
13
+ * - `{ kind: 'number' }` Number(next token)
14
+ * - `{ kind: 'path' }` path.resolve(next token)
15
+ * - `{ kind: 'list' }` repeatable; pushes trimmed tokens
16
+ * - `{ kind: 'custom', take, handle }` `handle(flags, value, context)`;
17
+ * `take: false` consumes no token
18
+ * - any spec may set `name` (defaults to the camelCased flag)
19
+ *
20
+ * A spec name never starts with `--`, so the refusal cannot swallow a real
21
+ * argument. `renamed` maps an old flag to advice lines appended to the refusal,
22
+ * so a rename hands off instead of leaving the caller to guess.
23
+ */
24
+
25
+ const path = require('node:path')
26
+
27
+ const camel = (flag) => flag.replace(/^--/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())
28
+
29
+ function parseFlags(args, table, { defaults = {}, renamed = {}, context = {} } = {}) {
30
+ const flags = { ...defaults }
31
+ const positional = []
32
+ const unknown = []
33
+ for (let i = 0; i < args.length; i++) {
34
+ const token = args[i]
35
+ const spec = table[token]
36
+ if (!spec) {
37
+ if (String(token).startsWith('--')) unknown.push(token)
38
+ else positional.push(token)
39
+ continue
40
+ }
41
+ const name = spec.name || camel(token)
42
+ const kind = spec.kind || 'boolean'
43
+ if (kind === 'boolean') flags[name] = true
44
+ else if (kind === 'value') flags[name] = args[++i]
45
+ else if (kind === 'string') flags[name] = String(args[++i] || '').trim()
46
+ else if (kind === 'number') flags[name] = Number(args[++i])
47
+ else if (kind === 'path') flags[name] = path.resolve(args[++i])
48
+ else if (kind === 'list') (flags[name] = flags[name] || []).push(String(args[++i] || '').trim())
49
+ else if (kind === 'custom') {
50
+ const value = spec.take === false ? undefined : args[++i]
51
+ spec.handle(flags, value, context)
52
+ } else throw new Error(`parseFlags: unknown kind "${kind}" for ${token}`)
53
+ }
54
+ return { flags, positional, unknown }
55
+ }
56
+
57
+ // The refusal, phrased once. Returns the lines to print (the caller owns the
58
+ // stream and the exit code).
59
+ function unknownFlagLines(program, unknown, renamed = {}) {
60
+ const lines = [`${program}: unknown flag ${unknown.join(', ')}`]
61
+ for (const flag of unknown) {
62
+ if (renamed[flag]) for (const advice of [].concat(renamed[flag])) lines.push(` ${advice}`)
63
+ }
64
+ lines.push(` run \`${program}\` for the full usage.`)
65
+ return lines
66
+ }
67
+
68
+ module.exports = { parseFlags, unknownFlagLines }
@@ -0,0 +1,277 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Assignment — who is building a spec, mirrored to the tracker's assignee.
5
+ *
6
+ * Three commands every provider with assignment shares, hoisted from the
7
+ * Linear provider when GitHub became the second consumer:
8
+ *
9
+ * - `assign <spec> --to <id> [--name <n>] | --release` stamps (or clears) the
10
+ * assignee fields in the overview frontmatter. It writes the REPO and
11
+ * nothing else; the next push carries it, like any other field.
12
+ * - `whoami [--set <id> [--name <n>]] [--unset]` reports — and caches — who
13
+ * the operator is in the tracker, which is the fact assignment rests on.
14
+ * - `users [<query>]` finds a person to hand a spec to.
15
+ *
16
+ * Everything tracker-shaped arrives as a hook (`makeAssignmentCommands`): the
17
+ * tracker's name for messages, the frontmatter fields, the store scope, how a
18
+ * credential resolves and how an adapter is made. The behaviour is the Linear
19
+ * provider's, unchanged — its tests still pin every message.
20
+ */
21
+
22
+ const path = require('node:path')
23
+
24
+ const { ownsField, writeFrontmatter, deleteFrontmatter, fieldsOf } = require('../../sync-core')
25
+ const { resolveTransport } = require('./transport.js')
26
+ const { makeSpecHelpers } = require('./specs.js')
27
+
28
+ /**
29
+ * @param {object} p
30
+ * - `tracker` the tracker's name in messages ("Linear", "GitHub")
31
+ * - `configFile` the provider config path, for refusals
32
+ * - `bin` the command a person types (for the hints)
33
+ * - `idHint` how a user id is described ("<linear-user-id>", "<login>")
34
+ * - `scopeOf(config)` → `{ id, label }` the credentials-store scope, or `{ id: '' , missing }` with
35
+ * `missing` the refusal text when the config cannot name one
36
+ * - `storePath(env)`, `writeUser(file, scope, user)`, `removeUser(file, scope)`
37
+ * - `resolveIdentity(config, env, deps)` the provider's identity binding
38
+ * - `resolveCredential(config, env)` → `{ ok, error? }`
39
+ * - `makeAdapter(config, credential, flags)` → an adapter with `searchUsers`
40
+ * - `displayNameOf(user)`
41
+ * - `validateAssignee(config, id, flags)` OPTIONAL — async, resolves to a
42
+ * refusal string or null. Absent means "do not check", which is the Linear
43
+ * provider's behaviour.
44
+ * - `usersOverMcp` the hint printed when users must be searched over MCP
45
+ * - `unknownHint` OPTIONAL extra lines for the unknown-identity whoami report
46
+ */
47
+ function makeAssignmentCommands(p) {
48
+ const helpers = makeSpecHelpers({ program: 'spec-sync' })
49
+
50
+ async function assign(dir, config, specArg, flags, out) {
51
+ const snapshotDir = helpers.resolveOrExit(specArg, dir, out)
52
+ if (!snapshotDir) return 1
53
+ const fields = fieldsOf(config)
54
+
55
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
56
+ const identifier = helpers.linkedIdentifier(path.join(snapshotDir, overviewFile), config)
57
+ const rel = path.relative(dir, snapshotDir)
58
+
59
+ const problems = []
60
+ if (flags.release && flags.to) problems.push('--release and --to are opposites — pass one')
61
+ if (!flags.release && !flags.to) problems.push('nothing to do — pass --to <user-id> or --release')
62
+ // An unlinked spec has no issue to assign. Refusing beats stamping a field
63
+ // that would sit in the file doing nothing until someone noticed.
64
+ if (!identifier) problems.push(`${rel} is not linked to ${p.tracker} — /spec-push it first`)
65
+ // AND THE SAME REASONING FOR THE FIELD ITSELF. A field the repo does not own
66
+ // is dropped from the projection, so a stamp written here would never reach
67
+ // the tracker — while the success line says `next: push it`. That is not a
68
+ // silent skip, it is an untrue assertion. Applies to `--release` too.
69
+ if (!ownsField(config, 'assignee')) {
70
+ problems.push(
71
+ 'this repo does not own the assignee field — set sync.fieldOwnership.assignee ' +
72
+ `to "push" in ${p.configFile}`,
73
+ )
74
+ }
75
+ // A POSITIVE refusal only: the hook answers "this id cannot be assigned"
76
+ // or null, and every cannot-tell inside it must be null.
77
+ if (!problems.length && flags.to && p.validateAssignee) {
78
+ const refusal = await p.validateAssignee(config, flags.to, flags)
79
+ if (refusal) problems.push(refusal)
80
+ }
81
+
82
+ if (problems.length) {
83
+ out.write(
84
+ ['spec-sync assign: refusing to write — nothing was changed', ...problems.map((x) => ` ${x}`)].join('\n') + '\n',
85
+ )
86
+ return 1
87
+ }
88
+
89
+ if (flags.release) {
90
+ const removed = deleteFrontmatter(snapshotDir, config, [fields.assigneeId, fields.assigneeName].filter(Boolean))
91
+ if (flags.json) {
92
+ out.write(JSON.stringify({ spec: rel, issue: identifier, released: removed.length > 0 }, null, 2) + '\n')
93
+ return 0
94
+ }
95
+ out.write(
96
+ (removed.length
97
+ ? `spec-sync assign: ${rel} released (${identifier})\n`
98
+ : `spec-sync assign: ${rel} records no assignee — nothing to release\n`) +
99
+ ` next: push it, so ${p.tracker} is unassigned too\n`,
100
+ )
101
+ return 0
102
+ }
103
+
104
+ writeFrontmatter(snapshotDir, config, {
105
+ [fields.assigneeId]: flags.to,
106
+ // Display only — the projection never sends it. It exists so a reader (and
107
+ // `/spec-claim`) can name the person without a tracker round trip.
108
+ ...(fields.assigneeName ? { [fields.assigneeName]: flags.name || null } : {}),
109
+ })
110
+ if (flags.json) {
111
+ out.write(JSON.stringify({ spec: rel, issue: identifier, id: flags.to, name: flags.name || null }, null, 2) + '\n')
112
+ return 0
113
+ }
114
+ const who = flags.name ? `${flags.name} (${flags.to})` : flags.to
115
+ out.write(`spec-sync assign: ${rel} assigned to ${who} (${identifier})\n next: push it, so ${p.tracker} agrees\n`)
116
+ return 0
117
+ }
118
+
119
+ /**
120
+ * Exit 0 even when the answer is "unknown": this answers a question, and
121
+ * "nobody is identified here" is a valid answer — a shared key, an offline
122
+ * machine. A successful DERIVE is cached; a cache read is not re-written, and
123
+ * the caching lives here so that asking never has a side effect.
124
+ */
125
+ async function whoami(dir, config, flags, out) {
126
+ const env = flags.env || process.env
127
+ const file = p.storePath(env)
128
+ const scope = p.scopeOf(config)
129
+
130
+ // The store is keyed by scope, so without one there is nowhere to read from
131
+ // or write to. That IS a broken config, so it exits non-zero.
132
+ if (!scope.id) {
133
+ out.write(scope.missing + '\n')
134
+ return 1
135
+ }
136
+ const label = scope.label || scope.id
137
+
138
+ if (flags.unset) {
139
+ const r = p.removeUser(file, scope.id, {})
140
+ if (!r.ok) {
141
+ out.write(`spec-sync whoami: ${r.reason}\n`)
142
+ return 1
143
+ }
144
+ out.write(
145
+ r.removed
146
+ ? `spec-sync whoami: forgot the identity for ${label} (the ${p.credentialNoun || 'API key'} is untouched)\n`
147
+ : `spec-sync whoami: no identity stored for ${label} — nothing to forget\n`,
148
+ )
149
+ return 0
150
+ }
151
+
152
+ if (flags.set) {
153
+ const r = p.writeUser(file, scope.id, { id: flags.set, name: flags.name }, {})
154
+ if (!r.ok) {
155
+ out.write(`spec-sync whoami: ${r.reason}\n`)
156
+ return 1
157
+ }
158
+ if (flags.json) {
159
+ out.write(JSON.stringify({ id: flags.set, name: flags.name || null, source: 'set', store: r.path }, null, 2) + '\n')
160
+ return 0
161
+ }
162
+ const shown = flags.name ? `${flags.name} (${flags.set})` : flags.set
163
+ out.write(`spec-sync whoami: identity for ${label} set to ${shown} in ${r.path} (600)\n`)
164
+ return 0
165
+ }
166
+
167
+ const identity = await p.resolveIdentity(config, env, { adapter: flags.adapter, fetch: flags.fetch })
168
+
169
+ if (identity.ok && identity.source === 'viewer') {
170
+ // A failed write is not fatal: we HAVE the answer, and re-deriving it next
171
+ // time is a slower success, not a wrong one.
172
+ const cached = p.writeUser(file, scope.id, { id: identity.id, name: identity.name }, {})
173
+ if (!cached.ok) identity.cacheWarning = cached.reason
174
+ }
175
+
176
+ if (flags.json) {
177
+ out.write(
178
+ JSON.stringify(
179
+ {
180
+ [p.scopeJsonKey || 'team']: label,
181
+ store: file,
182
+ ok: identity.ok,
183
+ id: identity.id || null,
184
+ name: identity.name || null,
185
+ source: identity.source,
186
+ reason: identity.ok ? null : identity.reason || null,
187
+ },
188
+ null,
189
+ 2,
190
+ ) + '\n',
191
+ )
192
+ return 0
193
+ }
194
+
195
+ if (!identity.ok) {
196
+ out.write(
197
+ [
198
+ 'spec-sync whoami: unknown — no identity for this workspace',
199
+ ` ${identity.reason}`,
200
+ ' Set one by hand:',
201
+ ` ${p.bin} spec-sync whoami --set ${p.idHint} --name "<your name>"`,
202
+ ` Find the id with: ${p.bin} spec-sync users <name-or-email>`,
203
+ ].join('\n') + '\n',
204
+ )
205
+ return 0
206
+ }
207
+
208
+ const where = identity.source === 'store' ? `cached in ${file}` : `derived from the ${p.credentialNoun || 'API key'}`
209
+ const lines = [
210
+ `spec-sync whoami: ${identity.name || '(unnamed)'} <${identity.id}>`,
211
+ ` ${(p.scopeLineLabel || 'team:').padEnd(7)} ${label}`,
212
+ ` source: ${where}`,
213
+ ]
214
+ if (identity.cacheWarning) lines.push(` note: not cached — ${identity.cacheWarning}`)
215
+ out.write(lines.join('\n') + '\n')
216
+ return 0
217
+ }
218
+
219
+ /**
220
+ * The engine answers on the API path and steps aside on the MCP path — the
221
+ * same contract `states` follows.
222
+ */
223
+ async function users(dir, config, query, flags, out) {
224
+ const cred = p.resolveCredential(config, flags.env || process.env)
225
+ const transport = resolveTransport(flags, config, cred.ok || !!flags.adapter)
226
+
227
+ if (transport === 'mcp') {
228
+ if (flags.json) {
229
+ out.write(JSON.stringify({ transport: 'mcp', reason: cred.ok ? 'requested' : cred.error, users: null }, null, 2) + '\n')
230
+ return 0
231
+ }
232
+ out.write(
233
+ ['spec-sync users: transport = mcp', ` ${cred.ok ? '--via mcp was requested' : cred.error}`, ` ${p.usersOverMcp}`].join('\n') +
234
+ '\n',
235
+ )
236
+ return 0
237
+ }
238
+ if (!cred.ok && !flags.adapter) {
239
+ out.write(`spec-sync users: refusing — ${cred.error}\n`)
240
+ return 1
241
+ }
242
+
243
+ const adapter = flags.adapter || p.makeAdapter(config, cred, flags)
244
+ let page
245
+ try {
246
+ page = await adapter.searchUsers(query || '', { limit: flags.limit || 50, cursor: flags.cursor || null })
247
+ } catch (error) {
248
+ out.write(`spec-sync users: ${error.message}\n`)
249
+ return 1
250
+ }
251
+
252
+ const list = (page && page.users) || []
253
+ if (flags.json) {
254
+ out.write(JSON.stringify({ transport: 'api', users: list, nextCursor: (page && page.nextCursor) || null }, null, 2) + '\n')
255
+ return 0
256
+ }
257
+ if (!list.length) {
258
+ out.write(`spec-sync users: no match for ${JSON.stringify(query || '')}\n`)
259
+ return 0
260
+ }
261
+ const lines = [`spec-sync users: ${list.length} match(es)`]
262
+ for (const u of list) {
263
+ // A deactivated user still resolves by id and can still be assigned, so
264
+ // the marker is the difference between a valid pick and a silently dead one.
265
+ const inactive = u && u.active === false ? ' [deactivated]' : ''
266
+ const email = u && u.email ? ` ${u.email}` : ''
267
+ lines.push(` ${p.displayNameOf(u) || '(unnamed)'} <${u && u.id}>${email}${inactive}`)
268
+ }
269
+ if (page && page.nextCursor) lines.push(` … more — re-run with --cursor ${page.nextCursor}`)
270
+ out.write(lines.join('\n') + '\n')
271
+ return 0
272
+ }
273
+
274
+ return { assign, whoami, users }
275
+ }
276
+
277
+ module.exports = { makeAssignmentCommands }