@hasna/instructions 0.4.19 → 0.4.20

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.
package/README.md CHANGED
@@ -331,14 +331,22 @@ workflows:
331
331
  - `{{PROJECT_DASHBOARD_DIR}}` -> `.hasna/project`
332
332
  - `{{PROJECT_DASHBOARD_RENDER_MANIFEST}}` -> `.hasna/project/dashboard/render.json`
333
333
  - `{{PROJECT_DASHBOARD_SNAPSHOTS_DIR}}` -> `.hasna/project/dashboard/snapshots`
334
- - `{{PROJECT_CHANNEL_PREFIX}}` -> `iproj-`
334
+ - `{{PROJECT_CHANNEL_PREFIX}}` -> `""` (no prefix; the channel is the normalized project slug)
335
+
336
+ Existing profiles can be migrated in place without deleting or recreating them:
337
+
338
+ ```bash
339
+ instructions profile update linux-arm64 \
340
+ --var PROJECT_CHANNEL_PREFIX= \
341
+ --unset-var LEGACY_VARIABLE
342
+ ```
335
343
 
336
344
  `instructions init` and `bun run seed` seed the
337
345
  `agent-managed-project-dashboard-standard` reference. It documents the standard
338
346
  `.hasna/project` layout, `projects dashboard *` commands, provider panel
339
- commands, `#iproj-*` channel naming, durable todos/goal workflow, and the rule
340
- that dashboards must show ids/statuses/evidence refs instead of raw private
341
- documents or secrets.
347
+ commands, normalized project-slug channel naming, durable todos/goal workflow,
348
+ and the rule that dashboards must show ids/statuses/evidence refs instead of raw
349
+ private documents or secrets.
342
350
 
343
351
  ## License
344
352
 
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=add-reference-update.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"add-reference-update.test.d.ts","sourceRoot":"","sources":["../../src/cli/add-reference-update.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=doctor-reference-duplicates.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doctor-reference-duplicates.test.d.ts","sourceRoot":"","sources":["../../src/cli/doctor-reference-duplicates.test.ts"],"names":[],"mappings":""}
package/dist/cli/index.js CHANGED
@@ -13882,6 +13882,7 @@ import { basename as basename7, join as join15, resolve as resolve8 } from "path
13882
13882
 
13883
13883
  // src/lib/config-target-identity.ts
13884
13884
  init_apply();
