@stravica-ai/digital-operator-pa 0.1.19 → 0.1.21

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.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -8,7 +8,7 @@ Dave uses SOPS with age encryption for secrets management. Secrets are encrypted
8
8
 
9
9
  Implemented: `init`, `get`, `set`, `list`, `delete`, `rotate`, `status`, `backup`, `vaults`, `find` (alias `which`), `verify`. No npm dependencies — shells out to `sops` and `age` via `node:child_process.spawn`.
10
10
 
11
- `set` additionally supports `--canonical-clone PATH` (write a canonical clone's vault file first, then the resolved workspace copy — aborts before touching the workspace copy if the canonical write fails). `verify --values --against PATH` does a byte-level comparison of DECRYPTED values between the resolved vault and a reference vault at PATH — never a decrypt-only pass. See "Value-compare verification" and "Canonical-first writes" below.
11
+ **Canonical-first dual-write is the DEFAULT for scoped `set` since 0.1.20.** A plain `secrets set` on a scoped vault writes canonical FIRST and workspace SECOND whenever a canonical clone for the scope is discoverable on this host (via `~/.config/stravica-pa/defaults.json`'s `canonicalClone` key or the `~/stravica-canonical/*/vault/<scope>.enc.json` estate convention). Absence of a canonical clone is a stderr NOTICE, never a failure. Explicit `--canonical-clone PATH` still wins over auto-discovery (and bootstraps a missing file); `--no-canonical` opts out entirely and suppresses the notice. `verify --values --against PATH` does a byte-level comparison of DECRYPTED values between the resolved vault and a reference vault at PATH — never a decrypt-only pass. See "Value-compare verification" and "Canonical-first writes" below.
12
12
 
13
13
  `get`/`set` run without `--scope`/`--file` now resolve a **default scope** — `$SECRETS_DEFAULT_SCOPE` env var, then `"defaultScope"` in `~/.config/stravica-pa/defaults.json`, then the calling PA's own `agents/<name>/identity.json`, then the legacy single-file vault as a last resort. Every such invocation prints a one-line stderr notice naming the target vault BEFORE the operation runs, including the legacy-fallback case ("no scope specified and no default configured") — see `secrets --help` → "Default scope" for the full precedence and example notices.
14
14
 
@@ -67,11 +67,17 @@ node core/tools/secrets/bin/secrets status
67
67
  # Open a browser with a QR code of the master key for backup
68
68
  node core/tools/secrets/bin/secrets backup
69
69
 
70
- # Write a canonical clone's vault FIRST, then the workspace copy second.
71
- # Aborts (exit 2) before touching the workspace copy if the canonical
72
- # write fails (e.g. the existing canonical store can't be decrypted).
70
+ # Force a specific canonical file (wins over auto-discovery; bootstraps
71
+ # a missing file). Aborts (exit 2) before touching the workspace copy if
72
+ # the canonical write fails (e.g. the existing canonical store can't be
73
+ # decrypted). Auto-discovery is on by default since 0.1.20 — this flag
74
+ # is only for pointing at a specific target.
73
75
  node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --canonical-clone /path/to/canonical/vault/scope.enc.json
74
76
 
77
+ # Opt out of canonical dual-write entirely (suppresses the absence
78
+ # notice too). Mutually exclusive with --canonical-clone.
79
+ node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --no-canonical
80
+
75
81
  # Byte-level comparison of DECRYPTED values against a reference vault.
76
82
  # --values is required — there is no decrypt-only verification mode
77
83
  # (decrypt-only false-passes a vault that decrypts cleanly but holds the
@@ -79,29 +85,66 @@ node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --canonical-clone /p
79
85
  node core/tools/secrets/bin/secrets verify --values --against /path/to/other/vault/scope.enc.json
80
86
  ```
81
87
 
82
- ### Canonical-first writes (`set --canonical-clone`)
88
+ ### Canonical-first writes
89
+
90
+ Canonical-first dual-write is the DEFAULT for scoped `set` since 0.1.20 —
91
+ a plain `secrets set` on a scoped vault writes canonical FIRST and the
92
+ workspace SECOND whenever a canonical clone for the scope is discoverable
93
+ on this host. The abort-before-workspace ordering is identical to the
94
+ explicit-flag path it grew out of: if the canonical write fails for any
95
+ reason (existing canonical store undecryptable, sops encrypt failure),
96
+ the command exits 2 and the workspace copy is never touched.
97
+
98
+ **Resolution order** (first hit wins; every miss recorded in the absence
99
+ notice's tried-tier detail):
100
+
101
+ 1. **Explicit `--canonical-clone PATH`** — always wins, bootstraps a
102
+ missing file (this is the flag's historical behaviour).
103
+ 2. **`"canonicalClone"` in `~/.config/stravica-pa/defaults.json`** — the
104
+ same file (already read for `workspaceRoot` and `defaultScope`); the
105
+ CLI never writes it. Two shapes supported:
106
+ - **Object (scope → file map):**
107
+ ```json
108
+ { "canonicalClone": { "seo-ops": "/Users/thefoot/stravica-canonical/stravica-operator-barry/vault/seo-ops.enc.json" } }
109
+ ```
110
+ - **String (root dir)** — derives `<root>/vault/<scope>.enc.json`,
111
+ which fits the estate's operator-repo layout:
112
+ ```json
113
+ { "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
114
+ ```
115
+ 3. **Estate convention** — `~/stravica-canonical/<repo>/vault/<scope>.enc.json`
116
+ (glob shape `~/stravica-canonical/*/vault/…enc.json`). This reuses the
117
+ root the rest of the estate already uses (see
118
+ `library-sync-cycle`'s `--repos-root` default and
119
+ `dex-cutover-driver`'s `reposRoot`). Exactly one match wins; ambiguity
120
+ (multiple matches) or zero matches skips this tier.
121
+
122
+ **Absence** is a stderr notice, never a failure — a host with no
123
+ canonical layout keeps writing to workspace only:
124
+
125
+ ```
126
+ notice: no canonical clone configured for scope 'seo-ops' on this host — workspace-only write (tried: …)
127
+ ```
128
+
129
+ The `tried:` tail names every tier that missed and why, so an operator on
130
+ a host that ought to have a canonical knows exactly where to configure it
131
+ (or that `--no-canonical` is the intended answer for a host that never
132
+ had one).
83
133
 
84
- Without the flag, `set` behaves exactly as before — it writes only the
85
- resolved workspace copy. With `--canonical-clone PATH`, `set`:
134
+ **Flags:**
86
135
 
87
- 1. Resolves `PATH` the same way `--file` resolves any other vault (its
88
- own `.sops.yaml` expected alongside it), reusing this host's own key
89
- material (`keyDir`/`keyFile`) — the canonical clone and the workspace
90
- copy are expected to share recipients in practice (same operator,
91
- same device).
92
- 2. Decrypts the existing canonical store (if any) and writes the new key
93
- into it. **If this fails for any reason — the existing canonical
94
- store can't be decrypted, or the encrypt itself fails — `set` aborts
95
- with exit 2 and the workspace copy is never touched.**
96
- 3. Only on canonical success does it proceed to the existing
97
- decrypt-mutate-encrypt cycle against the resolved workspace copy.
136
+ - `--canonical-clone PATH` — force a specific canonical file (wins over
137
+ auto-discovery; bootstraps a missing file). Backwards-compatible with
138
+ every prior release.
139
+ - `--no-canonical` — opt out of dual-write entirely, and suppress the
140
+ absence notice. Mutually exclusive with `--canonical-clone`.
98
141
 
99
142
  This closes the "device-side vault mutation only round-trips halfway"
100
- class: a device-side `secrets set` that updates the workspace copy but
101
- never reaches canonical leaves the two vaults silently diverged until the
102
- next down-path materialisation clobbers the device's fresher value.
103
- `--canonical-clone` makes the canonical write the FIRST, gating step —
104
- the workspace never ends up ahead of canonical.
143
+ class BY DEFAULT rather than opt-in: any workspace write that a
144
+ canonical layout could catch, it now does — no flag required.
145
+
146
+ Legacy vaults (no scope) never trigger canonical resolution — the notice
147
+ would be pure noise for a fallback that has no natural canonical mirror.
105
148
 
106
149
  ### Value-compare verification (`verify --values --against`)
107
150
 
@@ -1,12 +1,23 @@
1
1
  // work-cli.mjs — argv router for `pa work <verb>`.
2
2
  //
3
3
  // Verbs:
4
- // pa work create [--project <id>] --title "..." --category <c> [--priority N] [--summary "..."] [--owner ...] [--assignee ...]
4
+ // pa work create [--project <id>] --title "..." --category <c> [--priority N] [--summary "..."] [--owner ...] [--assignee ...] [--author <token>]
5
5
  // pa work status <id> <status>
6
6
  // pa work list [--project <id>] [--status ...] [--mine] [--theirs] [--all] [--json]
7
7
  //
8
8
  // PA name (for --mine resolution) is read from
9
9
  // operator/config/preferences.json. Falls back to 'pa' if missing.
10
+ //
11
+ // The `paName` field ALSO doubles as the fallback author-token source
12
+ // for `pa work create` (writer-scoped id allocation, R1 of the
13
+ // 2026-08-11 library remediation train). Precedence, highest first:
14
+ // 1. --author flag (or opts.author from a programmatic caller)
15
+ // 2. PA_AUTHOR_TOKEN env var
16
+ // 3. preferences.json `paName`
17
+ // If none resolve, `pa work create` hard-errors — the whole point of
18
+ // R1 is that a mint with no author identity is a defect, not a
19
+ // fall-back-to-default case. Full rationale + tests in
20
+ // `@stravica-ai/engineering-pa-core/src/workItemId/index.mjs`.
10
21
 
11
22
  import { readPreferences } from '../../../engineering-pa-core/src/preferences.mjs';
12
23
 
@@ -18,6 +29,7 @@ const USAGE = `pa work — manage operator-repo work items
18
29
  Usage:
19
30
  pa work create [--project <id>] --title "..." --category <feature|bug|chore|discussion|research>
20
31
  [--priority 0|1|2|3] [--summary "..."] [--owner ...] [--assignee ...]
32
+ [--author <token>]
21
33
  pa work status <id> <draft|planned|in-progress|blocked|done|archived>
22
34
  pa work list [--project <id>] [--status ...] [--category ...] [--mine] [--theirs] [--all] [--json]
23
35
  pa work -h, --help
@@ -26,6 +38,9 @@ Notes:
26
38
  - status=done stamps \`completedAt\` to today (only if not already set).
27
39
  - --mine resolves the PA name from operator/config/preferences.json
28
40
  (paName / agentName). Default: "pa".
41
+ - --author sets the writer-scoped id's author token. Precedence:
42
+ --author > PA_AUTHOR_TOKEN env > preferences.json paName. Must
43
+ match /^[a-z]{2,12}$/ and be present in operator/config/authors.json.
29
44
 
30
45
  Exit codes:
31
46
  0 ok | 1 usage/not-found | 2 operational failure
@@ -89,11 +104,29 @@ async function getPaName() {
89
104
  }
90
105
  }
91
106
 
107
+ /**
108
+ * Read the raw `paName` (device-local overlay merged over shared). Unlike
109
+ * `getPaName`, this returns null if the field is unset — createWork's
110
+ * author-resolver treats null as "skip this source", not "fall back to
111
+ * literal 'pa'" (which would silently mask a missing token).
112
+ */
113
+ async function readPreferencesPaName() {
114
+ try {
115
+ const repoRoot = await getRepoRoot();
116
+ const config = await readPreferences(repoRoot);
117
+ if (!config) return null;
118
+ return config.paName || config.agentName || null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
92
124
  async function doCreate(args) {
93
125
  const { flags } = parseFlags(args);
94
126
  const priority = flags.priority != null && flags.priority !== true
95
127
  ? Number(flags.priority)
96
128
  : null;
129
+ const preferencesPaName = await readPreferencesPaName();
97
130
  const res = await createWork({
98
131
  projectId: typeof flags.project === 'string' ? flags.project : null,
99
132
  title: typeof flags.title === 'string' ? flags.title : null,
@@ -102,6 +135,8 @@ async function doCreate(args) {
102
135
  summary: typeof flags.summary === 'string' ? flags.summary : null,
103
136
  owner: typeof flags.owner === 'string' ? flags.owner : null,
104
137
  assignee: typeof flags.assignee === 'string' ? flags.assignee : null,
138
+ author: typeof flags.author === 'string' ? flags.author : null,
139
+ preferencesPaName,
105
140
  });
106
141
  process.stdout.write(`created ${res.id}\n`);
107
142
  process.stdout.write(` ${res.path}\n`);
@@ -1,18 +1,30 @@
1
1
  // work.mjs — write-side commands for `pa work <verb>`.
2
2
  //
3
- // Source of truth is work/w-YYYY-MM-DD-NNN.json. We never cache.
3
+ // Source of truth is work/w-YYYY-MM-DD-*.json. We never cache.
4
4
  //
5
5
  // What lives here:
6
- // - create: scaffold a new work item with the next-free NNN for today
6
+ // - create: scaffold a new work item with the next-free NNN in the
7
+ // CURRENT WRITER'S per-day sequence
7
8
  // - status: change status (stamps `completedAt` on done)
8
9
  // - list: list work items, optionally filtered
9
10
  //
10
- // ID format and status/category enums match
11
- // packages/core/schema/work-item.json.
11
+ // Id format, parsing, and author resolution all live in
12
+ // `@stravica-ai/engineering-pa-core/src/workItemId/index.mjs` — the
13
+ // single-source primitive for the whole estate. See its header for the
14
+ // two legal forms (legacy + writer-scoped), the R1 remediation
15
+ // background, and the author-token precedence rules.
12
16
 
13
17
  import { existsSync } from 'node:fs';
14
18
  import { join } from 'node:path';
15
19
 
20
+ import {
21
+ parseWorkItemId,
22
+ isValidWorkItemId,
23
+ buildWorkItemId,
24
+ resolveAuthorToken,
25
+ maxNForAuthorOnDate,
26
+ } from '../../../engineering-pa-core/src/workItemId/index.mjs';
27
+
16
28
  import { getRepoRoot } from './repo-root.mjs';
17
29
  import { readJson, writeJson, ensureDir, listJsonFiles, today } from './io.mjs';
18
30
 
@@ -20,8 +32,6 @@ const STATUSES = new Set(['draft', 'planned', 'in-progress', 'blocked', 'done',
20
32
 
21
33
  const CATEGORIES = new Set(['feature', 'bug', 'chore', 'discussion', 'research']);
22
34
 
23
- const ID_RE = /^w-(\d{4}-\d{2}-\d{2})-(\d{3})$/;
24
-
25
35
  function validateStatus(status) {
26
36
  if (!STATUSES.has(status)) {
27
37
  const err = new Error(
@@ -53,38 +63,44 @@ function validatePriority(priority) {
53
63
  }
54
64
 
55
65
  /**
56
- * Scan work/*.json for the highest NNN with today's date. Returns the
57
- * next free NNN, zero-padded to 3 digits. Result is always "001"–"999".
66
+ * Highest NNN already allocated for `dateStr` in THIS author's per-day
67
+ * sequence. Legacy items (`w-YYYY-MM-DD-NNN` with no author segment)
68
+ * do not count toward any author's sequence — each writer starts from
69
+ * 001 on the day writer-scoping takes effect and existing legacy items
70
+ * simply occupy a separate, older space.
58
71
  *
59
72
  * If 999 is already taken, throws — callers should treat as an
60
- * operational failure and report. In practice this is unreachable.
73
+ * operational failure and report. In practice this is unreachable
74
+ * because NNN is per-author-per-day, not shared.
61
75
  */
62
- async function nextWorkNumber(repoRoot, dateStr) {
76
+ async function nextWorkNumber(repoRoot, dateStr, author) {
63
77
  const dir = join(repoRoot, 'work');
64
78
  const files = await listJsonFiles(dir);
65
- let max = 0;
66
- const prefix = `w-${dateStr}-`;
67
- for (const file of files) {
68
- if (!file.startsWith(prefix)) continue;
69
- const m = file.match(/^w-\d{4}-\d{2}-\d{2}-(\d{3})\.json$/);
70
- if (!m) continue;
71
- const n = parseInt(m[1], 10);
72
- if (Number.isFinite(n) && n > max) max = n;
73
- }
79
+ const max = maxNForAuthorOnDate(files, dateStr, author);
74
80
  const next = max + 1;
75
81
  if (next > 999) {
76
- const err = new Error(`no free work-item slot for ${dateStr} (NNN exhausted)`);
82
+ const err = new Error(
83
+ `no free work-item slot for ${dateStr} in author=${author} sequence (NNN exhausted)`,
84
+ );
77
85
  err.exitCode = 2;
78
86
  throw err;
79
87
  }
80
- return String(next).padStart(3, '0');
88
+ return next;
81
89
  }
82
90
 
83
91
  /**
84
92
  * Create a new work item. Returns { id, path }.
85
93
  *
86
94
  * Status defaults to "draft" per the brief. Date and NNN are
87
- * auto-derived from today; we do not accept overrides.
95
+ * auto-derived; we do not accept overrides.
96
+ *
97
+ * Author token comes from `opts.author` (explicit; wins over anything
98
+ * else), the `PA_AUTHOR_TOKEN` env var, or the `paName` field of
99
+ * `operator/config/preferences.json` (passed in as
100
+ * `opts.preferencesPaName` by the CLI entry). If none resolve, throws
101
+ * with an actionable message naming all three sources — a mint with no
102
+ * author identity is the exact defect R1 exists to close, so we refuse
103
+ * to fall back to any hard-coded default.
88
104
  */
89
105
  export async function createWork(opts) {
90
106
  if (!opts.title) {
@@ -95,13 +111,24 @@ export async function createWork(opts) {
95
111
  validateCategory(opts.category);
96
112
  validatePriority(opts.priority);
97
113
 
114
+ let author;
115
+ try {
116
+ author = resolveAuthorToken({
117
+ explicit: opts.author,
118
+ preferencesPaName: opts.preferencesPaName,
119
+ });
120
+ } catch (e) {
121
+ e.exitCode = 1;
122
+ throw e;
123
+ }
124
+
98
125
  const repoRoot = await getRepoRoot();
99
126
  const workDir = join(repoRoot, 'work');
100
127
  await ensureDir(workDir);
101
128
 
102
129
  const dateStr = today();
103
- const nnn = await nextWorkNumber(repoRoot, dateStr);
104
- const id = `w-${dateStr}-${nnn}`;
130
+ const nnn = await nextWorkNumber(repoRoot, dateStr, author);
131
+ const id = buildWorkItemId({ date: dateStr, author, nnn });
105
132
  const path = join(workDir, `${id}.json`);
106
133
 
107
134
  const item = {
@@ -127,10 +154,16 @@ export async function createWork(opts) {
127
154
  /**
128
155
  * Set work-item status. Stamps `completedAt` to today's date when
129
156
  * status=done (only if not already set). Always bumps `updated`.
157
+ *
158
+ * Accepts both legacy `w-YYYY-MM-DD-NNN` and writer-scoped
159
+ * `w-YYYY-MM-DD-<author>-NNN` ids — every existing item in the estate
160
+ * predates R1 and must remain mutable through this command.
130
161
  */
131
162
  export async function setWorkStatus(id, status) {
132
- if (!ID_RE.test(id)) {
133
- const err = new Error(`invalid work id: ${id} (expected w-YYYY-MM-DD-NNN)`);
163
+ if (!isValidWorkItemId(id)) {
164
+ const err = new Error(
165
+ `invalid work id: ${id} (expected w-YYYY-MM-DD-NNN or w-YYYY-MM-DD-<author>-NNN)`,
166
+ );
134
167
  err.exitCode = 1;
135
168
  throw err;
136
169
  }
@@ -157,6 +190,12 @@ export async function setWorkStatus(id, status) {
157
190
  * List work items. Filters: status, category, project, owner, assignee,
158
191
  * mine, theirs, all, paName (used for --mine resolution).
159
192
  * By default excludes done + archived (mirrors desk-list semantics).
193
+ *
194
+ * Reads any well-formed work item (both id forms). Skips files whose
195
+ * filename does not parse as a work-item id at all — the same
196
+ * skip-unparseable posture the original had, restated against the
197
+ * shared parser so an in-directory README or a stray temp file cannot
198
+ * poison the listing.
160
199
  */
161
200
  export async function listWork(filters = {}) {
162
201
  const repoRoot = await getRepoRoot();
@@ -164,10 +203,11 @@ export async function listWork(filters = {}) {
164
203
  const files = await listJsonFiles(dir);
165
204
  const items = [];
166
205
  for (const f of files) {
206
+ if (!parseWorkItemId(f)) continue;
167
207
  try {
168
208
  items.push(await readJson(join(dir, f)));
169
209
  } catch {
170
- // Skip unparseable.
210
+ // Skip unparseable JSON contents.
171
211
  }
172
212
  }
173
213
  let filtered = items;
@@ -25,10 +25,17 @@ Usage:
25
25
  secrets init Initialise the vault (generate keys, create empty secrets file)
26
26
  secrets get KEY Retrieve a secret
27
27
  secrets set KEY VALUE Store or update a secret (use \`-\` as VALUE to read from stdin)
28
- --canonical-clone PATH: write the canonical
29
- clone's vault file FIRST, then this workspace
30
- copy. If the canonical write fails, aborts
31
- before touching the workspace copy at all.
28
+ Since 0.1.20, canonical-first dual-write is
29
+ the DEFAULT for scoped writes when a canonical
30
+ clone for the scope is discoverable on this
31
+ host. See "Canonical-first writes" below.
32
+ --canonical-clone PATH: force a specific
33
+ canonical clone's vault file (bootstraps a
34
+ missing file; wins over auto-discovery).
35
+ --no-canonical: opt out of the dual-write and
36
+ the absence notice.
37
+ If the canonical write fails, aborts before
38
+ touching the workspace copy at all.
32
39
  secrets list List all secret key names (not values)
33
40
  secrets delete KEY Remove a secret
34
41
  secrets rotate Re-encrypt with current recipients
@@ -111,16 +118,30 @@ Default scope (get/set only, when neither --scope nor --file is given):
111
118
  note: writing to '(legacy)' pot — no scope specified and no default configured. Use --scope <name> or configure a default.
112
119
 
113
120
  Canonical-first writes (set only):
114
- --canonical-clone PATH Write PATH (a canonical clone's encrypted
115
- vault file) FIRST, then the resolved
116
- workspace copy SECOND. If the canonical
117
- write fails for any reason (existing store
118
- undecryptable, sops encrypt failure), the
119
- command aborts with exit 2 BEFORE touching
120
- the workspace copy at all — a device-side
121
- vault mutation never round-trips only
122
- halfway. Without this flag, 'set' behaves
123
- exactly as before (workspace copy only).
121
+ Canonical-first dual-write is DEFAULT for scoped writes since 0.1.20 —
122
+ a plain 'secrets set' on a scoped vault writes canonical FIRST and
123
+ workspace SECOND whenever a canonical clone for the scope is
124
+ discoverable on this host. Discovery order, first hit wins:
125
+ 1. Explicit --canonical-clone PATH (always wins, bootstraps a
126
+ missing file).
127
+ 2. "canonicalClone" in ~/.config/stravica-pa/defaults.json — either:
128
+ object (scope → file map):
129
+ { "canonicalClone": { "seo-ops": "/abs/vault/seo-ops.enc.json" } }
130
+ string (root dir), derives <root>/vault/<scope>.enc.json:
131
+ { "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
132
+ 3. Estate convention: ~/stravica-canonical/*/vault/<scope>.enc.json
133
+ (exactly one match; ambiguity or zero matches skips this tier).
134
+ Absence is a NOTICE, never a failure — a host with no canonical layout
135
+ keeps writing to workspace only, with a one-line stderr line naming why
136
+ nothing resolved. If any canonical write fails (existing store
137
+ undecryptable, sops encrypt failure), the command aborts with exit 2
138
+ BEFORE touching the workspace copy — the ordering invariant is
139
+ identical to the flag path since it always was.
140
+
141
+ --canonical-clone PATH Force PATH as the canonical target (wins over
142
+ auto-discovery; bootstraps a missing file).
143
+ --no-canonical Opt out of dual-write entirely, and suppress
144
+ the absence notice.
124
145
 
125
146
  Value-compare verification:
126
147
  secrets verify --values --against <path>
@@ -139,7 +160,7 @@ const USAGE = `Usage: secrets {init|get|set|list|delete|rotate|status|backup|vau
139
160
  Commands:
140
161
  init Initialise the vault (generate keys, create config)
141
162
  get KEY Retrieve a secret value
142
- set KEY VALUE Store or update a secret (--canonical-clone PATH: write canonical first)
163
+ set KEY VALUE Store or update a secret (canonical-first by default; --no-canonical to opt out)
143
164
  list List all secret key names
144
165
  delete KEY Remove a secret
145
166
  rotate Re-encrypt with current recipients
@@ -2,18 +2,29 @@
2
2
  // Passing `-` as VALUE reads the secret from stdin (trailing newline stripped),
3
3
  // which keeps the value out of argv / process listings / shell history.
4
4
  //
5
- // --canonical-clone PATH: write the canonical clone's vault file FIRST,
6
- // then this workspace copy SECOND. If the canonical write fails for any
7
- // reason, the command aborts (exit 2) BEFORE touching the workspace copy
8
- // at all — a device-side vault mutation never round-trips only halfway
9
- // (see up-path-single-writer-ownership's vault follow-up: "device-side
10
- // vault mutations must round-trip to canonical immediately"). Without the
11
- // flag, behaviour is exactly as before (workspace copy only).
5
+ // Canonical-first dual-write is the DEFAULT for scoped writes since 0.1.20:
6
+ // when a canonical clone for the target scope is discoverable on this host
7
+ // (via ~/.config/stravica-pa/defaults.json "canonicalClone" or the
8
+ // ~/stravica-canonical/*/vault/<scope>.enc.json convention — see
9
+ // resolveCanonicalCloneTarget in ../vault.mjs), set writes canonical FIRST
10
+ // and workspace SECOND, with the same abort-before-workspace ordering the
11
+ // explicit --canonical-clone flag has always had. Absence of a canonical
12
+ // clone is a NOTICE, never a failure (a host that never had a canonical
13
+ // keeps working); --no-canonical opts out (and suppresses the notice).
14
+ // Explicit --canonical-clone PATH still wins over any auto-discovery.
15
+ //
16
+ // Ordering invariant (pinned by tests): if the canonical write fails for
17
+ // any reason (existing canonical store undecryptable, sops encrypt
18
+ // failure), the command aborts with exit 2 BEFORE touching the workspace
19
+ // copy at all — a device-side vault mutation never round-trips only
20
+ // halfway (see up-path-single-writer-ownership's vault follow-up:
21
+ // "device-side vault mutations must round-trip to canonical immediately").
12
22
 
13
23
  import { readFileSync } from 'node:fs';
14
24
  import {
15
25
  resolveCommandPaths,
16
26
  resolveScopePaths,
27
+ resolveCanonicalCloneTarget,
17
28
  checkDeps,
18
29
  depInstallMessage,
19
30
  checkVault,
@@ -25,19 +36,21 @@ import {
25
36
  VaultError,
26
37
  } from '../vault.mjs';
27
38
 
28
- // --canonical-clone may appear anywhere in argv, matching the --scope/
29
- // --file convention in cli.mjs. Local to `set` (not a global flag) since
30
- // no other command writes to a second target.
31
- function extractCanonicalCloneFlag(argv) {
39
+ // --canonical-clone / --no-canonical may appear anywhere in argv, matching
40
+ // the --scope/--file convention in cli.mjs. Local to `set` (not global
41
+ // flags) since no other command writes to a second target.
42
+ function extractCanonicalFlags(argv) {
32
43
  const rest = [];
33
44
  let canonicalClone;
45
+ let noCanonical = false;
34
46
  for (let i = 0; i < argv.length; i += 1) {
35
47
  const a = argv[i];
36
48
  if (a === '--canonical-clone') { canonicalClone = argv[i + 1]; i += 1; continue; }
37
49
  if (a.startsWith('--canonical-clone=')) { canonicalClone = a.slice('--canonical-clone='.length); continue; }
50
+ if (a === '--no-canonical') { noCanonical = true; continue; }
38
51
  rest.push(a);
39
52
  }
40
- return { canonicalClone, rest };
53
+ return { canonicalClone, noCanonical, rest };
41
54
  }
42
55
 
43
56
  export async function runSet(args, overrides = {}) {
@@ -45,15 +58,20 @@ export async function runSet(args, overrides = {}) {
45
58
  // target vault) whenever --scope/--file wasn't given upstream in
46
59
  // cli.mjs — see vault.mjs's default-scope resolution section.
47
60
  const paths = resolveCommandPaths(overrides);
48
- const { canonicalClone, rest } = extractCanonicalCloneFlag(args);
61
+ const { canonicalClone, noCanonical, rest } = extractCanonicalFlags(args);
49
62
  const key = rest[0];
50
63
  let value = rest[1];
51
64
 
52
65
  if (!key || value == null || value === '') {
53
- console.error('Usage: secrets set KEY VALUE [--canonical-clone PATH] (use `-` as VALUE to read from stdin)');
66
+ console.error('Usage: secrets set KEY VALUE [--canonical-clone PATH | --no-canonical] (use `-` as VALUE to read from stdin)');
54
67
  throw new VaultError('usage', 1);
55
68
  }
56
69
 
70
+ if (canonicalClone && noCanonical) {
71
+ console.error('Error: --canonical-clone and --no-canonical are mutually exclusive.');
72
+ throw new VaultError('mutually exclusive canonical flags', 1);
73
+ }
74
+
57
75
  if (value === '-') {
58
76
  const readStdin = overrides.readStdin ?? (() => readFileSync(0, 'utf-8'));
59
77
  const raw = readStdin();
@@ -79,16 +97,54 @@ export async function runSet(args, overrides = {}) {
79
97
  throw new VaultError(vaultState.reason || 'vault not initialised', vaultState.exitCode ?? 1);
80
98
  }
81
99
 
100
+ // Canonical-first resolution order (BEFORE any workspace write):
101
+ // 1. Explicit --canonical-clone PATH → wins, always. Bootstraps a
102
+ // missing canonical file (matches the flag's historical behaviour).
103
+ // 2. --no-canonical → skip canonical entirely, no notice, no discovery.
104
+ // 3. Scoped write (paths.scope set) → auto-discover via
105
+ // resolveCanonicalCloneTarget. Hit → dual-write canonical-first,
106
+ // SAME output lines as the explicit-flag path. Miss → workspace-only
107
+ // PLUS a one-line stderr notice naming why nothing resolved.
108
+ // 4. Legacy fallback (no scope) → no canonical resolution, no notice —
109
+ // legacy has no natural canonical mirror.
110
+ let effectiveCanonical = null;
111
+ let canonicalPathsFromOverride = overrides.canonicalPaths || null;
112
+ if (canonicalClone) {
113
+ effectiveCanonical = { file: canonicalClone, source: 'flag' };
114
+ } else if (!noCanonical && paths.scope) {
115
+ // Honour the paths' own `home` first — it carries the caller's
116
+ // injected home end-to-end so test isolation cannot leak into the
117
+ // real ~/stravica-canonical/ scan. Falls back to overrides.home for
118
+ // callers that build paths themselves and only pass home via
119
+ // overrides, then to homedir() for the real CLI at run time.
120
+ const canonicalHome = paths.home ?? overrides.home;
121
+ const resolved = overrides.resolveCanonicalCloneTarget
122
+ ? overrides.resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env })
123
+ : resolveCanonicalCloneTarget({ scope: paths.scope, home: canonicalHome, env: overrides.env });
124
+ if (resolved.file) {
125
+ effectiveCanonical = { file: resolved.file, source: resolved.source, detail: resolved.detail };
126
+ } else {
127
+ // Absence notice — never a failure. Explains WHY nothing resolved so
128
+ // an operator on a host without a canonical layout knows whether to
129
+ // provision one, pass --no-canonical to silence, or ignore.
130
+ const tail = resolved.tried && resolved.tried.length > 0
131
+ ? ` (tried: ${resolved.tried.join('; ')})`
132
+ : '';
133
+ console.error(`notice: no canonical clone configured for scope '${paths.scope}' on this host — workspace-only write${tail}`);
134
+ }
135
+ }
136
+
82
137
  // Canonical FIRST. Resolved the same way `--file` already works for
83
138
  // every other scope-aware command — an arbitrary path, its own
84
139
  // .sops.yaml expected alongside it, reusing THIS host's key material
85
140
  // (same recipients in practice for a workspace/canonical-clone pair).
86
141
  // Any failure here (existing canonical store undecryptable, sops
87
142
  // encrypt failure) throws and propagates straight out of runSet —
88
- // the workspace write below is never reached.
89
- if (canonicalClone) {
90
- const canonicalPaths = overrides.canonicalPaths || resolveScopePaths({
91
- file: canonicalClone,
143
+ // the workspace write below is never reached. Ordering pinned by
144
+ // canonical-clone.test.mjs — do not reorder.
145
+ if (effectiveCanonical) {
146
+ const canonicalPaths = canonicalPathsFromOverride || resolveScopePaths({
147
+ file: effectiveCanonical.file,
92
148
  keyDir: paths.keyDir,
93
149
  keyFile: paths.keyFile,
94
150
  });
@@ -165,6 +165,11 @@ export function defaultPaths(overrides = {}) {
165
165
  sopsConfig: join(vaultDir, '.sops.yaml'),
166
166
  keyDir,
167
167
  keyFile: join(keyDir, 'age-key.txt'),
168
+ // Retained on the paths object so downstream consumers (canonical-
169
+ // clone auto-discovery in commands/set.mjs) can honour the SAME home
170
+ // the caller injected, instead of falling back to the real user's
171
+ // homedir() and cross-contaminating test isolation.
172
+ home,
168
173
  };
169
174
  }
170
175
 
@@ -220,6 +225,7 @@ export function resolveScopePaths(opts = {}) {
220
225
  scope: opts.scope ?? null,
221
226
  scoped: true,
222
227
  layout: 'file',
228
+ home,
223
229
  };
224
230
  }
225
231
 
@@ -260,6 +266,7 @@ export function resolveScopePaths(opts = {}) {
260
266
  scope: opts.scope,
261
267
  scoped: true,
262
268
  layout: 'workspace',
269
+ home,
263
270
  };
264
271
  }
265
272
 
@@ -275,6 +282,7 @@ export function resolveScopePaths(opts = {}) {
275
282
  scope: opts.scope,
276
283
  scoped: true,
277
284
  layout: 'canonical',
285
+ home,
278
286
  };
279
287
  }
280
288
 
@@ -332,6 +340,138 @@ function readDefaultsDefaultScope(home) {
332
340
  }
333
341
  }
334
342
 
343
+ // ── Canonical-clone target resolution (set only) ─────────────────────
344
+ //
345
+ // When `set` runs on a scoped vault WITHOUT --canonical-clone (and WITHOUT
346
+ // --no-canonical), auto-discover the canonical clone's vault file for the
347
+ // resolved scope. Resolution order (each tier tried in turn, first hit
348
+ // wins; every miss recorded in `tried` for the absence-notice detail):
349
+ //
350
+ // 1. "canonicalClone" in ~/.config/stravica-pa/defaults.json — two
351
+ // shapes supported:
352
+ // - object (scope → file map):
353
+ // { "canonicalClone": { "seo-ops": "/abs/path/seo-ops.enc.json" } }
354
+ // - string (root dir): derives <root>/vault/<scope>.enc.json:
355
+ // { "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
356
+ // This CLI never writes defaults.json.
357
+ // 2. Estate convention discovery under ~/stravica-canonical/*/vault/
358
+ // <scope>.enc.json — the ~/stravica-canonical/ layout is the
359
+ // established root for canonical clones on every host (see
360
+ // library-sync-cycle's default --repos-root, dex-cutover-driver's
361
+ // reposRoot, and the routine references across projects/). Exactly
362
+ // one match wins; zero matches or multiple matches skips this tier
363
+ // (multiple is recorded as ambiguity so the notice explains WHY the
364
+ // auto-write did not happen).
365
+ //
366
+ // A resolved-but-nonexistent file (config points at a path that isn't
367
+ // there) is treated as a miss with a `tried` line, not a bootstrap — the
368
+ // auto-write path only ever writes to canonicals that already exist.
369
+ // Explicit --canonical-clone still bootstraps missing files (it always did).
370
+
371
+ function readDefaultsCanonicalClone(home) {
372
+ const file = defaultsFilePath(home);
373
+ if (!existsSync(file)) return { file, shape: null, value: null, reason: 'file not present' };
374
+ let parsed;
375
+ try {
376
+ parsed = JSON.parse(readFileSync(file, 'utf-8'));
377
+ } catch {
378
+ return { file, shape: null, value: null, reason: 'file is not valid JSON' };
379
+ }
380
+ if (!parsed || typeof parsed !== 'object' || !('canonicalClone' in parsed)) {
381
+ return { file, shape: null, value: null, reason: 'canonicalClone key not set' };
382
+ }
383
+ const v = parsed.canonicalClone;
384
+ if (typeof v === 'string' && v.trim() !== '') {
385
+ return { file, shape: 'root', value: resolve(v.trim()), reason: null };
386
+ }
387
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
388
+ return { file, shape: 'map', value: v, reason: null };
389
+ }
390
+ return { file, shape: null, value: null, reason: 'canonicalClone must be a non-empty string or a scope→file object' };
391
+ }
392
+
393
+ // Directory scan for the estate convention: ~/stravica-canonical/<repo>/
394
+ // vault/<scope>.enc.json (glob-shape ~/stravica-canonical/*/vault/…enc.json;
395
+ // slash-star notation avoided in this comment because JSDoc closes on */).
396
+ // Returns { file, source, detail } on exactly-one hit, or
397
+ // { file: null, reason } describing the miss (none / ambiguous / root
398
+ // absent / unreadable). Never throws — a scan failure is a miss, not a
399
+ // secrets-set failure.
400
+ function discoverConventionalCanonical(home, scope) {
401
+ const root = join(home, 'stravica-canonical');
402
+ if (!existsSync(root)) {
403
+ return { file: null, reason: `no ${root} directory` };
404
+ }
405
+ let entries;
406
+ try {
407
+ entries = readdirSync(root, { withFileTypes: true });
408
+ } catch (err) {
409
+ return { file: null, reason: `${root} unreadable (${err && err.code ? err.code : 'error'})` };
410
+ }
411
+ const hits = [];
412
+ for (const ent of entries) {
413
+ if (!ent.isDirectory()) continue;
414
+ const candidate = join(root, ent.name, 'vault', `${scope}.enc.json`);
415
+ if (existsSync(candidate)) hits.push(candidate);
416
+ }
417
+ if (hits.length === 1) {
418
+ return { file: hits[0], source: 'convention', detail: `~/stravica-canonical/*/vault/${scope}.enc.json` };
419
+ }
420
+ if (hits.length === 0) {
421
+ return { file: null, reason: `no ~/stravica-canonical/*/vault/${scope}.enc.json` };
422
+ }
423
+ return { file: null, reason: `ambiguous — ${hits.length} matches under ~/stravica-canonical: ${hits.join(', ')}` };
424
+ }
425
+
426
+ /**
427
+ * Resolve the canonical clone's vault file for `scope`. Returns
428
+ * { file, source, detail, tried } on hit — file is an absolute path,
429
+ * source is 'defaults-map' |
430
+ * 'defaults-root' | 'convention'.
431
+ * { file: null, tried } on miss — tried is a list of the
432
+ * per-tier miss reasons, in
433
+ * priority order.
434
+ * Never throws. Never a failure — absence is a notice case, not an error.
435
+ * Scope is required; callers must not invoke this on the legacy vault
436
+ * fall-through (no scope → no natural canonical mirror).
437
+ */
438
+ export function resolveCanonicalCloneTarget(opts = {}) {
439
+ const home = opts.home ?? homedir();
440
+ const scope = opts.scope;
441
+ const tried = [];
442
+ if (!scope) return { file: null, tried: ['no scope resolved (legacy fallback)'] };
443
+
444
+ const defaults = readDefaultsCanonicalClone(home);
445
+ if (defaults.shape === 'map') {
446
+ const entry = defaults.value[scope];
447
+ if (typeof entry === 'string' && entry.trim() !== '') {
448
+ const file = resolve(entry.trim());
449
+ if (existsSync(file)) {
450
+ return { file, source: 'defaults-map', detail: `${defaults.file} → canonicalClone["${scope}"]`, tried };
451
+ }
452
+ tried.push(`${defaults.file} → canonicalClone["${scope}"] = ${file} (path not present)`);
453
+ } else {
454
+ tried.push(`${defaults.file} → canonicalClone map has no entry for "${scope}"`);
455
+ }
456
+ } else if (defaults.shape === 'root') {
457
+ const file = join(defaults.value, 'vault', `${scope}.enc.json`);
458
+ if (existsSync(file)) {
459
+ return { file, source: 'defaults-root', detail: `${defaults.file} → canonicalClone root ${defaults.value}`, tried };
460
+ }
461
+ tried.push(`${defaults.file} → canonicalClone root ${defaults.value} → ${file} (path not present)`);
462
+ } else {
463
+ tried.push(`${defaults.file} canonicalClone: ${defaults.reason}`);
464
+ }
465
+
466
+ const conv = discoverConventionalCanonical(home, scope);
467
+ if (conv.file) {
468
+ return { file: conv.file, source: conv.source, detail: conv.detail, tried };
469
+ }
470
+ tried.push(`convention: ${conv.reason}`);
471
+
472
+ return { file: null, tried };
473
+ }
474
+
335
475
  /**
336
476
  * Walk up from `cwd` for the nearest ancestor directory that sits under
337
477
  * an "agents" path segment AND contains identity.json. Returns the file
File without changes
File without changes
File without changes
File without changes
@@ -9,8 +9,8 @@
9
9
  "properties": {
10
10
  "id": {
11
11
  "type": "string",
12
- "pattern": "^w-\\d{4}-\\d{2}-\\d{2}-\\d{3}$",
13
- "description": "Work item identifier in the format w-YYYY-MM-DD-NNN"
12
+ "pattern": "^w-\\d{4}-\\d{2}-\\d{2}(?:-[a-z]{2,12})?-\\d{3}$",
13
+ "description": "Work item identifier. Two forms are permanently valid: legacy `w-YYYY-MM-DD-NNN` (pre-2026-08-12 items) and writer-scoped `w-YYYY-MM-DD-<author>-NNN` (post-R1 mints; author = /^[a-z]{2,12}$/ from operator/config/authors.json). New allocators must always emit the writer-scoped form — see packages/core/src/workItemId/index.mjs."
14
14
  },
15
15
  "projectId": {
16
16
  "type": ["string", "null"],
@@ -0,0 +1,219 @@
1
+ // workItemId/index.mjs — the canonical work-item-id primitive.
2
+ //
3
+ // One place, every rule. Every writer on the estate MUST parse work-item
4
+ // ids through `parseWorkItemId` (accepts both legal forms) and MUST mint
5
+ // new ids through `buildWorkItemId` (always the writer-scoped form).
6
+ // Nothing outside this module gets to know the shape of an id, and
7
+ // nothing outside this module gets to decide what the current writer's
8
+ // author token is.
9
+ //
10
+ // ────────────────────────────────────────────────────────────────────────
11
+ // Background — w-2026-08-12-001, R1 of the library remediation train.
12
+ //
13
+ // The estate has ≥5 autonomous writers competing for one filename space
14
+ // (`work/w-YYYY-MM-DD-NNN.json`): Dave's macbook session, Dex's
15
+ // wsd-laptop session, librarian-pr-intake on ops-01, upstream-triage on
16
+ // smarthome01, Baz manual. Each allocated the next-free NNN from its own
17
+ // locally-visible git state, and sync latency meant visible state lied.
18
+ // Four dual-mint collisions in two weeks; the 2026-08-11 collision
19
+ // wedged the ops-01 librarian sweep for 2+ hours (see incident
20
+ // `2026-08-11-wedge-root-cause-d024.md` and library architecture review
21
+ // `2026-08-11-library-architecture-review.md` §2.4 collision surface).
22
+ //
23
+ // Baz ratified the fix 2026-08-11: writer-scoped per-day sequences. Each
24
+ // writer gets its own NNN space, keyed by an author token from the
25
+ // authors registry (`operator/config/authors.json`). Cross-writer
26
+ // collision is now structurally impossible with zero coordination.
27
+ // Legacy ids without an author segment remain valid PARSE targets
28
+ // forever (forward-only migration; existing items never renumbered) but
29
+ // no allocator may ever MINT them again.
30
+ //
31
+ // ────────────────────────────────────────────────────────────────────────
32
+ // The two legal forms
33
+ //
34
+ // legacy: w-YYYY-MM-DD-NNN e.g. w-2026-08-11-025
35
+ // new: w-YYYY-MM-DD-<author>-NNN e.g. w-2026-08-12-dave-001
36
+ //
37
+ // Author token = /^[a-z]{2,12}$/ (lowercase letters only, 2–12 chars).
38
+ // Deliberately all-alpha: keeps the parse alternation unambiguous
39
+ // against the NNN segment (any middle segment starting with a digit is
40
+ // legacy) and mirrors the registry's `dave`/`dex`/`sam`/`max`/`ian`/
41
+ // `baz` shape without inviting alphanumeric drift.
42
+ //
43
+ // NNN = /^\d{3}$/, 001–999, zero-padded. NNN space is per-author-per-day,
44
+ // which is why we never run out of headroom in practice.
45
+
46
+ const AUTHOR_TOKEN_RE = /^[a-z]{2,12}$/;
47
+
48
+ // Two-branch alternation — the ONE-line spec of every valid id in the
49
+ // estate. Every other regex in this repo is either this one, or (for
50
+ // filenames) this one with a `\.json` suffix.
51
+ const WORK_ITEM_ID_RE = /^w-(\d{4}-\d{2}-\d{2})(?:-([a-z]{2,12}))?-(\d{3})$/;
52
+ const WORK_ITEM_FILENAME_RE = /^w-(\d{4}-\d{2}-\d{2})(?:-([a-z]{2,12}))?-(\d{3})\.json$/;
53
+
54
+ /**
55
+ * True iff `s` is a well-formed author token.
56
+ * @param {unknown} s
57
+ * @returns {boolean}
58
+ */
59
+ export function isValidAuthorToken(s) {
60
+ return typeof s === 'string' && AUTHOR_TOKEN_RE.test(s);
61
+ }
62
+
63
+ /**
64
+ * Parse a work-item id (with or without `.json`). Accepts both the
65
+ * legacy `w-YYYY-MM-DD-NNN` form and the writer-scoped
66
+ * `w-YYYY-MM-DD-<author>-NNN` form. Returns `null` if the string is
67
+ * not a valid id in either form — callers decide how to handle that.
68
+ *
69
+ * @param {string} idOrFilename
70
+ * @returns {{ date: string, author: string | null, nnn: number, id: string, isLegacy: boolean } | null}
71
+ */
72
+ export function parseWorkItemId(idOrFilename) {
73
+ if (typeof idOrFilename !== 'string') return null;
74
+ const bare = idOrFilename.endsWith('.json')
75
+ ? idOrFilename.slice(0, -'.json'.length)
76
+ : idOrFilename;
77
+ const m = bare.match(WORK_ITEM_ID_RE);
78
+ if (!m) return null;
79
+ const date = m[1];
80
+ const author = m[2] || null;
81
+ const nnn = parseInt(m[3], 10);
82
+ return { date, author, nnn, id: bare, isLegacy: author === null };
83
+ }
84
+
85
+ /**
86
+ * True iff `s` is a well-formed work-item id (either form). Convenience
87
+ * wrapper around `parseWorkItemId` for the many callers that only need
88
+ * a yes/no.
89
+ *
90
+ * @param {unknown} s
91
+ * @returns {boolean}
92
+ */
93
+ export function isValidWorkItemId(s) {
94
+ return parseWorkItemId(String(s)) !== null;
95
+ }
96
+
97
+ /**
98
+ * Mint a writer-scoped id. NEW writes MUST always go through this
99
+ * function; the legacy form is parse-only and no allocator may re-emit
100
+ * it. Throws if `author` is not a valid token — that is a caller bug
101
+ * (an allocator with no resolved author identity), not a runtime input.
102
+ *
103
+ * @param {object} opts
104
+ * @param {string} opts.date YYYY-MM-DD
105
+ * @param {string} opts.author registry-validated author token
106
+ * @param {number} opts.nnn 1..999
107
+ * @returns {string}
108
+ */
109
+ export function buildWorkItemId({ date, author, nnn }) {
110
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date))) {
111
+ throw new Error(`buildWorkItemId: bad date: ${date}`);
112
+ }
113
+ if (!isValidAuthorToken(author)) {
114
+ throw new Error(
115
+ `buildWorkItemId: bad author token: ${JSON.stringify(author)} (expected /^[a-z]{2,12}$/)`,
116
+ );
117
+ }
118
+ if (!Number.isInteger(nnn) || nnn < 1 || nnn > 999) {
119
+ throw new Error(`buildWorkItemId: nnn out of range: ${nnn} (expected 1..999)`);
120
+ }
121
+ return `w-${date}-${author}-${String(nnn).padStart(3, '0')}`;
122
+ }
123
+
124
+ /**
125
+ * Resolve the current caller's author token. Precedence, highest first:
126
+ *
127
+ * 1. `opts.explicit` — an explicit token passed by the caller
128
+ * (e.g. a HQ tool that KNOWS it is `max`
129
+ * or `ian`). ALWAYS wins.
130
+ * 2. `PA_AUTHOR_TOKEN` env — session/host default.
131
+ * 3. `opts.preferencesPaName` — `paName` from
132
+ * `operator/config/preferences.json`,
133
+ * the interactive-session fallback.
134
+ *
135
+ * Hard-errors with an actionable message naming every source if none
136
+ * resolves. Rationale: an allocator without a resolved author is a
137
+ * design bug — silently defaulting to a hard-coded token would put us
138
+ * back in the collision class this whole R1 exists to close.
139
+ *
140
+ * The precedence ordering is load-bearing: HQ tools deployed on ops-01
141
+ * (which shares $HOME across many launch contexts) MUST be able to pin
142
+ * their own token via the explicit param without an ambient
143
+ * `PA_AUTHOR_TOKEN` inherited from the parent shell silently
144
+ * overriding them. Tested by
145
+ * `test/workItemId/resolveAuthorToken.test.mjs`.
146
+ *
147
+ * @param {object} [opts]
148
+ * @param {string} [opts.explicit]
149
+ * @param {string} [opts.envToken] — inject env for tests; defaults to process.env.PA_AUTHOR_TOKEN
150
+ * @param {string} [opts.preferencesPaName] — `paName` field from preferences.json, if loaded
151
+ * @returns {string}
152
+ */
153
+ export function resolveAuthorToken(opts = {}) {
154
+ const envToken = 'envToken' in opts ? opts.envToken : process.env.PA_AUTHOR_TOKEN;
155
+ const candidates = [
156
+ { source: 'explicit param', value: opts.explicit },
157
+ { source: 'PA_AUTHOR_TOKEN env var', value: envToken },
158
+ { source: 'preferences.json paName', value: opts.preferencesPaName },
159
+ ];
160
+ for (const { source, value } of candidates) {
161
+ if (value == null || value === '') continue;
162
+ if (!isValidAuthorToken(value)) {
163
+ throw new Error(
164
+ `resolveAuthorToken: ${source} is not a valid author token: ${JSON.stringify(value)} (expected /^[a-z]{2,12}$/)`,
165
+ );
166
+ }
167
+ return value;
168
+ }
169
+ throw new Error(
170
+ 'resolveAuthorToken: no author token resolved. Set one of, in order of precedence: ' +
171
+ '(1) an explicit token via the caller\'s API, ' +
172
+ '(2) the PA_AUTHOR_TOKEN environment variable, ' +
173
+ '(3) `paName` in operator/config/preferences.json. ' +
174
+ 'Token must match /^[a-z]{2,12}$/. Registry lives at operator/config/authors.json.',
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Compute the highest NNN already allocated for a given `date` +
180
+ * `author` slot from a bag of parsed work-item filenames. Legacy ids
181
+ * (no author segment) DO NOT count toward any author's sequence — each
182
+ * writer's per-day NNN space starts from 001 on the day writer-scoping
183
+ * takes effect, and legacy items just occupy a separate, older space.
184
+ *
185
+ * Purpose: the arithmetic every allocator does is identical; keep the
186
+ * rule in one place so a new writer added to the registry doesn't need
187
+ * to re-implement it.
188
+ *
189
+ * @param {string[]} filenames raw directory entries
190
+ * @param {string} date YYYY-MM-DD
191
+ * @param {string} author the author whose sequence is being read
192
+ * @returns {number} 0 if the author has no items yet today
193
+ */
194
+ export function maxNForAuthorOnDate(filenames, date, author) {
195
+ if (!isValidAuthorToken(author)) {
196
+ throw new Error(
197
+ `maxNForAuthorOnDate: bad author: ${JSON.stringify(author)} (expected /^[a-z]{2,12}$/)`,
198
+ );
199
+ }
200
+ let max = 0;
201
+ for (const name of filenames) {
202
+ const parsed = parseWorkItemId(name);
203
+ if (!parsed) continue;
204
+ if (parsed.date !== date) continue;
205
+ if (parsed.author !== author) continue;
206
+ if (parsed.nnn > max) max = parsed.nnn;
207
+ }
208
+ return max;
209
+ }
210
+
211
+ /**
212
+ * The regex sources, exported for callers that MUST use a raw string
213
+ * (JSON Schema `pattern`, other language regexes). Do not reach into
214
+ * `.source` in JavaScript — call `parseWorkItemId`/`isValidWorkItemId`
215
+ * instead.
216
+ */
217
+ export const WORK_ITEM_ID_PATTERN = WORK_ITEM_ID_RE.source;
218
+ export const WORK_ITEM_FILENAME_PATTERN = WORK_ITEM_FILENAME_RE.source;
219
+ export const AUTHOR_TOKEN_PATTERN = AUTHOR_TOKEN_RE.source;
File without changes
File without changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.19",
4
- "description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
3
+ "version": "0.1.21",
4
+ "description": "Digital Operator PA \u2014 single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "exports": {
@@ -49,10 +49,11 @@
49
49
  "engines": {
50
50
  "node": ">=24"
51
51
  },
52
- "//": "test pins --test-isolation=none: default process-isolation mode silently undercounts (drops passed tests from totals) when a test awaits async work while stdout is patched by a capture() helper — see packages/cli/test/secrets. Counts must never lie.",
53
52
  "scripts": {
54
53
  "build": "node build.mjs",
54
+ "prepack": "node build.mjs",
55
55
  "test": "node --test --test-isolation=none test/*.test.mjs",
56
56
  "dry-publish": "node build.mjs && node scripts/dry-publish.mjs"
57
- }
58
- }
57
+ },
58
+ "//": "test pins --test-isolation=none: default process-isolation mode silently undercounts (drops passed tests from totals) when a test awaits async work while stdout is patched by a capture() helper \u2014 see packages/cli/test/secrets. Counts must never lie."
59
+ }