13885
+ init_database();
13885
13886
  function findConfigsByTargetPath(configs, targetPath) {
13886
13887
  const wanted = normalizeTargetPath(targetPath);
13887
13888
  return configs.filter((config) => {
@@ -13892,6 +13893,20 @@ function findConfigsByTargetPath(configs, targetPath) {
13892
13893
  return normalizeTargetPath(config.target_path) === wanted;
13893
13894
  });
13894
13895
  }
13896
+ function findReferenceConfigsByName(configs, name) {
13897
+ const wantedSlug = slugify(name);
13898
+ return configs.filter((config) => config.kind === "reference" && (config.name === name || slugify(config.name) === wantedSlug));
13899
+ }
13900
+ function findDuplicateReferenceNameGroups(configs) {
13901
+ const groups = new Map;
13902
+ for (const config of configs) {
13903
+ if (config.kind !== "reference")
13904
+ continue;
13905
+ const key = slugify(config.name);
13906
+ groups.set(key, [...groups.get(key) ?? [], config]);
13907
+ }
13908
+ return [...groups.entries()].filter(([, rows]) => rows.length > 1).map(([, rows]) => ({ name: rows[0].name, configs: rows }));
13909
+ }
13895
13910
  function findDuplicateTargetPathGroups(configs) {
13896
13911
  const groups = new Map;
13897
13912
  for (const config of configs) {
@@ -14921,7 +14936,7 @@ var PROJECT_DASHBOARD_PROFILE_VARIABLES = {
14921
14936
  PROJECT_DASHBOARD_DIR: ".hasna/project",
14922
14937
  PROJECT_DASHBOARD_RENDER_MANIFEST: ".hasna/project/dashboard/render.json",
14923
14938
  PROJECT_DASHBOARD_SNAPSHOTS_DIR: ".hasna/project/dashboard/snapshots",
14924
- PROJECT_CHANNEL_PREFIX: "iproj-"
14939
+ PROJECT_CHANNEL_PREFIX: ""
14925
14940
  };
14926
14941
  var PROJECT_DASHBOARD_STANDARD_CONTENT = `# Agent-Managed Project Dashboard Standard
14927
14942
 
@@ -14976,8 +14991,8 @@ context.
14976
14991
 
14977
14992
  ## Coordination
14978
14993
 
14979
- - Project conversation channels use \`iproj-<project-slug>\` in the CLI and are
14980
- displayed to humans as \`#iproj-<project-slug>\`.
14994
+ - Project conversation channels use the normalized project slug in the CLI and
14995
+ are displayed to humans as \`#<project-slug>\`.
14981
14996
  - Todos tasks are the source of truth for work; messages are only coordination.
14982
14997
  - Durable Codewith goal plans should own long-running implementation.
14983
14998
  - New implementation/verification work should route through task-triggered fresh
@@ -15082,10 +15097,15 @@ function mergeProfileSelectors(preset, existing) {
15082
15097
  };
15083
15098
  }
15084
15099
  function mergeProfileVariables(preset, existing) {
15085
- return {
15100
+ const variables = {
15086
15101
  ...preset ?? {},
15087
15102
  ...existing
15088
15103
  };
15104
+ for (const [key, value] of Object.entries(preset ?? {})) {
15105
+ if (value === "")
15106
+ variables[key] = value;
15107
+ }
15108
+ return variables;
15089
15109
  }
15090
15110
  function mergeUnique(preset, existing) {
15091
15111
  const values = [...new Set([...preset ?? [], ...existing ?? []])];
@@ -15665,6 +15685,15 @@ function parseVarArgs(values) {
15665
15685
  }
15666
15686
  return Object.keys(vars).length > 0 ? vars : undefined;
15667
15687
  }
15688
+ function parseUnsetVarArgs(values) {
15689
+ if (!values || values.length === 0)
15690
+ return [];
15691
+ const keys = [...new Set(values.map((value) => value.trim()))];
15692
+ if (keys.some((key) => key.length === 0 || key.includes("="))) {
15693
+ throw new Error('Invalid --unset-var (expected variable names without "=")');
15694
+ }
15695
+ return keys;
15696
+ }
15668
15697
  function parseProfileSelectors(opts) {
15669
15698
  const selectors = {};
15670
15699
  const os = splitCsv(opts.os);
@@ -15779,20 +15808,33 @@ program.command("add <path>").description("Ingest a file into the config DB").op
15779
15808
  const targetPath = abs.startsWith(homedir7()) ? abs.replace(homedir7(), "~") : abs;
15780
15809
  const name = opts.name || filePath.split("/").pop();
15781
15810
  const store = resolveConfigStore();
15782
- const existingOwners = opts.kind === "reference" ? [] : findConfigsByTargetPath(await store.listConfigs(), targetPath);
15811
+ const allConfigs = await store.listConfigs();
15812
+ const existingOwners = opts.kind === "reference" ? findReferenceConfigsByName(allConfigs, name) : findConfigsByTargetPath(allConfigs, targetPath);
15813
+ const isReference = opts.kind === "reference";
15814
+ const identityLabel = isReference ? `Reference config "${name}"` : targetPath;
15815
+ const identityNoun = isReference ? "name" : "path";
15783
15816
  if (existingOwners.length > 0 && !opts.update) {
15784
15817
  const owners = existingOwners.map((owner) => `${owner.slug} (${owner.id})`).join(", ");
15785
- console.error(chalk.red(`${targetPath} is already tracked by: ${owners}`));
15818
+ console.error(chalk.red(`${identityLabel} is already tracked by: ${owners}`));
15786
15819
  if (existingOwners.length > 1) {
15787
- console.error(chalk.red(` ${existingOwners.length} rows already collide on this path \u2014 apply order between them is undefined.`));
15820
+ console.error(chalk.red(` ${existingOwners.length} rows already collide on this ${identityNoun} \u2014 apply order between them is undefined.`));
15788
15821
  }
15789
15822
  console.error(chalk.dim(" Use `instructions add <path> --update` to refresh that row in place,"));
15790
- console.error(chalk.dim(" `instructions sync` to pull disk changes in, or `instructions delete <id>` first."));
15823
+ if (isReference) {
15824
+ console.error(chalk.dim(" or `instructions delete <id>` first."));
15825
+ } else {
15826
+ console.error(chalk.dim(" `instructions sync` to pull disk changes in, or `instructions delete <id>` first."));
15827
+ }
15791
15828
  process.exit(1);
15792
15829
  }
15793
15830
  let config;
15794
15831
  if (existingOwners.length > 0) {
15795
- const [target, ...rest] = existingOwners;
15832
+ const exactIndex = isReference ? existingOwners.findIndex((owner) => owner.name === name) : -1;
15833
+ const target = exactIndex >= 0 ? existingOwners[exactIndex] : existingOwners[0];
15834
+ const rest = existingOwners.filter((owner) => owner.id !== target.id);
15835
+ if (content !== target.content) {
15836
+ await store.createSnapshot(target.id, target.content, target.version);
15837
+ }
15796
15838
  config = await store.updateConfig(target.id, {
15797
15839
  content,
15798
15840
  format: fmt,
@@ -15802,7 +15844,7 @@ program.command("add <path>").description("Ingest a file into the config DB").op
15802
15844
  });
15803
15845
  console.log(chalk.green("\u2713") + ` Updated: ${chalk.bold(config.name)} ${chalk.dim(`(${config.slug})`)}`);
15804
15846
  if (rest.length > 0) {
15805
- console.log(chalk.yellow(` \u26A0 ${rest.length} other row(s) still target ${targetPath}: ${rest.map((r) => r.slug).join(", ")}`));
15847
+ console.log(chalk.yellow(` \u26A0 ${rest.length} other row(s) still share this ${identityNoun}: ${rest.map((r) => r.slug).join(", ")}`));
15806
15848
  console.log(chalk.yellow(" Apply order between them is undefined. Delete the extras."));
15807
15849
  }
15808
15850
  if (redacted.length > 0) {
@@ -16067,6 +16109,31 @@ profileCmd.command("create <name>").description("Create a new profile").option("
16067
16109
  });
16068
16110
  console.log(chalk.green("\u2713") + ` Created profile: ${chalk.bold(p.name)} ${chalk.dim(`(${p.slug})`)}`);
16069
16111
  });
16112
+ profileCmd.command("update <id>").description("Update an existing profile's variables in one store operation").option("--var <vars...>", "set profile variable(s) as KEY=VALUE").option("--unset-var <keys...>", "remove profile variable(s) by key").action(async (id, opts) => {
16113
+ try {
16114
+ const setVariables = parseVarArgs(opts.var) ?? {};
16115
+ const unsetVariables = parseUnsetVarArgs(opts.unsetVar);
16116
+ const setKeys = new Set(Object.keys(setVariables));
16117
+ const conflicts = unsetVariables.filter((key) => setKeys.has(key));
16118
+ if (conflicts.length > 0) {
16119
+ throw new Error(`Variables cannot be both set and unset: ${conflicts.join(", ")}`);
16120
+ }
16121
+ if (Object.keys(setVariables).length === 0 && unsetVariables.length === 0) {
16122
+ throw new Error("Provide --var KEY=VALUE and/or --unset-var KEY");
16123
+ }
16124
+ const store = resolveConfigStore();
16125
+ const profile = await store.getProfile(id);
16126
+ const variables = { ...profile.variables };
16127
+ for (const key of unsetVariables)
16128
+ delete variables[key];
16129
+ Object.assign(variables, setVariables);
16130
+ const updated = await store.updateProfile(profile.id, { variables });
16131
+ console.log(chalk.green("\u2713") + ` Updated profile: ${chalk.bold(updated.name)} ${chalk.dim(`(${updated.slug})`)}`);
16132
+ } catch (e) {
16133
+ console.error(chalk.red(formatCliError(e)));
16134
+ process.exit(1);
16135
+ }
16136
+ });
16070
16137
  profileCmd.command("show <id>").description("Show profile and its configs").option("--limit <n>", `max config rows (default ${DEFAULT_LIST_LIMIT})`).option("--cursor <n>", "zero-based pagination cursor").action(async (id, opts) => {
16071
16138
  try {
16072
16139
  const store = resolveConfigStore();
@@ -16843,6 +16910,20 @@ Stored configs (${allConfigs.length}):`));
16843
16910
  }
16844
16911
  console.log(chalk.dim(" Keep one row per path: `instructions delete <id>` for the extras."));
16845
16912
  }
16913
+ const duplicateReferenceNames = findDuplicateReferenceNameGroups(allConfigs);
16914
+ if (duplicateReferenceNames.length === 0) {
16915
+ pass("No reference config name is claimed by more than one row");
16916
+ } else {
16917
+ const rowCount = duplicateReferenceNames.reduce((total, group) => total + group.configs.length, 0);
16918
+ fail(`${duplicateReferenceNames.length} reference name(s) claimed by more than one row (${rowCount} rows) \u2014 only one is live in the next render`);
16919
+ for (const group of duplicateReferenceNames) {
16920
+ console.log(chalk.yellow(` ${group.name}`));
16921
+ for (const c of group.configs) {
16922
+ console.log(chalk.dim(` ${c.slug} (${c.id}) updated ${c.updated_at}`));
16923
+ }
16924
+ }
16925
+ console.log(chalk.dim(" Keep one row per name: `instructions delete <id>` for the extras."));
16926
+ }
16846
16927
  console.log(`
16847
16928
  ${issues === 0 ? chalk.green("\u2713 All checks passed") : chalk.yellow(`${issues} issue(s) found`)}`);
16848
16929
  });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=profile-update.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"profile-update.test.d.ts","sourceRoot":"","sources":["../../src/cli/profile-update.test.ts"],"names":[],"mappings":""}
package/dist/index.js CHANGED
@@ -11251,7 +11251,7 @@ var PROJECT_DASHBOARD_PROFILE_VARIABLES = {
11251
11251
  PROJECT_DASHBOARD_DIR: ".hasna/project",
11252
11252
  PROJECT_DASHBOARD_RENDER_MANIFEST: ".hasna/project/dashboard/render.json",
11253
11253
  PROJECT_DASHBOARD_SNAPSHOTS_DIR: ".hasna/project/dashboard/snapshots",
11254
- PROJECT_CHANNEL_PREFIX: "iproj-"
11254
+ PROJECT_CHANNEL_PREFIX: ""
11255
11255
  };
11256
11256
  var PROJECT_DASHBOARD_STANDARD_CONTENT = `# Agent-Managed Project Dashboard Standard
11257
11257
 
@@ -11306,8 +11306,8 @@ context.
11306
11306
 
11307
11307
  ## Coordination
11308
11308
 
11309
- - Project conversation channels use \`iproj-<project-slug>\` in the CLI and are
11310
- displayed to humans as \`#iproj-<project-slug>\`.
11309
+ - Project conversation channels use the normalized project slug in the CLI and
11310
+ are displayed to humans as \`#<project-slug>\`.
11311
11311
  - Todos tasks are the source of truth for work; messages are only coordination.
11312
11312
  - Durable Codewith goal plans should own long-running implementation.
11313
11313
  - New implementation/verification work should route through task-triggered fresh
@@ -11412,10 +11412,15 @@ function mergeProfileSelectors(preset, existing) {
11412
11412
  };
11413
11413
  }
11414
11414
  function mergeProfileVariables(preset, existing) {
11415
- return {
11415
+ const variables = {
11416
11416
  ...preset ?? {},
11417
11417
  ...existing
11418
11418
  };
11419
+ for (const [key, value] of Object.entries(preset ?? {})) {
11420
+ if (value === "")
11421
+ variables[key] = value;
11422
+ }
11423
+ return variables;
11419
11424
  }
11420
11425
  function mergeUnique(preset, existing) {
11421
11426
  const values = [...new Set([...preset ?? [], ...existing ?? []])];
@@ -15,6 +15,82 @@ import type { Config } from "../types/index.js";
15
15
  * Reference configs (`kind: "reference"`) own no target path and never match.
16
16
  */
17
17
  export declare function findConfigsByTargetPath(configs: Config[], targetPath: string): Config[];
18
+ /**
19
+ * Every reference-kind row already ingested under `name`.
20
+ *
21
+ * A reference config (`kind: "reference"`) owns no target_path — it is not
22
+ * mirrored 1:1 onto one file on disk, so `findConfigsByTargetPath` can never
23
+ * find it, by design (see that function's own doc comment). That left
24
+ * `add <path> --kind reference --update` with no identity signal at all: every
25
+ * re-ingest of a reference config's content minted a fresh row instead of
26
+ * updating the one that already existed, at every `--update` setting, because
27
+ * there was nothing to match on. Measured live 2026-08-04, todos 757cefdb: 20
28
+ * of 20 reference-kind rows in the fleet store had target_path=null.
29
+ *
30
+ * For a reference config, `name` IS the identity, in exactly the same sense a
31
+ * file-kind config's target_path is its identity: re-ingest with the same
32
+ * name, get the same row; re-ingest with a different name, get a different
33
+ * (or new) row. Two comparisons, both needed:
34
+ *
35
+ * - EXACT name match. `uniqueSlug` (db/database.ts) only de-duplicates the
36
+ * SLUG column, which carries a DB-level UNIQUE constraint — it never
37
+ * touches `name`, which carries no such constraint. So every prior
38
+ * duplicate this bug already produced still has the identical `name` and a
39
+ * `-1`, `-2`, ... suffixed slug. Measured live 2026-08-04 in the fleet
40
+ * store: 8 reference rows all named "Global Agent Rules Standard"
41
+ * (`global-agent-rules-standard-1` through `-8`), one identical SHA-256
42
+ * content hash across all 8, ingested on 6 different days — the exact
43
+ * shape this function exists to stop.
44
+ * - Slugified match, for a re-ingest whose `--name` differs only in case or
45
+ * punctuation from an existing row's name.
46
+ *
47
+ * CORRECTED 2026-08-04 (todos 195272ae, Finding 1): this used to compare
48
+ * the query's slug against each candidate's STORED `.slug` COLUMN
49
+ * (`config.slug === wantedSlug`). That is sound only for a candidate whose
50
+ * own slug was never disambiguated — the moment two rows already collide
51
+ * and `uniqueSlug` has suffixed one of them (`sample-rule-1`), its stored
52
+ * slug no longer equals what re-slugifying its OWN name produces, so the
53
+ * old comparison went blind to it. Reproduced live: row A "Sample Rule"
54
+ * (slug "sample-rule"), row B "sample rule" (slug disambiguated to
55
+ * "sample-rule-1" because A already held the base slug) — querying "Sample
56
+ * Rule" found only A. `add --update` then updated A, reported
57
+ * `rest.length === 0` (no "N other rows share this name" warning), and
58
+ * left B — the exact row a human would call a duplicate of A — untouched
59
+ * and unmentioned. This is the one population the slug clause exists to
60
+ * catch: it is impossible for a fresh, non-colliding row to need it, since
61
+ * an un-disambiguated slug already matches via `slugify(name)` trivially.
62
+ * Comparing against `slugify(config.name)` instead — recomputed from the
63
+ * candidate's own name rather than trusted from its (possibly
64
+ * disambiguated) stored column — fixes this without weakening the EXACT
65
+ * name clause above, which still independently catches the byte-identical
66
+ * case regardless: it can only ADD matches the old comparison missed, not
67
+ * remove any a query's name literally shares.
68
+ */
69
+ export declare function findReferenceConfigsByName(configs: Config[], name: string): Config[];
70
+ /**
71
+ * Groups of reference-kind rows that collide on identity, the mirror image of
72
+ * `findDuplicateTargetPathGroups` for the identity axis reference configs
73
+ * actually use. Only groups with more than one member are returned, so an
74
+ * empty result means the store is clean.
75
+ *
76
+ * CORRECTED 2026-08-04 (todos 195272ae, Finding 1): grouping used to key on
77
+ * the raw, exact `config.name` string. `doctor` (which calls this) therefore
78
+ * reported a store CLEAN for two rows whose names differ only in case or
79
+ * punctuation — precisely the pair `findReferenceConfigsByName` above treats
80
+ * as one identity (see its doc comment for the reproduction). The two
81
+ * functions disagreeing about what "the same reference config" means was the
82
+ * same defect class PR #57 fixed one level up, one file down: `doctor` a
83
+ * store clean, `add --update` half-fixing it. Keying on `slugify(config.name)`
84
+ * instead makes this agree with `findReferenceConfigsByName`'s identity
85
+ * notion — a case/punctuation variant is now reported as a duplicate group
86
+ * too, not only a byte-identical one. The reported `name` is the first
87
+ * colliding row's own (human-authored) name, for display — the grouping key
88
+ * itself is never surfaced.
89
+ */
90
+ export declare function findDuplicateReferenceNameGroups(configs: Config[]): Array<{
91
+ name: string;
92
+ configs: Config[];
93
+ }>;
18
94
  /**
19
95
  * Groups of rows that collide on one normalized target path. Only groups with
20
96
  * more than one member are returned, so an empty result means the store is
@@ -1 +1 @@
1
- {"version":3,"file":"config-target-identity.d.ts","sourceRoot":"","sources":["../../src/lib/config-target-identity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAGhD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAOvF;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAUlH"}
1
+ {"version":3,"file":"config-target-identity.d.ts","sourceRoot":"","sources":["../../src/lib/config-target-identity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAIhD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAOvF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAKpF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gCAAgC,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAU9G;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAUlH"}
@@ -2,6 +2,6 @@ import type { Config, ProfileVariables } from "../types/index.js";
2
2
  import { type ConfigStore } from "../data/config-store.js";
3
3
  export declare const PROJECT_DASHBOARD_STANDARD_SLUG = "agent-managed-project-dashboard-standard";
4
4
  export declare const PROJECT_DASHBOARD_PROFILE_VARIABLES: ProfileVariables;
5
- export declare const PROJECT_DASHBOARD_STANDARD_CONTENT = "# Agent-Managed Project Dashboard Standard\n\nThis standard applies to Hasna work projects under `Workspace/<division>/project/<slug>`.\nHumans do not work directly inside these folders; agents keep structure,\nevidence, tasks, knowledge, and dashboard output consistent.\n\n## Canonical Files\n\n- Project manifest root: `.hasna/project/`\n- Dashboard render manifest: `.hasna/project/dashboard/render.json`\n- Latest snapshot: `.hasna/project/dashboard/snapshots/latest.snapshot.json`\n- Dashboard schema ids come from `@hasna/contracts`.\n- Project folders may contain private documents, but render JSON must contain\n only ids, counts, statuses, resource refs, evidence refs, and redacted\n summaries.\n\n## Viewer Commands\n\nUse the Projects-owned viewer. Do not invent a separate per-project app unless\n`open-projects` cannot express the surface.\n\n```bash\nprojects dashboard snapshot <project> --write --json\nprojects dashboard render <project> --json\nprojects dashboard validate <project> --json\nPROJECTS_DASHBOARD_TOKEN=<token> projects dashboard serve <project> --host 0.0.0.0 --port <port>\n```\n\nNon-loopback serving must use `--token`, `PROJECTS_DASHBOARD_TOKEN`, or an\nexplicit `--trust-network` choice. Never put the token in a URL, task\nevidence, render spec, or report.\n\n## Provider Panels\n\nProvider CLIs emit bounded `hasna.project_panel.v1` summaries:\n\n```bash\ntodos project-panel --project <project> --json --contract\nfiles project-panel --project <project> --json --contract\nmailery project-panel --project <project> --limit 20 --json --contract\nconversations project-panel --project <project> --limit 30 --json --contract\nknowledge project-panel --project <project> --scope project --limit 30 --json --contract\nmementos --json project-panel --project <project> --contract\nreports project-panel --project <project> --json --contract\n```\n\nProviders must degrade to unavailable/error panels instead of dumping raw\ncontent. Mailery is workspace-scoped until explicit project-email mapping is\nconfigured; Knowledge must run from the project cwd or with the correct store\ncontext.\n\n## Coordination\n\n- Project conversation channels use `iproj-<project-slug>` in the CLI and are\n displayed to humans as `#iproj-<project-slug>`.\n- Todos tasks are the source of truth for work; messages are only coordination.\n- Durable Codewith goal plans should own long-running implementation.\n- New implementation/verification work should route through task-triggered fresh\n agent runs, not by opening or pasting into existing tmux panes.\n- Agent handoff should reference task ids, project ids, commit ids, evidence\n paths, and dashboard URLs without exposing secrets or raw private documents.\n\n## Report Rules\n\nReports and dashboards should use JSON Render/React Flow through\n`projects dashboard render`. Include decisions, open questions, bank/document\nids, tasks, and evidence refs. Exclude raw email bodies, account numbers, tax\nids, passport numbers, credentials, and contract clauses unless an explicit\napproved storage policy exists.\n";
5
+ export declare const PROJECT_DASHBOARD_STANDARD_CONTENT = "# Agent-Managed Project Dashboard Standard\n\nThis standard applies to Hasna work projects under `Workspace/<division>/project/<slug>`.\nHumans do not work directly inside these folders; agents keep structure,\nevidence, tasks, knowledge, and dashboard output consistent.\n\n## Canonical Files\n\n- Project manifest root: `.hasna/project/`\n- Dashboard render manifest: `.hasna/project/dashboard/render.json`\n- Latest snapshot: `.hasna/project/dashboard/snapshots/latest.snapshot.json`\n- Dashboard schema ids come from `@hasna/contracts`.\n- Project folders may contain private documents, but render JSON must contain\n only ids, counts, statuses, resource refs, evidence refs, and redacted\n summaries.\n\n## Viewer Commands\n\nUse the Projects-owned viewer. Do not invent a separate per-project app unless\n`open-projects` cannot express the surface.\n\n```bash\nprojects dashboard snapshot <project> --write --json\nprojects dashboard render <project> --json\nprojects dashboard validate <project> --json\nPROJECTS_DASHBOARD_TOKEN=<token> projects dashboard serve <project> --host 0.0.0.0 --port <port>\n```\n\nNon-loopback serving must use `--token`, `PROJECTS_DASHBOARD_TOKEN`, or an\nexplicit `--trust-network` choice. Never put the token in a URL, task\nevidence, render spec, or report.\n\n## Provider Panels\n\nProvider CLIs emit bounded `hasna.project_panel.v1` summaries:\n\n```bash\ntodos project-panel --project <project> --json --contract\nfiles project-panel --project <project> --json --contract\nmailery project-panel --project <project> --limit 20 --json --contract\nconversations project-panel --project <project> --limit 30 --json --contract\nknowledge project-panel --project <project> --scope project --limit 30 --json --contract\nmementos --json project-panel --project <project> --contract\nreports project-panel --project <project> --json --contract\n```\n\nProviders must degrade to unavailable/error panels instead of dumping raw\ncontent. Mailery is workspace-scoped until explicit project-email mapping is\nconfigured; Knowledge must run from the project cwd or with the correct store\ncontext.\n\n## Coordination\n\n- Project conversation channels use the normalized project slug in the CLI and\n are displayed to humans as `#<project-slug>`.\n- Todos tasks are the source of truth for work; messages are only coordination.\n- Durable Codewith goal plans should own long-running implementation.\n- New implementation/verification work should route through task-triggered fresh\n agent runs, not by opening or pasting into existing tmux panes.\n- Agent handoff should reference task ids, project ids, commit ids, evidence\n paths, and dashboard URLs without exposing secrets or raw private documents.\n\n## Report Rules\n\nReports and dashboards should use JSON Render/React Flow through\n`projects dashboard render`. Include decisions, open questions, bank/document\nids, tasks, and evidence refs. Exclude raw email bodies, account numbers, tax\nids, passport numbers, credentials, and contract clauses unless an explicit\napproved storage policy exists.\n";
6
6
  export declare function ensureProjectDashboardStandardConfig(store?: ConfigStore): Promise<Config>;
7
7
  //# sourceMappingURL=project-dashboard-standard.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"project-dashboard-standard.d.ts","sourceRoot":"","sources":["../../src/lib/project-dashboard-standard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE/E,eAAO,MAAM,+BAA+B,6CAA6C,CAAC;AAE1F,eAAO,MAAM,mCAAmC,EAAE,gBAKjD,CAAC;AAEF,eAAO,MAAM,kCAAkC,2hGAqE9C,CAAC;AAEF,wBAAsB,oCAAoC,CAAC,KAAK,GAAE,WAAkC,GAAG,OAAO,CAAC,MAAM,CAAC,CA4BrH"}
1
+ {"version":3,"file":"project-dashboard-standard.d.ts","sourceRoot":"","sources":["../../src/lib/project-dashboard-standard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE/E,eAAO,MAAM,+BAA+B,6CAA6C,CAAC;AAE1F,eAAO,MAAM,mCAAmC,EAAE,gBAKjD,CAAC;AAEF,eAAO,MAAM,kCAAkC,0hGAqE9C,CAAC;AAEF,wBAAsB,oCAAoC,CAAC,KAAK,GAAE,WAAkC,GAAG,OAAO,CAAC,MAAM,CAAC,CA4BrH"}
package/dist/mcp/index.js CHANGED
@@ -6957,7 +6957,7 @@ var init_sync_dir = __esm(() => {
6957
6957
  var require_package = __commonJS((exports, module) => {
6958
6958
  module.exports = {
6959
6959
  name: "@hasna/instructions",
6960
- version: "0.4.19",
6960
+ version: "0.4.20",
6961
6961
  description: "AI coding agent instruction & configuration manager \u2014 store, version, apply, and share all your AI coding configs. CLI + MCP + HTTP API (instructions-serve) + generated SDK + Dashboard.",
6962
6962
  type: "module",
6963
6963
  main: "dist/index.js",
@@ -7062,6 +7062,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
7062
7062
 
7063
7063
  // src/lib/config-target-identity.ts
7064
7064
  init_apply();
7065
+ init_database();
7065
7066
  function findConfigsByTargetPath(configs, targetPath) {
7066
7067
  const wanted = normalizeTargetPath(targetPath);
7067
7068
  return configs.filter((config) => {
@@ -7072,6 +7073,10 @@ function findConfigsByTargetPath(configs, targetPath) {
7072
7073
  return normalizeTargetPath(config.target_path) === wanted;
7073
7074
  });
7074
7075
  }
7076
+ function findReferenceConfigsByName(configs, name) {
7077
+ const wantedSlug = slugify(name);
7078
+ return configs.filter((config) => config.kind === "reference" && (config.name === name || slugify(config.name) === wantedSlug));
7079
+ }
7075
7080
 
7076
7081
  // src/mcp/server.ts
7077
7082
  init_sync_dir();
@@ -7178,7 +7183,7 @@ function summarizeApplyResult(result) {
7178
7183
  var TOOL_DOCS = {
7179
7184
  list_configs: "List configs. Params: category?, agent?, kind?, search?, limit?, cursor?, verbose?. Defaults to a paged compact envelope without content; use get_config for full content.",
7180
7185
  get_config: "Get a config by id or slug. Returns full config including content.",
7181
- create_config: "Create a new config. Required: name, content, category. Optional: agent, target_path, outputs, kind, format, tags, description, is_template. Refuses when target_path is already tracked by another config (one target path, one row) \u2014 use update_config on the owning row, or delete_config first. kind:'reference' owns no target path and is exempt.",
7186
+ create_config: "Create a new config. Required: name, content, category. Optional: agent, target_path, outputs, kind, format, tags, description, is_template. Refuses when target_path is already tracked by another config (one target path, one row) \u2014 use update_config on the owning row, or delete_config first. kind:'reference' owns no target path and is exempt from that check, but is instead refused when its name (or a case/punctuation variant of it) already tracks another reference config.",
7182
7187
  update_config: "Update a config by id or slug. Optional: content, name, tags, description, category, agent, target_path, outputs.",
7183
7188
  apply_config: "Apply a config through the shared ownership gate. Params: id_or_slug, dry_run?, verbose?. Returns results plus session-renderer-owned targets that were skipped.",
7184
7189
  sync_directory: "Sync a directory with the DB. Params: dir, direction ('from_disk'|'to_disk'). Returns sync result.",
@@ -7269,6 +7274,15 @@ function buildServer() {
7269
7274
  return err(`${targetPath} is already tracked by: ${named}.${collision}` + ` Use update_config on that row to refresh it in place, or delete_config first.`);
7270
7275
  }
7271
7276
  }
7277
+ if (kind === "reference") {
7278
+ const name2 = args["name"];
7279
+ const owners = findReferenceConfigsByName(await store.listConfigs(), name2);
7280
+ if (owners.length > 0) {
7281
+ const named = owners.map((owner) => `${owner.slug} (${owner.id})`).join(", ");
7282
+ const collision = owners.length > 1 ? ` ${owners.length} rows already collide on this name \u2014 apply order between them is undefined.` : "";
7283
+ return err(`Reference config "${name2}" is already tracked by: ${named}.${collision}` + ` Use update_config on that row to refresh it in place, or delete_config first.`);
7284
+ }
7285
+ }
7272
7286
  const c = await store.createConfig({
7273
7287
  name: args["name"],
7274
7288
  content: args["content"],
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AA4EnE,wBAAgB,WAAW,IAAI,MAAM,CAyTpC"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AA4EnE,wBAAgB,WAAW,IAAI,MAAM,CAyVpC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/instructions",
3
- "version": "0.4.19",
3
+ "version": "0.4.20",
4
4
  "description": "AI coding agent instruction & configuration manager \u2014 store, version, apply, and share all your AI coding configs. CLI + MCP + HTTP API (instructions-serve) + generated SDK + Dashboard.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",